feat: Add initial implementation for converting SVG nametag designs to multi-part STL files.

This commit is contained in:
2026-02-14 16:40:48 -05:00
commit 7960320004
4 changed files with 318 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.venv/

295
convert_nametags.py Normal file
View File

@@ -0,0 +1,295 @@
import os
import sys
import argparse
import logging
import numpy as np
import trimesh
from shapely.geometry import Polygon
from shapely.ops import unary_union
from svg.path import parse_path
from xml.dom import minidom
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Constants based on specs
BACKGROUND_THICKNESS = 3.0
TEXT_THICKNESS = 2.0
TEXT_Z_OFFSET = 3.0 # Starts on top of background
CURVE_RESOLUTION = 64 # number of segments per circle
# SVG Class mappings
CLASS_WHITE = 'st2'
CLASS_CYAN = 'st1'
def setup_args():
parser = argparse.ArgumentParser(description='Convert SVGs to Nametag STLs')
parser.add_argument('input_dir', help='Directory containing SVG files')
parser.add_argument('--output-dir', help='Output directory for STLs', default=None)
return parser.parse_args()
def get_paths_from_svg(svg_file):
"""
Parses the SVG file and separates paths into Background, White, and Cyan.
Returns a dictionary with list of svg.path objects.
"""
doc = minidom.parse(svg_file)
paths = {
'background': [],
'white': [],
'cyan': []
}
# Process all path elements
for path_node in doc.getElementsByTagName('path'):
d_str = path_node.getAttribute('d')
class_str = path_node.getAttribute('class')
if not d_str:
continue
try:
path_obj = parse_path(d_str)
except Exception as e:
logger.warning(f"Failed to parse path in {svg_file}: {e}")
continue
# Classify based on your specs
if class_str == CLASS_WHITE:
paths['white'].append(path_obj)
elif class_str == CLASS_CYAN:
paths['cyan'].append(path_obj)
else:
# Assume no class or other classes are background/outline
# But specifically, look for the main outline which usually has no class or a specific structure
# based on your SVG, the background is the first path with no class.
# We will treat any non-color path as background for now, or refine if needed.
paths['background'].append(path_obj)
doc.unlink()
return paths
def svg_path_to_shapely(path_obj, segments=CURVE_RESOLUTION):
"""
Converts an svg.path object to a shapely Polygon.
Handles curves by discretizing them.
"""
points = []
# Sample the path
# If it's a closed path, we want a polygon.
# Note: svg.path is a list of segments (Line, CubicBezier, etc.)
# Simple sampling approach
n_points = segments
for i in range(n_points + 1):
f = i / n_points
c = path_obj.point(f)
points.append((c.real, c.imag))
# Check if closed
if points[0] != points[-1]:
points.append(points[0]) # force close if needed, though svg paths might be open
# Create polygon. If inconsistent winding, trimesh usually handles it,
# but shapely needs valid rings.
try:
# Shapely requires valid linear rings
if len(points) < 3:
return None
return Polygon(points)
except Exception:
return None
def create_extrusion(paths, height, z_offset=0.0, scale=1.0):
"""
Takes a list of svg.path objects and converts to a 3D mesh.
Uses Shapely's symmetric_difference to handle holes in paths correctly (Even-Odd rule).
"""
from shapely.geometry import Polygon
from shapely.ops import unary_union
path_polygons = []
for p in paths:
path_list = list(p)
if not path_list:
continue
# Split fixed single path into separate loops (sub-paths)
loops = []
current_loop = [path_list[0]]
for i in range(1, len(path_list)):
# Detect jumps in the path (e.g. Move segments or new contours)
if abs(path_list[i].start - path_list[i-1].end) > 1e-4:
loops.append(current_loop)
current_loop = []
current_loop.append(path_list[i])
loops.append(current_loop)
# Build the final polygon for THIS path by XORing its loops
this_path_poly = Polygon()
for loop in loops:
pts = []
# Start of loop
start_p = loop[0].point(0)
pts.append((start_p.real * scale, -start_p.imag * scale))
for segment in loop:
seg_type = segment.__class__.__name__
# Adaptive sampling
if seg_type == 'Line':
num_samples = 1
elif seg_type in ['QuadraticBezier', 'CubicBezier', 'Arc']:
try:
length = segment.length()
num_samples = int(length * scale / 2.0)
num_samples = max(4, min(num_samples, 20))
except:
num_samples = 8
else:
num_samples = 4
for j in range(1, num_samples + 1):
point = segment.point(j / num_samples)
pts.append((point.real * scale, -point.imag * scale))
if len(pts) < 3:
continue
loop_poly = Polygon(pts)
if not loop_poly.is_valid:
loop_poly = loop_poly.buffer(0)
# Symmetric difference handles nested loops (Even-Odd fill)
# This is how most vector engines handle compound paths (holes)
this_path_poly = this_path_poly.symmetric_difference(loop_poly)
if not this_path_poly.is_empty:
path_polygons.append(this_path_poly)
if not path_polygons:
return None
# Merge all separate paths of the same color
# unary_union joins overlapping shapes (e.g. adjacent letters in a logo)
try:
merged_poly = unary_union(path_polygons)
except Exception as e:
logger.error(f"Failed to merge polygons: {e}")
return None
if merged_poly.is_empty:
return None
# Extrude the result
try:
# trimesh.creation.extrude_polygon expects a Polygon, so we iterate if it's a MultiPolygon
if hasattr(merged_poly, 'geoms'):
polys = list(merged_poly.geoms)
else:
polys = [merged_poly]
meshes = []
for poly in polys:
# extrude_polygon handles Polygon with holes correctly
m = trimesh.creation.extrude_polygon(poly, height=height)
meshes.append(m)
if not meshes:
return None
# Concatenate all parts (separate letters, etc.) into one mesh
if len(meshes) == 1:
mesh = meshes[0]
else:
mesh = trimesh.util.concatenate(meshes)
mesh.apply_translation([0, 0, z_offset])
return mesh
except Exception as e:
logger.error(f"Extrusion failed: {e}")
return None
def process_file(filepath, output_dir):
filename = os.path.basename(filepath)
base_name = os.path.splitext(filename)[0]
logger.info(f"Processing {filename}...")
paths = get_paths_from_svg(filepath)
parts = {
'Black': {'paths': paths['background'], 'height': BACKGROUND_THICKNESS, 'z': 0.0},
'White': {'paths': paths['white'], 'height': TEXT_THICKNESS, 'z': TEXT_Z_OFFSET},
'Cyan': {'paths': paths['cyan'], 'height': TEXT_THICKNESS, 'z': TEXT_Z_OFFSET}
}
# Calculate scale factor based on background
target_width = 87.80
scale_factor = 1.0
if parts['Black']['paths']:
all_bg_pts = []
for p in parts['Black']['paths']:
for i in range(11):
pt = p.point(i/10)
all_bg_pts.append(pt.real)
if all_bg_pts:
min_x = min(all_bg_pts)
max_x = max(all_bg_pts)
current_width = max_x - min_x
if current_width > 0:
scale_factor = target_width / current_width
logger.info(f" Scaling factor: {scale_factor:.4f} (Original width: {current_width:.2f} -> {target_width})")
file_output_dir = os.path.join(output_dir, base_name)
if not os.path.exists(file_output_dir):
os.makedirs(file_output_dir)
for color, data in parts.items():
if not data['paths']:
logging.info(f" No paths for {color} in {filename}")
continue
mesh = create_extrusion(data['paths'], data['height'], data['z'], scale=scale_factor)
if mesh:
out_name = f"{base_name}_{color}.stl"
out_path = os.path.join(file_output_dir, out_name)
mesh.export(out_path)
logging.info(f" Exported {out_path}")
def main():
args = setup_args()
input_path = args.input_dir
if not os.path.exists(input_path):
logger.error(f"Input path {input_path} does not exist")
sys.exit(1)
output_dir = args.output_dir if args.output_dir else os.path.join(os.path.dirname(input_path) if os.path.isfile(input_path) else input_path, 'stl_output')
if not os.path.exists(output_dir):
os.makedirs(output_dir)
files_to_process = []
if os.path.isfile(input_path):
if input_path.lower().endswith('.svg'):
files_to_process.append(input_path)
else:
for file in os.listdir(input_path):
if file.lower().endswith('.svg') and not file.startswith('._'):
full_path = os.path.join(input_path, file)
if os.path.isfile(full_path):
files_to_process.append(full_path)
for full_path in files_to_process:
try:
process_file(full_path, output_dir)
except Exception as e:
import traceback
logger.error(f"Failed to process {full_path}: {e}")
logger.error(traceback.format_exc())
if __name__ == '__main__':
main()

13
count_tris.py Normal file
View File

@@ -0,0 +1,13 @@
import trimesh
import sys
def count_triangles(file_path):
try:
mesh = trimesh.load(file_path)
print(f"{file_path}: {len(mesh.faces)} triangles")
except Exception as e:
print(f"Error reading {file_path}: {e}")
if __name__ == "__main__":
for f in sys.argv[1:]:
count_triangles(f)

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
trimesh
shapely
numpy
scipy
lxml
svg.path
networkx
mapbox_earcut