Compare commits

..

2 Commits

Author SHA1 Message Date
2719ec244d Add PNG to SVG color separation tool for multi-color 3D printing
Created split_png_by_brightness.py to split rasterized logos into separate
SVG files by color for Underground Magnetics logo conversion.

Key features:
- OpenCV contour hierarchy detection for proper hole handling
- Letters with enclosed shapes (d, o, g, etc.) now render correctly
- Tight brightness thresholds to avoid antialiasing artifacts
- Automatic bounding box cropping for optimal file sizes
- Black text: brightness < 40 (236k pixels, 21 shape groups)
- Grey icon: brightness 108-118 (119k pixels, 2 shape groups)

Results:
- um_black.svg: 6.1KB, 3070x233px (was 139KB, 3613x391px)
- um_grey.svg: 728B, 413x390px (was 253KB, 3613x391px)

Files:
- split_png_by_brightness.py: Main color separation tool
- IMPROVEMENTS.md: Detailed changelog and comparison
- README_underground_magnetics.md: Usage documentation
- underground-magnetics.eps: Source logo file
- um_black.svg: Separated black text (cropped)
- um_grey.svg: Separated grey icon (cropped)
- requirements.txt: Added opencv-python dependency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-31 09:51:24 -05:00
8b80f68a19 Fix SVG to STL conversion for multi-color 3D printing
Split green features into separate non-overlapping parts to fix slicing issues.

Key changes:
- Split green mask into icon (top) and text (bottom) using white text as separator
- Create 4 separate parts: base, green_icon, white_text, green_text
- Parts positioned at same Z height but don't overlap in XY space
- Assembly structure matches nameplate.3mf (components with identity transforms)
- Fixed polygon detection: reduced min_size, added preserve_topology
- Added mesh repair logic to ensure watertight meshes
- Updated material maps for new part names

Files:
- svg_logo_to_stl.py: Main conversion script with green split logic
- Color logo - no background.svg: Source SVG file
- command.txt: Usage documentation with working parameters
- requirements.txt: Python dependencies
- .gitignore: Exclude output files and debug images

Minimum scale: 254mm width for 0.4mm nozzle (smaller features too thin to print)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-31 09:02:30 -05:00
11 changed files with 678 additions and 46 deletions

50
.gitignore vendored Normal file
View File

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

9
Color logo - no background.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

62
IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,62 @@
# Improvements to split_png_by_brightness.py
## Version 2 Fixes
### 1. Proper Hole Handling
**Problem**: Letters with enclosed shapes (like "d", "o", "g") were missing their holes, making them appear filled.
**Solution**:
- Switched from `scikit-image` contour detection to OpenCV's `cv2.findContours` with `RETR_CCOMP` mode
- This detects contour hierarchy (parent/child relationships)
- Holes are now included in the same SVG path as their parent contour
- Using `fill-rule="evenodd"` properly renders the holes
### 2. Better Color Detection
**Problem**: Antialiasing pixels around edges were being included, creating artifacts and incorrect bounding boxes.
**Solution**:
- Changed from single threshold to tight ranges
- Black text: brightness < 40 (was < 50)
- Grey icon: brightness 108-118 (was 80-120)
- This excludes antialiased edge pixels and prevents the grey icon from spanning the full image width
### 3. Automatic Bounding Box Cropping
**Problem**: SVG files contained a lot of empty space.
**Solution**:
- Added automatic cropping to bounding box by default
- Each SVG is sized to fit its content exactly
- `--no-crop` flag available if full canvas is needed
## Results
**Before (v1)**:
- um_black.svg: 139K, 27 separate paths, many filled letters
- um_grey.svg: 253K, 1609 paths, antialiasing artifacts, full width (3613px)
**After (v2)**:
- um_black.svg: 6.1K, 21 shape groups with proper holes, cropped to 3070x233
- um_grey.svg: 728B, 2 shape groups, clean edges, cropped to 413x390
## Dependencies Added
- opencv-python==4.13.0.92
## Default Parameters
- Black threshold: < 40 brightness
- Grey range: 108-118 brightness
- Cropping: enabled by default
## Usage
```bash
# Default (optimized for Underground Magnetics logo)
python split_png_by_brightness.py input.png --prefix output
# Custom thresholds
python split_png_by_brightness.py input.png \
--dark-threshold 40 \
--mid-min 108 \
--mid-max 118
# Disable cropping
python split_png_by_brightness.py input.png --no-crop
```

