commit 7960320004969e229bd626a88a47d93a69f14f57 Author: unfunny Date: Sat Feb 14 16:40:48 2026 -0500 feat: Add initial implementation for converting SVG nametag designs to multi-part STL files. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..def4cc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv/ + diff --git a/convert_nametags.py b/convert_nametags.py new file mode 100644 index 0000000..e93fd3f --- /dev/null +++ b/convert_nametags.py @@ -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() diff --git a/count_tris.py b/count_tris.py new file mode 100644 index 0000000..0107a91 --- /dev/null +++ b/count_tris.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e85b837 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +trimesh +shapely +numpy +scipy +lxml +svg.path +networkx +mapbox_earcut