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

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 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__":