View File

@@ -0,0 +1,66 @@
# Underground Magnetics Logo - Color Separation
Successfully split the Underground Magnetics EPS logo into separate SVG files by color for multi-color 3D printing.
## Process
1. **EPS to PNG Conversion**: Used ImageMagick to convert `underground-magnetics.eps` to PNG format
- Output: `underground-magnetics-0.png` and `underground-magnetics-1.png`
2. **Color Separation**: Used `split_png_by_brightness.py` to extract separate colors
- Analyzed brightness values to distinguish grey icon from black text
- Created separate SVG files for each color region
## Output Files
- **underground-magnetics_grey.svg** - Grey "UM" icon (126,801 pixels, 1609 paths)
- **underground-magnetics_black.svg** - Black "Underground Magnetics" text (237,130 pixels, 27 paths)
## Usage
To convert these SVG files to 3D models for multi-color printing:
```bash
# Convert grey icon to STL
python svg_logo_to_stl.py underground-magnetics_grey.svg um_grey --width-mm 100 --base-thickness 1.5 --feature-height 2.5
# Convert black text to STL
python svg_logo_to_stl.py underground-magnetics_black.svg um_black --width-mm 100 --base-thickness 1.5 --feature-height 2.5
```
## Tools Created
### split_png_by_brightness.py
Splits PNG images into separate SVG files based on brightness/color clusters.
**Usage:**
```bash
python split_png_by_brightness.py <input.png> [options]
Options:
--output-dir, -o Output directory (default: same as input)
--prefix, -p Prefix for output files (default: input filename)
--dark-threshold INT Brightness threshold for dark/black (default: 50)
--mid-threshold INT Brightness threshold for mid/grey (default: 150)
```
**Example:**
```bash
python split_png_by_brightness.py underground-magnetics-0.png --prefix underground-magnetics
```
This creates separate SVG files for each color region detected in the PNG.
## Color Detection
The script analyzes pixel brightness to separate colors:
- **Dark pixels** (brightness < 50): Black text → `_black.svg`
- **Mid-range pixels** (50 ≤ brightness < 150): Grey icon → `_grey.svg`
- **Light pixels** (brightness ≥ 150): White background (ignored)
## Next Steps
1. Import the separate SVG files into the 3D conversion tool
2. Generate STL files for each color
3. Create 3MF assembly with multiple selectable parts
4. Import into BambuStudio for multi-color printing

28
command.txt Normal file
View File

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

21
requirements.txt Normal file
View File

@@ -0,0 +1,21 @@
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
opencv-python==4.13.0.92
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

