Add SVG combination tool and plate dimension options

Created combine_svg_vertical.py to vertically stack SVG files with centering
and configurable spacing. Enhanced svg_logo_to_stl.py with plate dimensions
and margin options for better nameplate creation.

combine_svg_vertical.py features:
- Vertically combines two SVG files (icon above text)
- Automatically centers smaller element
- Configurable spacing between elements (default: 20px)
- Configurable margin around composition (default: 10px)
- Preserves individual SVG structure in groups

svg_logo_to_stl.py enhancements:
- Added --plate-width and --plate-height options
- Logo scales to fit within plate dimensions (maintaining aspect ratio)
- Added --margin option for spacing around logo on plate
- Automatic centering of logo on plate
- Reports actual dimensions: plate, logo, margin, and offset

Example usage:
  # Combine icon and text
  python combine_svg_vertical.py um_grey.svg um_black.svg combined.svg --spacing 114

  # Center on 256x256mm plate with 10mm margin
  python svg_logo_to_stl.py combined.svg output \
    --plate-width 256 --plate-height 256 --margin 10 \
    --base-thickness 1.5 --feature-height 2.5

Files:
- combine_svg_vertical.py: Vertical SVG stacking tool
- README_combine_svg.md: Documentation for combine tool
- underground-magnetics-combined.svg: Example combined logo
- svg_logo_to_stl.py: Enhanced with plate dimensions
- command.txt: Updated with new parameters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:23:24 -05:00
parent 2719ec244d
commit 859ce050a3
5 changed files with 519 additions and 92 deletions

112
README_combine_svg.md Normal file
View File

@@ -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 <top_svg> <bottom_svg> <output_svg> [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 `<g>` group with transform
4. **Combine** - Creates new SVG canvas with both elements positioned
## Output Structure
```xml
<svg width="..." height="..." viewBox="...">
<!-- Combined: top.svg (top) + bottom.svg (bottom) -->
<g id="top" transform="translate(x_offset, y_offset)">
<!-- All elements from top SVG -->
</g>
<g id="bottom" transform="translate(x_offset, y_offset)">
<!-- All elements from bottom SVG -->
</g>
</svg>
```
## 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

145
combine_svg_vertical.py Executable file
View File

@@ -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())

View File

@@ -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)

View File

@@ -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 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
mesh.apply_translation((0.0, height_mm, base_thickness))
# 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'})
# 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': 'Base',
'displaycolor': '#000000'
'name': name.capitalize(),
'displaycolor': display_color
})
ET.SubElement(basematerials, 'base', {
'name': 'Green',
'displaycolor': '#39e991'
})
ET.SubElement(basematerials, 'base', {
'name': 'White',
'displaycolor': '#ffffff'
})
return 1
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__":

View File

