diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..557ff35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Output files +*.stl +*.3mf +*_output* +logo_output* + +# Temporary files +*.tmp +*.log diff --git a/Color logo - no background.svg b/Color logo - no background.svg new file mode 100755 index 0000000..fd9607d --- /dev/null +++ b/Color logo - no background.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/command.txt b/command.txt new file mode 100644 index 0000000..0ca8014 --- /dev/null +++ b/command.txt @@ -0,0 +1,28 @@ +# SVG to STL Conversion Command +# Note: The DYLD_LIBRARY_PATH is needed for Cairo to work on macOS + +export DYLD_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_LIBRARY_PATH" && source .venv/bin/activate && python svg_logo_to_stl.py "Color logo - no background.svg" logo_output --width-mm 254 --base-thickness 1.5 --feature-height 2.5 + +# Or if you've already activated the venv and set the library path: +python svg_logo_to_stl.py "Color logo - no background.svg" logo_output --width-mm 254 --base-thickness 1.5 --feature-height 2.5 + +# Output files: +# - logo_output_base.stl - Black base plate (Z: 0 to 1.5mm) +# - logo_output_green_icon.stl - Green microchip icon at top (Z: 1.5 to 4.0mm) +# - logo_output_white.stl - White "SPARKSOFT DESIGN" text (Z: 1.5 to 4.0mm) +# - logo_output_green_bottom.stl - Green "EMBEDDED SOLUTIONS" text (Z: 1.5 to 4.0mm) +# - logo_output_assembled.stl - All parts combined (single object) +# - logo_output_assembled.3mf - All parts as single mesh (for paint tool) +# - logo_output_parts.3mf - 4 separate selectable parts (BEST for multi-color!) + +# Import logo_output_parts.3mf into BambuStudio +# You'll see 4 separate parts that can each be assigned different filaments: +# 1. base - black base plate +# 2. green_icon - microchip icon +# 3. white_text - middle text +# 4. green_text - bottom text +# Parts do NOT overlap in XY space, preventing slicing issues +# Total model height: 4.0mm (1.5mm base + 2.5mm features) + +# IMPORTANT: Scale must be 254mm or larger for 0.4mm nozzle +# Smaller sizes cause features to be too small for proper toolpath generation diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f05fe01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +cairocffi==1.7.1 +CairoSVG==2.9.0 +cffi==2.0.0 +cssselect2==0.9.0 +defusedxml==0.7.1 +ImageIO==2.37.3 +lazy-loader==0.5 +mapbox_earcut==2.0.0 +networkx==3.4.2 +numpy==2.2.6 +packaging==26.2 +pillow==12.2.0 +pycparser==3.0 +scikit-image==0.25.2 +scipy==1.15.3 +shapely==2.1.2 +tifffile==2025.5.10 +tinycss2==1.5.1 +trimesh==4.12.2 +webencodings==0.5.1 diff --git a/svg_logo_to_stl.py b/svg_logo_to_stl.py index 018b77a..92e9fda 100644 --- a/svg_logo_to_stl.py +++ b/svg_logo_to_stl.py @@ -20,9 +20,10 @@ def render_svg_to_image(svg_path: Path, pixel_width: int = 2048) -> Image.Image: return Image.open(png_output).convert("RGBA") -def clean_mask(mask: np.ndarray, min_size: int = 32) -> np.ndarray: +def clean_mask(mask: np.ndarray, min_size: int = 10) -> np.ndarray: + # Use smaller min_size to preserve small features like parts of the icon if min_size > 0: - cleaned = morphology.remove_small_objects(mask, max_size=min_size - 1) + cleaned = morphology.remove_small_objects(mask, min_size=min_size) else: cleaned = mask structure = np.ones((3, 3), dtype=bool) @@ -49,8 +50,10 @@ def signed_area(coords: np.ndarray) -> float: 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): +def mask_to_polygons(mask: np.ndarray, min_area: float = 1.0, simplify_tolerance: float = 0.25, debug=False): contours = measure.find_contours(mask.astype(np.uint8), 0.5) + if debug: + print(f" Found {len(contours)} contours") shapes: list[tuple[Polygon, float]] = [] for contour in contours: if contour.shape[0] < 4: @@ -86,15 +89,22 @@ def mask_to_polygons(mask: np.ndarray, min_area: float = 10.0, simplify_toleranc hole_list.append(hole.exterior.coords) assigned_holes.add(hole) poly = Polygon(exterior.exterior.coords, hole_list) - poly = poly.simplify(simplify_tolerance) + poly = poly.simplify(simplify_tolerance, preserve_topology=True) if poly.is_valid and poly.area >= min_area: polygons.append(poly) + elif debug: + print(f" Skipped polygon: valid={poly.is_valid}, area={poly.area:.1f}") for hole in holes: if hole not in assigned_holes: - poly = hole.simplify(simplify_tolerance) + poly = hole.simplify(simplify_tolerance, preserve_topology=True) if poly.is_valid and poly.area >= min_area: polygons.append(poly) + elif debug: + print(f" Skipped unassigned hole: valid={poly.is_valid}, area={poly.area:.1f}") + + if debug: + print(f" Output: {len(polygons)} polygons (from {len(exteriors)} exteriors, {len(holes)} holes)") return polygons @@ -115,7 +125,37 @@ def create_extruded_mesh(polygons, height_mm: float, scale: float, y_flip: bool meshes.append(mesh) except Exception: continue - return trimesh.util.concatenate(meshes) if meshes else None + + if not meshes: + return None + + # Process each mesh individually to ensure they're watertight + watertight_meshes = [] + skipped_count = 0 + for mesh in meshes: + # Try to make it watertight + trimesh.repair.fill_holes(mesh) + trimesh.repair.fix_normals(mesh) + trimesh.repair.fix_winding(mesh) + + # If still not watertight, try to split and fix components + if not mesh.is_watertight: + # Split into connected components + components = mesh.split(only_watertight=False) + for comp in components: + trimesh.repair.fill_holes(comp) + if comp.is_watertight or comp.is_volume: + watertight_meshes.append(comp) + else: + skipped_count += 1 + else: + watertight_meshes.append(mesh) + + if not watertight_meshes: + # Fallback: return combined mesh even if not perfect + return trimesh.util.concatenate(meshes) + + return trimesh.util.concatenate(watertight_meshes) def build_logo_meshes(svg_path: Path, width_mm: float, base_thickness: float, feature_height: float, png_width: int): @@ -128,19 +168,45 @@ def build_logo_meshes(svg_path: Path, width_mm: float, base_thickness: float, fe 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) + # Split green mask into top (icon) and bottom (text) regions + # Find where white text is located to use as separator + white_rows = np.any(white_mask, axis=1) + white_y_coords = np.where(white_rows)[0] + + if len(white_y_coords) > 0: + white_y_mid = (white_y_coords[0] + white_y_coords[-1]) // 2 + + # Green icon: everything above white text + green_icon_mask = green_mask.copy() + green_icon_mask[white_y_coords[0]:, :] = False # Zero out everything from white text onwards + + # Green bottom text: everything below white text + green_bottom_mask = green_mask.copy() + green_bottom_mask[:white_y_coords[-1], :] = False # Zero out everything before end of white text + + green_icon_polys = mask_to_polygons(green_icon_mask) + green_bottom_polys = mask_to_polygons(green_bottom_mask) + else: + # No white text found, treat all green as one + green_icon_polys = mask_to_polygons(green_mask) + green_bottom_polys = [] + 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) + # Features sit exactly on top of the base (no overlap to avoid slicing issues) + green_icon_mesh = create_extruded_mesh(green_icon_polys, feature_height, scale) + green_bottom_mesh = create_extruded_mesh(green_bottom_polys, feature_height, scale) white_mesh = create_extruded_mesh(white_polys, feature_height, scale) - for mesh in (green_mesh, white_mesh): + + for mesh in (green_icon_mesh, green_bottom_mesh, white_mesh): if mesh is not None: + # Position features to start exactly at the top of the base mesh.apply_translation((0.0, height_mm, base_thickness)) - return base, green_mesh, white_mesh + return base, green_icon_mesh, green_bottom_mesh, white_mesh def _mesh_to_3mf_object(mesh: trimesh.Trimesh, object_id: int, name: str, material_id: int) -> ET.Element: @@ -196,30 +262,35 @@ def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path): object_ids = [] current_id = 1 - material_map = {'base': 1, 'green': 2, 'white': 3} + material_map = {'base': 0, 'green_icon': 1, 'green_text': 1, 'white_text': 2, 'logo': 0} for name, mesh in meshes.items(): if mesh is None: continue - material_id = material_map.get(name, 1) + material_id = material_map.get(name, 0) 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)}) + + # If we have only one object, add it directly to build (no assembly) + # If we have multiple objects, create an assembly + if len(object_ids) == 1: + ET.SubElement(build, 'item', {'objectid': str(object_ids[0])}) + else: + 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) + }) + 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: @@ -230,29 +301,52 @@ def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path): def save_3mf_parts(meshes: dict[str, trimesh.Trimesh], path: Path): + """ + Save meshes as separate objects in a 3MF assembly, similar to nameplate.3mf. + Each mesh becomes a separate selectable component in BambuStudio. + """ 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) + basematerials_id = _add_basematerials(resources) + # Create separate object entries for each mesh object_ids = [] current_id = 1 - material_map = {'base': 1, 'green': 2, 'white': 3} + material_map = {'base': 0, 'green_icon': 1, 'green_text': 1, 'white_text': 2} for name, mesh in meshes.items(): if mesh is None: continue - material_id = material_map.get(name, 1) + material_id = material_map.get(name, 0) 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') + # Create an assembly object that references all the individual objects + # This is key: the assembly is what gets added to the build, not the individual objects + assembly_id = current_id + assembly = ET.SubElement(resources, 'object', { + 'id': str(assembly_id), + 'name': 'logo_assembly', + 'type': 'model' + }) + components_el = ET.SubElement(assembly, 'components') + + # Add each object as a component in the assembly + # Identity transform (no translation/rotation) for object_id in object_ids: - ET.SubElement(build, 'item', {'objectid': str(object_id)}) + ET.SubElement(components_el, 'component', { + 'objectid': str(object_id), + 'transform': '1 0 0 0 1 0 0 0 1 0 0 0' # Identity matrix + }) + + # Add only the assembly to the build (not the individual objects) + 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: @@ -283,7 +377,7 @@ def main(): output_prefix = Path(args.output) output_prefix.parent.mkdir(parents=True, exist_ok=True) - base_mesh, green_mesh, white_mesh = build_logo_meshes( + base_mesh, green_icon_mesh, green_bottom_mesh, white_mesh = build_logo_meshes( svg_path, args.width_mm, args.base_thickness, @@ -292,40 +386,48 @@ def main(): ) base_path = output_prefix.with_name(output_prefix.stem + "_base.stl") - green_path = output_prefix.with_name(output_prefix.stem + "_green.stl") + green_icon_path = output_prefix.with_name(output_prefix.stem + "_green_icon.stl") + green_bottom_path = output_prefix.with_name(output_prefix.stem + "_green_bottom.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_green_icon = save_mesh(green_icon_mesh, green_icon_path) + saved_green_bottom = save_mesh(green_bottom_mesh, green_bottom_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]) + # Concatenate all meshes into a single object + assembled_mesh = trimesh.util.concatenate([m for m in (base_mesh, green_icon_mesh, green_bottom_mesh, white_mesh) if m is not None]) assembled_mesh.export(assembled_stl_path) + # For 3MF assembled version, export as a SINGLE mesh object + # BambuStudio can then use the paint tool to assign colors save_3mf({ - 'base': base_mesh, - 'green': green_mesh, - 'white': white_mesh, + 'logo': assembled_mesh, }, assembled_3mf_path) + + # Export as separate parts in assembly for multi-color selection save_3mf_parts({ 'base': base_mesh, - 'green': green_mesh, - 'white': white_mesh, + 'green_icon': green_icon_mesh, + 'white_text': white_mesh, + 'green_text': green_bottom_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}") + print(f" Base STL: {saved_base}") + if saved_green_icon: + print(f" Green icon STL: {saved_green_icon}") + if saved_green_bottom: + print(f" Green bottom text STL: {saved_green_bottom}") 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}") + print(f" White text 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__":