265
split_png_by_brightness.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Split a PNG file into multiple SVG files based on brightness/color clusters.
Useful for multi-color 3D printing when the source is a rasterized logo.
"""
import argparse
import numpy as np
from PIL import Image
from pathlib import Path
import cv2
import xml.etree.ElementTree as ET
def extract_color_regions(image_path: Path, dark_threshold: int = 30, mid_min: int = 108, mid_max: int = 118):
"""
Extract different color regions from PNG based on brightness.
Uses tighter thresholds to avoid antialiasing artifacts.
Returns dict of {color_name: binary_mask}
"""
img = Image.open(image_path).convert('RGBA')
img_array = np.array(img)
# Get alpha mask (non-transparent pixels)
alpha = img_array[..., 3] > 128
# Calculate brightness for each pixel
rgb = img_array[..., :3]
brightness = np.mean(rgb, axis=-1)
# Create masks for different brightness levels
masks = {}
# Dark pixels (black text) - very tight threshold to avoid antialiasing
dark_mask = alpha & (brightness < dark_threshold)
if np.sum(dark_mask) > 100:
masks['black'] = dark_mask
# Mid-range pixels (grey icon) - tight range to get only the core grey
mid_mask = alpha & (brightness >= mid_min) & (brightness <= mid_max)
if np.sum(mid_mask) > 100:
masks['grey'] = mid_mask
return masks
def compute_contour_area(contour):
"""Compute the signed area of a contour (positive = clockwise, negative = counter-clockwise)"""
if len(contour) < 3:
return 0
# Shoelace formula
x = contour[:, 0]
y = contour[:, 1]
return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
def mask_to_svg_paths_with_holes(mask: np.ndarray, simplify_epsilon: float = 1.0):
"""
Convert a binary mask to SVG path data with proper hole handling.
Returns list of path d attributes with fill-rule evenodd.
"""
# Convert to uint8 for OpenCV
mask_uint8 = (mask * 255).astype(np.uint8)
# Find contours with hierarchy (to detect holes)
contours, hierarchy = cv2.findContours(mask_uint8, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
if hierarchy is None or len(contours) == 0:
return []
# Simplify contours
simplified_contours = []
for contour in contours:
if simplify_epsilon > 0:
simplified = cv2.approxPolyDP(contour, simplify_epsilon, True)
else:
simplified = contour
simplified_contours.append(simplified)
# Group contours by parent/child relationship
# hierarchy format: [Next, Previous, First_Child, Parent]
hierarchy = hierarchy[0]
# Find all top-level contours (no parent)
top_level_indices = [i for i in range(len(hierarchy)) if hierarchy[i][3] == -1]
paths = []
for top_idx in top_level_indices:
contour = simplified_contours[top_idx]
if len(contour) < 3:
continue
# Start with outer contour
path_data = contour_to_svg_path(contour)
# Find all children (holes) of this contour
child_idx = hierarchy[top_idx][2]
while child_idx != -1:
child_contour = simplified_contours[child_idx]
if len(child_contour) >= 3:
# Add hole to the same path (evenodd fill-rule will handle it)
path_data += " " + contour_to_svg_path(child_contour)
# Move to next sibling
child_idx = hierarchy[child_idx][0]
paths.append(path_data)
return paths
def contour_to_svg_path(contour):
"""Convert OpenCV contour to SVG path data"""
# OpenCV contours are shape (N, 1, 2)
points = contour.reshape(-1, 2)
if len(points) < 2:
return ""
path_data = f"M {points[0, 0]},{points[0, 1]}"
for point in points[1:]:
path_data += f" L {point[0]},{point[1]}"
path_data += " Z"
return path_data
def get_mask_bounds(mask: np.ndarray):
"""Get the bounding box of a binary mask"""
rows = np.any(mask, axis=1)
cols = np.any(mask, axis=0)
if not np.any(rows) or not np.any(cols):
return None
y_min, y_max = np.where(rows)[0][[0, -1]]
x_min, x_max = np.where(cols)[0][[0, -1]]
return (x_min, y_min, x_max + 1, y_max + 1)
def create_svg_from_mask(mask: np.ndarray, output_path: Path, color: str, image_size: tuple, crop_to_bounds: bool = True):
"""Create SVG file from binary mask with proper hole handling and optional cropping"""
width, height = image_size
# Get bounding box of the mask
if crop_to_bounds:
bounds = get_mask_bounds(mask)
if bounds is None:
print(f"WARNING: No content found for {color}")
return
x_min, y_min, x_max, y_max = bounds
cropped_width = x_max - x_min
cropped_height = y_max - y_min
# Crop the mask
cropped_mask = mask[y_min:y_max, x_min:x_max]
else:
x_min, y_min = 0, 0
cropped_width, cropped_height = width, height
cropped_mask = mask
# Create SVG root with cropped dimensions
svg = ET.Element('svg', {
'xmlns': 'http://www.w3.org/2000/svg',
'width': str(cropped_width),
'height': str(cropped_height),
'viewBox': f'0 0 {cropped_width} {cropped_height}'
})
# Convert mask to paths with hole detection
paths = mask_to_svg_paths_with_holes(cropped_mask, simplify_epsilon=1.0)
# Color mapping
color_hex = {
'black': '#000000',
'grey': '#808080',
'gray': '#808080',
'white': '#FFFFFF',
}.get(color, color)
# Add paths to SVG (no translation needed since we cropped the mask)
for path_data in paths:
ET.SubElement(svg, 'path', {
'd': path_data,
'fill': color_hex,
'fill-rule': 'evenodd'
})
# Write SVG
tree = ET.ElementTree(svg)
ET.indent(tree, space=' ')
tree.write(output_path, encoding='utf-8', xml_declaration=True)
if crop_to_bounds:
print(f"Created {output_path} with color {color} ({len(paths)} shape groups, cropped to {cropped_width}x{cropped_height})")
else:
print(f"Created {output_path} with color {color} ({len(paths)} shape groups)")
def main():
parser = argparse.ArgumentParser(
description='Split PNG into separate SVG files by brightness/color'
)
parser.add_argument('input', help='Input PNG file')
parser.add_argument('--output-dir', '-o', help='Output directory (default: same as input)')
parser.add_argument('--prefix', '-p', help='Prefix for output files (default: input filename)')
parser.add_argument('--dark-threshold', type=int, default=30,
help='Brightness threshold for dark/black elements (default: 30)')
parser.add_argument('--mid-min', type=int, default=108,
help='Minimum brightness for mid/grey elements (default: 108)')
parser.add_argument('--mid-max', type=int, default=118,
help='Maximum brightness for mid/grey elements (default: 118)')
parser.add_argument('--no-crop', action='store_true',
help='Do not crop to bounding box (default: crop enabled)')
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"ERROR: File not found: {input_path}")
return 1
# Setup output directory
if args.output_dir:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = input_path.parent
# Setup prefix
prefix = args.prefix if args.prefix else input_path.stem
# Load image to get size
img = Image.open(input_path)
image_size = img.size
# Extract color regions
print(f"Analyzing colors in {input_path}...")
masks = extract_color_regions(input_path, args.dark_threshold, args.mid_min, args.mid_max)
if not masks:
print("No color regions found!")
return 1
print(f"\nFound {len(masks)} color regions:")
for color, mask in masks.items():
pixel_count = np.sum(mask)
print(f" {color}: {pixel_count:,} pixels")
# Create SVG for each color
print(f"\nCreating separate SVG files...")
crop_enabled = not args.no_crop
for color, mask in masks.items():
output_path = output_dir / f"{prefix}_{color}.svg"
create_svg_from_mask(mask, output_path, color, image_size, crop_to_bounds=crop_enabled)
print(f"\nDone! Created {len(masks)} SVG files in {output_dir}")
if __name__ == '__main__':
main()

View File

@@ -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,16 +262,23 @@ 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
build = ET.SubElement(model, 'build')
# 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),
@@ -217,8 +290,6 @@ def save_3mf(meshes: dict[str, trimesh.Trimesh], path: Path):
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)
@@ -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,37 +386,45 @@ 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}")
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" 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}")

24
um_black.svg Normal file
View File

@@ -0,0 +1,24 @@
<?xml version='1.0' encoding='utf-8'?>
<svg xmlns="http://www.w3.org/2000/svg" width="3070" height="233" viewBox="0 0 3070 233">
<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" />
<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" />
<path d="M 2735,49 L 2735,183 L 2771,183 L 2771,49 Z" fill="#000000" fill-rule="evenodd" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<path d="M 2735,0 L 2735,32 L 2771,32 L 2771,0 Z" fill="#000000" fill-rule="evenodd" />
<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" />
<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" />
<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" />
<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" />
<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" />
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

5
um_grey.svg Normal file
View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<svg xmlns="http://www.w3.org/2000/svg" width="413" height="390" viewBox="0 0 413 390">
<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" />
<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" />
</svg>

After

Width:  |  Height:  |  Size: 728 B

BIN
underground-magnetics.eps Normal file

Binary file not shown.