@@ -0,0 +1,31 @@
<?xml version='1.0' encoding='utf-8'?>
<svg xmlns:ns0="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="3090.0" height="757.0" viewBox="0 0 3090.0 757.0">
<!-- Combined: um_grey.svg (top) + um_black.svg (bottom) -->
<g id="top" transform="translate(1338.5, 10)">
<ns0:path d="M 159,0 L 159,116 L 164,135 L 175,150 L 187,157 L 198,160 L 214,160 L 223,158 L 236,151 L 243,144 L 250,131 L 252,124 L 253,0 Z" fill="#808080" fill-rule="evenodd" />
<ns0:path d="M 0,0 L 0,389 L 99,389 L 100,292 L 107,302 L 122,317 L 135,326 L 161,337 L 178,341 L 198,343 L 234,341 L 263,333 L 279,325 L 294,314 L 306,301 L 312,292 L 313,389 L 412,389 L 412,0 L 314,0 L 314,119 L 311,140 L 305,159 L 299,171 L 290,184 L 272,201 L 261,208 L 241,217 L 212,223 L 200,223 L 182,220 L 153,209 L 134,196 L 120,181 L 111,167 L 102,143 L 98,114 L 98,0 Z" fill="#808080" fill-rule="evenodd" />
</g>
<g id="bottom" transform="translate(10.0, 514.0)">
<ns0:path d="M 2952,58 L 2945,66 L 2940,80 L 2940,102 L 2945,116 L 2951,122 L 2965,127 L 3026,129 L 3029,130 L 3035,136 L 3036,146 L 3031,153 L 3023,156 L 2942,156 L 2942,183 L 3037,183 L 3046,181 L 3056,175 L 3064,165 L 3069,150 L 3069,133 L 3063,117 L 3053,108 L 3040,103 L 2983,101 L 2977,97 L 2975,93 L 2975,85 L 2979,79 L 2990,76 L 3062,76 L 3062,49 L 2982,49 L 2964,52 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2820,63 L 2809,76 L 2804,86 L 2799,104 L 2799,128 L 2801,138 L 2808,154 L 2815,164 L 2827,174 L 2837,179 L 2860,183 L 2916,183 L 2916,155 L 2861,155 L 2850,151 L 2840,141 L 2837,136 L 2834,123 L 2834,110 L 2836,99 L 2839,92 L 2846,84 L 2857,79 L 2868,77 L 2916,77 L 2916,49 L 2851,50 L 2835,54 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2735,49 L 2735,183 L 2771,183 L 2771,49 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2501,63 L 2494,70 L 2487,82 L 2483,94 L 2481,107 L 2481,125 L 2483,138 L 2486,148 L 2494,162 L 2508,174 L 2527,181 L 2541,183 L 2599,183 L 2599,156 L 2535,155 L 2528,152 L 2523,147 L 2519,135 L 2520,129 L 2599,129 L 2599,103 L 2519,102 L 2519,97 L 2523,86 L 2527,81 L 2533,78 L 2543,76 L 2599,76 L 2599,49 L 2533,50 L 2512,56 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2332,49 L 2332,183 L 2366,183 L 2366,79 L 2368,77 L 2406,78 L 2416,83 L 2420,94 L 2420,183 L 2454,183 L 2454,87 L 2450,72 L 2446,65 L 2440,59 L 2431,54 L 2415,50 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2298,49 L 2228,49 L 2210,51 L 2200,54 L 2188,61 L 2176,74 L 2167,94 L 2164,120 L 2166,135 L 2170,148 L 2179,163 L 2192,175 L 2207,181 L 2224,183 L 2264,184 L 2262,196 L 2258,201 L 2251,205 L 2179,206 L 2179,232 L 2262,231 L 2279,225 L 2291,213 L 2298,192 Z M 2216,81 L 2228,77 L 2264,78 L 2263,156 L 2226,155 L 2213,150 L 2206,143 L 2201,131 L 2200,108 L 2205,92 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2011,49 L 2011,76 L 2081,76 L 2090,78 L 2100,86 L 2103,101 L 2038,103 L 2025,106 L 2015,111 L 2007,120 L 2002,136 L 2003,157 L 2010,171 L 2021,179 L 2038,183 L 2138,183 L 2138,92 L 2134,75 L 2127,64 L 2120,58 L 2112,54 L 2098,50 Z M 2038,137 L 2044,130 L 2051,128 L 2102,128 L 2103,156 L 2045,156 L 2041,154 L 2038,150 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1370,49 L 1370,183 L 1405,183 L 1406,77 L 1445,78 L 1452,81 L 1455,84 L 1458,93 L 1458,183 L 1492,183 L 1492,86 L 1489,74 L 1485,66 L 1477,58 L 1469,54 L 1453,50 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1212,49 L 1212,136 L 1214,152 L 1221,167 L 1230,175 L 1251,182 L 1334,183 L 1334,49 L 1300,49 L 1299,154 L 1260,154 L 1253,151 L 1249,147 L 1246,132 L 1246,49 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1012,52 L 996,49 L 932,49 L 932,183 L 966,183 L 967,74 L 987,74 L 994,79 L 995,104 L 1028,104 L 1028,76 L 1026,66 L 1023,60 L 1018,55 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 898,49 L 828,49 L 805,52 L 795,56 L 786,62 L 774,76 L 768,88 L 765,99 L 764,128 L 768,145 L 779,164 L 793,176 L 806,181 L 823,183 L 863,184 L 863,191 L 860,198 L 850,205 L 778,206 L 778,232 L 861,231 L 880,224 L 886,219 L 891,212 L 896,198 L 898,183 Z M 814,82 L 827,77 L 863,78 L 862,156 L 831,156 L 821,154 L 811,149 L 804,141 L 801,134 L 799,122 L 800,104 L 805,91 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 730,52 L 713,49 L 649,49 L 649,183 L 684,183 L 685,74 L 705,74 L 712,79 L 713,104 L 746,104 L 746,78 L 744,67 L 740,59 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 525,62 L 517,70 L 509,84 L 504,106 L 504,126 L 506,139 L 516,161 L 531,174 L 550,181 L 564,183 L 622,183 L 622,156 L 558,155 L 551,152 L 546,147 L 542,136 L 543,129 L 622,129 L 622,103 L 542,102 L 545,87 L 550,81 L 556,78 L 566,76 L 622,76 L 622,49 L 556,50 L 540,54 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 197,49 L 197,183 L 231,183 L 232,77 L 271,78 L 278,81 L 281,84 L 284,93 L 284,183 L 318,183 L 318,86 L 315,74 L 311,66 L 303,58 L 295,54 L 279,50 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1081,54 L 1070,61 L 1059,72 L 1051,86 L 1047,99 L 1046,127 L 1050,144 L 1056,156 L 1074,174 L 1086,180 L 1100,184 L 1133,184 L 1145,181 L 1158,175 L 1173,162 L 1182,147 L 1186,134 L 1187,104 L 1183,88 L 1177,76 L 1171,68 L 1162,60 L 1150,53 L 1138,49 L 1126,47 L 1107,47 L 1095,49 Z M 1109,75 L 1124,75 L 1136,80 L 1145,89 L 1151,105 L 1151,128 L 1147,140 L 1143,146 L 1135,153 L 1129,156 L 1119,158 L 1104,156 L 1094,150 L 1088,143 L 1082,126 L 1082,106 L 1087,91 L 1097,80 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2735,0 L 2735,32 L 2771,32 L 2771,0 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 2638,0 L 2638,48 L 2613,49 L 2613,75 L 2638,76 L 2638,147 L 2642,163 L 2651,175 L 2663,181 L 2673,183 L 2710,183 L 2710,157 L 2688,157 L 2681,154 L 2674,145 L 2672,133 L 2672,76 L 2710,75 L 2710,49 L 2673,49 L 2672,0 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1764,0 L 1764,183 L 1802,183 L 1798,64 L 1799,40 L 1848,183 L 1888,183 L 1923,74 L 1935,41 L 1936,66 L 1933,183 L 1969,183 L 1969,0 L 1914,0 L 1867,141 L 1865,139 L 1819,0 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 1650,0 L 1616,0 L 1615,49 L 1580,49 L 1558,52 L 1546,57 L 1539,62 L 1528,75 L 1523,84 L 1519,97 L 1518,127 L 1520,138 L 1527,155 L 1531,161 L 1544,173 L 1563,181 L 1576,183 L 1650,183 Z M 1564,85 L 1575,79 L 1585,77 L 1616,78 L 1615,155 L 1575,154 L 1566,150 L 1559,143 L 1554,129 L 1554,105 L 1556,97 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 476,0 L 442,0 L 441,49 L 406,49 L 384,52 L 369,59 L 359,68 L 354,75 L 348,87 L 344,103 L 345,133 L 353,155 L 357,161 L 370,173 L 389,181 L 402,183 L 476,183 Z M 390,85 L 401,79 L 411,77 L 442,78 L 441,155 L 406,155 L 392,150 L 386,144 L 383,139 L 380,128 L 380,106 L 383,95 Z" fill="#000000" fill-rule="evenodd" />
<ns0:path d="M 0,0 L 0,125 L 2,142 L 10,160 L 22,171 L 35,178 L 53,183 L 71,185 L 87,185 L 110,182 L 123,178 L 140,168 L 149,159 L 153,152 L 158,130 L 158,0 L 121,0 L 121,122 L 119,133 L 115,142 L 109,149 L 97,155 L 83,157 L 66,156 L 54,152 L 44,143 L 40,135 L 37,119 L 37,0 Z" fill="#000000" fill-rule="evenodd" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB