From 7a38de8ef2c14b3e3735938cc7c327cef0e1b68e Mon Sep 17 00:00:00 2001 From: brentperteet Date: Sat, 30 May 2026 10:50:23 -0500 Subject: [PATCH] initial commit --- svg_logo_to_stl.py | 332 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 svg_logo_to_stl.py diff --git a/svg_logo_to_stl.py b/svg_logo_to_stl.py new file mode 100644 index 0000000..018b77a --- /dev/null +++ b/svg_logo_to_stl.py @@ -0,0 +1,332 @@ +import argparse +import io +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + +import cairosvg +import numpy as np +import trimesh +from PIL import Image +from shapely.geometry import LinearRing, Polygon +from shapely.ops import unary_union +from skimage import measure, morphology + + +def render_svg_to_image(svg_path: Path, pixel_width: int = 2048) -> Image.Image: + png_output = io.BytesIO() + cairosvg.svg2png(url=str(svg_path), write_to=png_output, output_width=pixel_width) + png_output.seek(0) + return Image.open(png_output).convert("RGBA") + + +def clean_mask(mask: np.ndarray, min_size: int = 32) -> np.ndarray: + if min_size > 0: + cleaned = morphology.remove_small_objects(mask, max_size=min_size - 1) + else: + cleaned = mask + structure = np.ones((3, 3), dtype=bool) + cleaned = morphology.closing(cleaned, structure) + return cleaned + + +def color_mask(image: np.ndarray, target_rgb: tuple[int, int, int], tolerance: int = 64) -> np.ndarray: + alpha = image[..., 3] > 32 + diff = np.linalg.norm(image[..., :3].astype(np.int16) - np.array(target_rgb, dtype=np.int16), axis=-1) + return alpha & (diff <= tolerance) + + +def white_color_mask(image: np.ndarray, min_brightness: int = 220) -> np.ndarray: + alpha = image[..., 3] > 32 + rgb = image[..., :3] + brightness = np.min(rgb, axis=-1) + return alpha & (brightness >= min_brightness) + + +def signed_area(coords: np.ndarray) -> float: + x = coords[:, 0] + y = coords[:, 1] + return 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) + + +def mask_to_polygons(mask: np.ndarray, min_area: float = 10.0, simplify_tolerance: float = 1.0): + contours = measure.find_contours(mask.astype(np.uint8), 0.5) + shapes: list[tuple[Polygon, float]] = [] + for contour in contours: + if contour.shape[0] < 4: + continue + coords = np.column_stack((contour[:, 1], contour[:, 0])) + if not np.allclose(coords[0], coords[-1]): + coords = np.vstack([coords, coords[0]]) + area = signed_area(coords) + ring = LinearRing(coords) + if not ring.is_valid or ring.length == 0: + continue + poly = Polygon(ring) + if not poly.is_valid or abs(area) < min_area: + continue + shapes.append((poly, area)) + + if not shapes: + return [] + + exteriors: list[Polygon] = [poly for poly, area in shapes if area > 0] + holes: list[Polygon] = [poly for poly, area in shapes if area < 0] + if not exteriors: + shapes = sorted(shapes, key=lambda item: abs(item[1]), reverse=True) + exteriors = [shapes[0][0]] + holes = [poly for poly, _ in shapes[1:]] + + polygons = [] + assigned_holes = set() + for exterior in exteriors: + hole_list = [] + for hole in holes: + if exterior.contains(hole.representative_point()): + hole_list.append(hole.exterior.coords) + assigned_holes.add(hole) + poly = Polygon(exterior.exterior.coords, hole_list) + poly = poly.simplify(simplify_tolerance) + if poly.is_valid and poly.area >= min_area: + polygons.append(poly) + + for hole in holes: + if hole not in assigned_holes: + poly = hole.simplify(simplify_tolerance) + if poly.is_valid and poly.area >= min_area: + polygons.append(poly) + + return polygons + + +def create_extruded_mesh(polygons, height_mm: float, scale: float, y_flip: bool = True): + meshes = [] + for poly in polygons: + exterior = [(x * scale, ((-y if y_flip else y) * scale)) for x, y in poly.exterior.coords] + holes = [ + [(x * scale, ((-y if y_flip else y) * scale)) for x, y in ring.coords] + for ring in poly.interiors + ] + polygon = Polygon(exterior, holes) + if not polygon.is_valid or polygon.area == 0: + continue + try: + mesh = trimesh.creation.extrude_polygon(polygon, height_mm) + meshes.append(mesh) + except Exception: + continue + return trimesh.util.concatenate(meshes) if meshes else None + + +def build_logo_meshes(svg_path: Path, width_mm: float, base_thickness: float, feature_height: float, png_width: int): + image = render_svg_to_image(svg_path, pixel_width=png_width) + width_px, height_px = image.size + scale = width_mm / width_px + height_mm = height_px * scale + + img_array = np.array(image) + green_mask = clean_mask(color_mask(img_array, (57, 233, 145), tolerance=80)) + white_mask = clean_mask(white_color_mask(img_array, min_brightness=220)) + + green_polys = mask_to_polygons(green_mask) + white_polys = mask_to_polygons(white_mask) + + base = trimesh.creation.box(extents=(width_mm, height_mm, base_thickness)) + base.apply_translation((width_mm / 2.0, height_mm / 2.0, base_thickness / 2.0)) + + green_mesh = create_extruded_mesh(green_polys, feature_height, scale) + white_mesh = create_extruded_mesh(white_polys, feature_height, scale) + for mesh in (green_mesh, white_mesh): + if mesh is not None: + mesh.apply_translation((0.0, height_mm, base_thickness)) + + return base, green_mesh, white_mesh + + +def _mesh_to_3mf_object(mesh: trimesh.Trimesh, object_id: int, name: str, material_id: int) -> ET.Element: + obj = ET.Element('object', { + 'id': str(object_id), + 'name': name, + 'type': 'model' + }) + mesh_el = ET.SubElement(obj, 'mesh') + vertices_el = ET.SubElement(mesh_el, 'vertices') + for vertex in mesh.vertices: + ET.SubElement(vertices_el, 'vertex', { + 'x': str(float(vertex[0])), + 'y': str(float(vertex[1])), + 'z': str(float(vertex[2])) + }) + triangles_el = ET.SubElement(mesh_el, 'triangles') + for face in mesh.faces: + ET.SubElement(triangles_el, 'triangle', { + 'v1': str(int(face[0])), + 'v2': str(int(face[1])), + 'v3': str(int(face[2])), + 'materialid': str(material_id) + }) + return obj + + +def _add_basematerials(resources: ET.Element) -> int: + basematerials = ET.SubElement(resources, 'basematerials', {'id': '1'}) + ET.SubElement(basematerials, 'base', { + 'name': 'Base', + 'displaycolor': '#000000' + }) + ET.SubElement(basematerials, 'base', { + 'name': 'Green', + 'displaycolor': '#39e991' + }) + ET.SubElement(basematerials, 'base', { + 'name': 'White', + 'displaycolor': '#ffffff' + }) + return 1 + + +def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path): + model = ET.Element('model', { + 'xmlns': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02', + 'xmlns:m': 'http://schemas.microsoft.com/3dmanufacturing/material/2015/02', + 'unit': 'millimeter' + }) + resources = ET.SubElement(model, 'resources') + _add_basematerials(resources) + + object_ids = [] + current_id = 1 + material_map = {'base': 1, 'green': 2, 'white': 3} + for name, mesh in meshes.items(): + if mesh is None: + continue + material_id = material_map.get(name, 1) + resource = _mesh_to_3mf_object(mesh, current_id, name, material_id) + resources.append(resource) + object_ids.append(current_id) + current_id += 1 + + assembly_id = current_id + assembly = ET.SubElement(resources, 'object', { + 'id': str(assembly_id), + 'name': 'assembly', + 'type': 'model' + }) + components_el = ET.SubElement(assembly, 'components') + for object_id in object_ids: + ET.SubElement(components_el, 'component', { + 'objectid': str(object_id) + }) + + build = ET.SubElement(model, 'build') + ET.SubElement(build, 'item', {'objectid': str(assembly_id)}) + + xml_data = ET.tostring(model, encoding='utf-8', xml_declaration=True) + with zipfile.ZipFile(path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr('[Content_Types].xml', '''\n''') + zf.writestr('_rels/.rels', '''\n''') + zf.writestr('3D/3dmodel.model', xml_data) + return path + + +def save_3mf_parts(meshes: dict[str, trimesh.Trimesh], path: Path): + model = ET.Element('model', { + 'xmlns': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02', + 'xmlns:m': 'http://schemas.microsoft.com/3dmanufacturing/material/2015/02', + 'unit': 'millimeter' + }) + resources = ET.SubElement(model, 'resources') + _add_basematerials(resources) + + object_ids = [] + current_id = 1 + material_map = {'base': 1, 'green': 2, 'white': 3} + for name, mesh in meshes.items(): + if mesh is None: + continue + material_id = material_map.get(name, 1) + resource = _mesh_to_3mf_object(mesh, current_id, name, material_id) + resources.append(resource) + object_ids.append(current_id) + current_id += 1 + + build = ET.SubElement(model, 'build') + for object_id in object_ids: + ET.SubElement(build, 'item', {'objectid': str(object_id)}) + + xml_data = ET.tostring(model, encoding='utf-8', xml_declaration=True) + with zipfile.ZipFile(path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr('[Content_Types].xml', '''\n''') + zf.writestr('_rels/.rels', '''\n''') + zf.writestr('3D/3dmodel.model', xml_data) + return path + + +def save_mesh(mesh, path: Path): + if mesh is None: + return None + mesh.export(path) + return path + + +def main(): + parser = argparse.ArgumentParser(description="Convert a color SVG logo into a layered STL for multi-color printing.") + parser.add_argument("svg", help="Input SVG file path") + parser.add_argument("output", help="Base output file path (without extension is fine)") + parser.add_argument("--width-mm", type=float, default=100.0, help="Final model width in millimeters") + parser.add_argument("--base-thickness", type=float, default=0.8, help="Thickness of the black base in mm") + parser.add_argument("--feature-height", type=float, default=1.8, help="Height of the raised logo features above the base in mm") + parser.add_argument("--png-width", type=int, default=2048, help="Rasterization width for SVG rendering") + args = parser.parse_args() + + svg_path = Path(args.svg) + output_prefix = Path(args.output) + output_prefix.parent.mkdir(parents=True, exist_ok=True) + + base_mesh, green_mesh, white_mesh = build_logo_meshes( + svg_path, + args.width_mm, + args.base_thickness, + args.feature_height, + args.png_width, + ) + + base_path = output_prefix.with_name(output_prefix.stem + "_base.stl") + green_path = output_prefix.with_name(output_prefix.stem + "_green.stl") + white_path = output_prefix.with_name(output_prefix.stem + "_white.stl") + assembled_stl_path = output_prefix.with_name(output_prefix.stem + "_assembled.stl") + assembled_3mf_path = output_prefix.with_name(output_prefix.stem + "_assembled.3mf") + parts_3mf_path = output_prefix.with_name(output_prefix.stem + "_parts.3mf") + + saved_base = save_mesh(base_mesh, base_path) + saved_green = save_mesh(green_mesh, green_path) + saved_white = save_mesh(white_mesh, white_path) + + assembled_mesh = trimesh.util.concatenate([m for m in (base_mesh, green_mesh, white_mesh) if m is not None]) + assembled_mesh.export(assembled_stl_path) + + save_3mf({ + 'base': base_mesh, + 'green': green_mesh, + 'white': white_mesh, + }, assembled_3mf_path) + save_3mf_parts({ + 'base': base_mesh, + 'green': green_mesh, + 'white': white_mesh, + }, parts_3mf_path) + + print("Created files:") + if saved_base: + print(f" Base STL: {saved_base}") + if saved_green: + print(f" Green STL: {saved_green}") + if saved_white: + print(f" White STL: {saved_white}") + print(f" Assembled STL: {assembled_stl_path}") + print(f" Assembled 3MF: {assembled_3mf_path}") + print(f" Parts 3MF: {parts_3mf_path}") + + +if __name__ == "__main__": + main()