diff --git a/README_combine_svg.md b/README_combine_svg.md new file mode 100644 index 0000000..899cebd --- /dev/null +++ b/README_combine_svg.md @@ -0,0 +1,112 @@ +# Combine SVG Vertical + +A script to combine two SVG files vertically with automatic centering and configurable spacing. + +## Purpose + +This tool is designed for preparing multi-part logos for 3D printing by: +- Placing an icon/logo above text +- Automatically centering the smaller element +- Adding configurable spacing between elements +- Maintaining proper dimensions for STL conversion + +## Usage + +```bash +python combine_svg_vertical.py [options] +``` + +### Arguments + +- `top` - Top SVG file (e.g., icon or logo) +- `bottom` - Bottom SVG file (e.g., text) +- `output` - Output combined SVG file + +### Options + +- `--spacing, -s` - Vertical spacing between elements in pixels (default: 20) +- `--margin, -m` - Margin around entire composition in pixels (default: 10) + +## Examples + +### Basic Usage (Underground Magnetics Logo) +```bash +python combine_svg_vertical.py um_grey.svg um_black.svg underground-magnetics-combined.svg +``` + +**Result:** +- Grey "UM" icon (413×390) centered above black text (3070×233) +- 20px spacing between icon and text +- 10px margin around composition +- Final canvas: 3090×663px + +### Custom Spacing +```bash +# Larger spacing between elements +python combine_svg_vertical.py icon.svg text.svg combined.svg --spacing 50 + +# Larger margin around composition +python combine_svg_vertical.py icon.svg text.svg combined.svg --margin 30 + +# Both custom +python combine_svg_vertical.py icon.svg text.svg combined.svg -s 40 -m 20 +``` + +## How It Works + +1. **Load SVGs** - Reads dimensions from viewBox or width/height attributes +2. **Calculate Layout** - Determines positioning based on: + - Maximum width of both elements + - Combined height + spacing + - Centering offsets for smaller element +3. **Create Groups** - Wraps each SVG in a `` group with transform +4. **Combine** - Creates new SVG canvas with both elements positioned + +## Output Structure + +```xml + + + + + + + + + +``` + +## Integration with 3D Workflow + +After combining SVGs, you can convert to STL: + +```bash +# Convert combined logo to 3D model +python svg_logo_to_stl.py underground-magnetics-combined.svg um_logo \ + --width-mm 100 \ + --base-thickness 1.5 \ + --feature-height 2.5 +``` + +This will create a single 3D model with both the icon and text at the same height (not stacked vertically in 3D, just in the 2D layout). + +## Tips + +- **Spacing**: Use `--spacing` to adjust the gap between icon and text + - Small spacing (10-20): Compact look + - Medium spacing (20-40): Balanced (default) + - Large spacing (50+): Separated elements + +- **Margin**: Use `--margin` to add breathing room around the entire composition + - Useful when the combined SVG will be cropped or bounded + +- **Order Matters**: The first argument is always placed on top + - Icon above text: `combine_svg_vertical.py icon.svg text.svg ...` + - Text above icon: `combine_svg_vertical.py text.svg icon.svg ...` + +## File Size + +The combined SVG file size is approximately the sum of the input files: +- um_grey.svg: 728B +- um_black.svg: 6.1KB +- **Combined**: 7.0KB diff --git a/combine_svg_vertical.py b/combine_svg_vertical.py new file mode 100755 index 0000000..68a3a68 --- /dev/null +++ b/combine_svg_vertical.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Combine two SVG files vertically (top over bottom) with centering and spacing. +Useful for combining separated logo elements for 3D printing. +""" + +import argparse +import xml.etree.ElementTree as ET +from pathlib import Path + + +def load_svg(svg_path: Path): + """Load SVG and extract its dimensions and content""" + tree = ET.parse(svg_path) + root = tree.getroot() + + # Get dimensions from viewBox or width/height attributes + viewbox = root.get('viewBox') + if viewbox: + parts = viewbox.split() + x, y, width, height = map(float, parts) + else: + width = float(root.get('width', 0)) + height = float(root.get('height', 0)) + x, y = 0, 0 + + return { + 'root': root, + 'width': width, + 'height': height, + 'viewbox_x': x, + 'viewbox_y': y + } + + +def combine_svgs_vertical(top_svg_path: Path, bottom_svg_path: Path, output_path: Path, + spacing: float = 20, margin: float = 10): + """ + Combine two SVGs vertically with the top one centered above the bottom one. + + Args: + top_svg_path: Path to top SVG (e.g., icon) + bottom_svg_path: Path to bottom SVG (e.g., text) + output_path: Path for output combined SVG + spacing: Vertical space between top and bottom elements (default: 20) + margin: Margin around the entire composition (default: 10) + """ + # Load both SVGs + top_svg = load_svg(top_svg_path) + bottom_svg = load_svg(bottom_svg_path) + + # Calculate combined dimensions + max_width = max(top_svg['width'], bottom_svg['width']) + combined_height = top_svg['height'] + spacing + bottom_svg['height'] + + # Add margins + canvas_width = max_width + (2 * margin) + canvas_height = combined_height + (2 * margin) + + # Calculate centering offsets + top_x_offset = margin + (max_width - top_svg['width']) / 2 + top_y_offset = margin + + bottom_x_offset = margin + (max_width - bottom_svg['width']) / 2 + bottom_y_offset = margin + top_svg['height'] + spacing + + # Create new SVG root + combined = ET.Element('svg', { + 'xmlns': 'http://www.w3.org/2000/svg', + 'width': str(canvas_width), + 'height': str(canvas_height), + 'viewBox': f'0 0 {canvas_width} {canvas_height}' + }) + + # Add comment for clarity + comment = ET.Comment(f' Combined: {top_svg_path.name} (top) + {bottom_svg_path.name} (bottom) ') + combined.append(comment) + + # Create group for top SVG with transform + top_group = ET.SubElement(combined, 'g', { + 'id': 'top', + 'transform': f'translate({top_x_offset}, {top_y_offset})' + }) + + # Copy all elements from top SVG + for child in top_svg['root']: + if child.tag != '{http://www.w3.org/2000/svg}metadata': + top_group.append(child) + + # Create group for bottom SVG with transform + bottom_group = ET.SubElement(combined, 'g', { + 'id': 'bottom', + 'transform': f'translate({bottom_x_offset}, {bottom_y_offset})' + }) + + # Copy all elements from bottom SVG + for child in bottom_svg['root']: + if child.tag != '{http://www.w3.org/2000/svg}metadata': + bottom_group.append(child) + + # Write output + tree = ET.ElementTree(combined) + ET.indent(tree, space=' ') + tree.write(output_path, encoding='utf-8', xml_declaration=True) + + print(f"Created {output_path}") + print(f" Top ({top_svg_path.name}): {top_svg['width']:.0f}x{top_svg['height']:.0f} at ({top_x_offset:.1f}, {top_y_offset:.1f})") + print(f" Bottom ({bottom_svg_path.name}): {bottom_svg['width']:.0f}x{bottom_svg['height']:.0f} at ({bottom_x_offset:.1f}, {bottom_y_offset:.1f})") + print(f" Canvas: {canvas_width:.0f}x{canvas_height:.0f}") + print(f" Spacing: {spacing:.0f}, Margin: {margin:.0f}") + + +def main(): + parser = argparse.ArgumentParser( + description='Combine two SVG files vertically with centering and spacing' + ) + parser.add_argument('top', help='Top SVG file (e.g., icon)') + parser.add_argument('bottom', help='Bottom SVG file (e.g., text)') + parser.add_argument('output', help='Output SVG file') + parser.add_argument('--spacing', '-s', type=float, default=20, + help='Vertical spacing between elements (default: 20)') + parser.add_argument('--margin', '-m', type=float, default=10, + help='Margin around entire composition (default: 10)') + + args = parser.parse_args() + + top_path = Path(args.top) + bottom_path = Path(args.bottom) + output_path = Path(args.output) + + if not top_path.exists(): + print(f"ERROR: Top SVG file not found: {top_path}") + return 1 + + if not bottom_path.exists(): + print(f"ERROR: Bottom SVG file not found: {bottom_path}") + return 1 + + combine_svgs_vertical(top_path, bottom_path, output_path, args.spacing, args.margin) + + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/command.txt b/command.txt index 0ca8014..3d02978 100644 --- a/command.txt +++ b/command.txt @@ -1,28 +1,46 @@ -# SVG to STL Conversion Command +# SVG to Multi-Color 3D Model Converter +# Automatically detects all colors in SVG and creates one part per color # Note: The DYLD_LIBRARY_PATH is needed for Cairo to work on macOS +# Basic usage - specify width, logo scales proportionally 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 +# Plate dimensions - logo scales and centers on specified plate size with margin +python svg_logo_to_stl.py underground-magnetics-combined.svg um_logo --plate-width 256 --plate-height 256 --margin 10 --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 +# Works with ANY SVG file! The script will: +# 1. Automatically detect all unique colors +# 2. Create one part per color +# 3. Generate assembly with separate selectable parts + # 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_base.stl - Black base plate +# - logo_output_{color}.stl - One STL per detected color +# - logo_output_assembled.stl - All parts combined (single mesh) # - logo_output_assembled.3mf - All parts as single mesh (for paint tool) -# - logo_output_parts.3mf - 4 separate selectable parts (BEST for multi-color!) +# - logo_output_parts.3mf - 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: +# Each detected color becomes a separate selectable part +# Assign different filaments to each part for multi-color printing + +# Example for this logo: # 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) +# 2. white - "SPARKSOFT DESIGN" text +# 3. green - microchip icon + "EMBEDDED SOLUTIONS" text + +# Total model height: base_thickness + feature_height (default: 0.8mm + 1.8mm = 2.6mm) # IMPORTANT: Scale must be 254mm or larger for 0.4mm nozzle # Smaller sizes cause features to be too small for proper toolpath generation + +# Parameters: +# --width-mm: Logo width in mm (if plate dimensions not specified) +# --plate-width: Plate width in mm (logo will be scaled and centered) +# --plate-height: Plate height in mm (logo will be scaled and centered) +# --margin: Margin around logo when using plate dimensions (default: 0) +# --base-thickness: Base plate thickness in mm (default: 0.8) +# --feature-height: Raised feature height in mm (default: 1.8) diff --git a/svg_logo_to_stl.py b/svg_logo_to_stl.py index 92e9fda..51821c9 100644 --- a/svg_logo_to_stl.py +++ b/svg_logo_to_stl.py @@ -37,11 +37,76 @@ def color_mask(image: np.ndarray, target_rgb: tuple[int, int, int], tolerance: i 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 detect_color_clusters(image: np.ndarray, tolerance: int = 80, min_pixels: int = 100): + """ + Automatically detect distinct color regions in an image. + Returns list of (color_name, target_rgb, mask) tuples. + """ + img_array = np.array(image) + alpha = img_array[..., 3] > 32 + rgb_pixels = img_array[alpha][:, :3] + + if len(rgb_pixels) == 0: + return [] + + # Get unique colors and their counts + unique_colors, counts = np.unique(rgb_pixels, axis=0, return_counts=True) + + # Sort by count (most common first) + sorted_indices = np.argsort(-counts) + unique_colors = unique_colors[sorted_indices] + counts = counts[sorted_indices] + + # Cluster similar colors together + color_clusters = [] + used = set() + + for i, color in enumerate(unique_colors): + if i in used: + continue + + # Find all colors within tolerance of this one + cluster_mask = np.zeros(img_array.shape[:2], dtype=bool) + cluster_pixels = 0 + + for j, other_color in enumerate(unique_colors): + if j in used: + continue + diff = np.linalg.norm(color.astype(np.int16) - other_color.astype(np.int16)) + if diff <= tolerance: + mask = color_mask(img_array, tuple(other_color), tolerance=tolerance) + cluster_mask |= mask + cluster_pixels += counts[j] + used.add(j) + + if cluster_pixels >= min_pixels: + # Generate color name + r, g, b = color + if r > 200 and g > 200 and b > 200: + color_name = 'white' + elif r < 50 and g < 50 and b < 50: + color_name = 'black' + elif r > max(g, b): + color_name = 'red' + elif g > max(r, b): + color_name = 'green' + elif b > max(r, g): + color_name = 'blue' + elif r > 200 and g > 200: + color_name = 'yellow' + elif r > 200 and b > 200: + color_name = 'magenta' + elif g > 200 and b > 200: + color_name = 'cyan' + else: + color_name = f'color_{len(color_clusters)+1}' + + color_clusters.append((color_name, tuple(color), cluster_mask, cluster_pixels)) + + # Sort by pixel count (largest first) + color_clusters.sort(key=lambda x: x[3], reverse=True) + + return [(name, rgb, mask) for name, rgb, mask, _ in color_clusters] def signed_area(coords: np.ndarray) -> float: @@ -158,55 +223,91 @@ def create_extruded_mesh(polygons, height_mm: float, scale: float, y_flip: bool 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): +def build_logo_meshes(svg_path: Path, width_mm: float, base_thickness: float, feature_height: float, png_width: int, + plate_width_mm: float = None, plate_height_mm: float = None, margin_mm: float = 0): + """ + Generalized function to convert any SVG to 3D meshes. + Automatically detects colors and creates one part per color. + + Args: + svg_path: Path to SVG file + width_mm: Target width in mm (if plate dimensions not specified) + base_thickness: Thickness of base plate in mm + feature_height: Height of raised features in mm + png_width: Resolution for SVG rasterization + plate_width_mm: Optional plate width - logo will be scaled and centered + plate_height_mm: Optional plate height - logo will be scaled and centered + margin_mm: Margin around logo when using plate dimensions + + Returns (base_mesh, color_parts_dict) where color_parts_dict maps color names to meshes. + """ 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)) + # Calculate scaling based on whether plate dimensions are specified + if plate_width_mm is not None and plate_height_mm is not None: + # Calculate available space after margins + available_width = plate_width_mm - (2 * margin_mm) + available_height = plate_height_mm - (2 * margin_mm) - # 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] + # Scale to fit within available space (maintaining aspect ratio) + scale_x = available_width / width_px + scale_y = available_height / height_px + scale = min(scale_x, scale_y) - if len(white_y_coords) > 0: - white_y_mid = (white_y_coords[0] + white_y_coords[-1]) // 2 + # Actual logo dimensions after scaling + logo_width_mm = width_px * scale + logo_height_mm = height_px * scale - # 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 + # Calculate centering offsets + x_offset = (plate_width_mm - logo_width_mm) / 2.0 + y_offset = (plate_height_mm - logo_height_mm) / 2.0 - # 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) + # Use plate dimensions for base + width_mm = plate_width_mm + height_mm = plate_height_mm else: - # No white text found, treat all green as one - green_icon_polys = mask_to_polygons(green_mask) - green_bottom_polys = [] + # Original behavior: use specified width, calculate height + scale = width_mm / width_px + logo_width_mm = width_mm + logo_height_mm = height_px * scale + height_mm = logo_height_mm + x_offset = 0 + y_offset = 0 - white_polys = mask_to_polygons(white_mask) + # Automatically detect all color regions + color_clusters = detect_color_clusters(image, tolerance=80, min_pixels=100) + print(f"Detected {len(color_clusters)} color regions:") + for name, rgb, mask in color_clusters: + pixel_count = np.sum(mask) + print(f" {name}: RGB{rgb} - {pixel_count:,} pixels") + + # Create base plate 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)) - # 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) + # Create mesh for each color + color_parts = {} + for name, rgb, mask in color_clusters: + cleaned_mask = clean_mask(mask) + polygons = mask_to_polygons(cleaned_mask) - 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)) + if polygons: + mesh = create_extruded_mesh(polygons, feature_height, scale) + if mesh is not None: + # Position features to start exactly at the top of the base + # Apply centering offset when plate dimensions are used + mesh.apply_translation((x_offset, height_mm - y_offset, base_thickness)) + color_parts[name] = mesh - return base, green_icon_mesh, green_bottom_mesh, white_mesh + if plate_width_mm is not None and plate_height_mm is not None: + print(f"\nPlate dimensions: {plate_width_mm:.1f} x {plate_height_mm:.1f} mm") + print(f"Logo dimensions: {logo_width_mm:.1f} x {logo_height_mm:.1f} mm") + print(f"Margin: {margin_mm:.1f} mm") + print(f"Centering offset: ({x_offset:.1f}, {y_offset:.1f}) mm") + + return base, color_parts def _mesh_to_3mf_object(mesh: trimesh.Trimesh, object_id: int, name: str, material_id: int) -> ET.Element: @@ -234,21 +335,36 @@ def _mesh_to_3mf_object(mesh: trimesh.Trimesh, object_id: int, name: str, materi return obj -def _add_basematerials(resources: ET.Element) -> int: +def _add_basematerials(resources: ET.Element, color_names: list[str]) -> dict[str, int]: + """ + Add basematerials for all colors. + Returns dict mapping color name to material ID. + """ 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 + + # Color palette for display + color_palette = { + 'base': '#000000', + 'black': '#000000', + 'white': '#FFFFFF', + 'red': '#FF0000', + 'green': '#00FF00', + 'blue': '#0000FF', + 'yellow': '#FFFF00', + 'magenta': '#FF00FF', + 'cyan': '#00FFFF', + } + + material_map = {} + for i, name in enumerate(['base'] + color_names): + display_color = color_palette.get(name, '#808080') # Default to gray + ET.SubElement(basematerials, 'base', { + 'name': name.capitalize(), + 'displaycolor': display_color + }) + material_map[name] = i + + return material_map def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path): @@ -258,11 +374,13 @@ def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path): 'unit': 'millimeter' }) resources = ET.SubElement(model, 'resources') - _add_basematerials(resources) + + # Get list of color names (excluding 'base') + color_names = [name for name in meshes.keys() if name != 'base' and meshes[name] is not None] + material_map = _add_basematerials(resources, color_names) object_ids = [] current_id = 1 - 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 @@ -311,12 +429,14 @@ def save_3mf_parts(meshes: dict[str, trimesh.Trimesh], path: Path): 'unit': 'millimeter' }) resources = ET.SubElement(model, 'resources') - basematerials_id = _add_basematerials(resources) + + # Get list of color names (excluding 'base') + color_names = [name for name in meshes.keys() if name != 'base' and meshes[name] is not None] + material_map = _add_basematerials(resources, color_names) # Create separate object entries for each mesh object_ids = [] current_id = 1 - material_map = {'base': 0, 'green_icon': 1, 'green_text': 1, 'white_text': 2} for name, mesh in meshes.items(): if mesh is None: continue @@ -367,39 +487,46 @@ 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("--width-mm", type=float, default=100.0, help="Final model width in millimeters (if plate dimensions not specified)") 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") + parser.add_argument("--plate-width", type=float, help="Plate width in mm - logo will be scaled and centered") + parser.add_argument("--plate-height", type=float, help="Plate height in mm - logo will be scaled and centered") + parser.add_argument("--margin", type=float, default=0, help="Margin around logo when using plate dimensions (default: 0)") 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_icon_mesh, green_bottom_mesh, white_mesh = build_logo_meshes( + base_mesh, color_parts = build_logo_meshes( svg_path, args.width_mm, args.base_thickness, args.feature_height, args.png_width, + plate_width_mm=args.plate_width, + plate_height_mm=args.plate_height, + margin_mm=args.margin, ) + # Save individual STL files for each color part base_path = output_prefix.with_name(output_prefix.stem + "_base.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_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) + saved_parts = {} + for color_name, mesh in color_parts.items(): + part_path = output_prefix.with_name(output_prefix.stem + f"_{color_name}.stl") + if save_mesh(mesh, part_path): + saved_parts[color_name] = part_path # 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]) + all_meshes = [base_mesh] + list(color_parts.values()) + assembled_mesh = trimesh.util.concatenate([m for m in all_meshes if m is not None]) assembled_mesh.export(assembled_stl_path) # For 3MF assembled version, export as a SINGLE mesh object @@ -409,25 +536,19 @@ def main(): }, assembled_3mf_path) # Export as separate parts in assembly for multi-color selection - save_3mf_parts({ - 'base': base_mesh, - 'green_icon': green_icon_mesh, - 'white_text': white_mesh, - 'green_text': green_bottom_mesh, - }, parts_3mf_path) + parts_dict = {'base': base_mesh} + parts_dict.update(color_parts) + save_3mf_parts(parts_dict, parts_3mf_path) - print("Created files:") + print("\nCreated files:") if saved_base: 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 text STL: {saved_white}") + for color_name, part_path in saved_parts.items(): + print(f" {color_name.capitalize()} STL: {part_path}") print(f" Assembled STL: {assembled_stl_path}") print(f" Assembled 3MF: {assembled_3mf_path}") print(f" Parts 3MF: {parts_3mf_path}") + print(f"\nImport {parts_3mf_path.name} into BambuStudio for multi-color printing!") if __name__ == "__main__": diff --git a/underground-magnetics-combined.svg b/underground-magnetics-combined.svg new file mode 100644 index 0000000..bdf3654 --- /dev/null +++ b/underground-magnetics-combined.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file