Compare commits

...

3 Commits

Author SHA1 Message Date
859ce050a3 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>
2026-05-31 12:23:24 -05:00
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
14 changed files with 1143 additions and 84 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
```

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

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

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

46
command.txt Normal file
View File

@@ -0,0 +1,46 @@
# 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
# - 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 - Separate selectable parts (BEST for multi-color!)
# Import logo_output_parts.3mf into BambuStudio
# 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. 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)

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)
@@ -36,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:
@@ -49,8 +115,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 +154,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,32 +190,124 @@ 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):
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
# 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)
# 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)
# Actual logo dimensions after scaling
logo_width_mm = width_px * scale
logo_height_mm = height_px * scale
# Calculate centering offsets
x_offset = (plate_width_mm - logo_width_mm) / 2.0
y_offset = (plate_height_mm - logo_height_mm) / 2.0
# Use plate dimensions for base
width_mm = plate_width_mm
height_mm = plate_height_mm
else:
# Original behavior: use specified width, calculate height
scale = width_mm / width_px
height_mm = height_px * scale
logo_width_mm = width_mm
logo_height_mm = height_px * scale
height_mm = logo_height_mm
x_offset = 0
y_offset = 0
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))
# Automatically detect all color regions
color_clusters = detect_color_clusters(image, tolerance=80, min_pixels=100)
green_polys = mask_to_polygons(green_mask)
white_polys = mask_to_polygons(white_mask)
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))
green_mesh = create_extruded_mesh(green_polys, feature_height, scale)
white_mesh = create_extruded_mesh(white_polys, feature_height, scale)
for mesh in (green_mesh, white_mesh):
if mesh is not None:
mesh.apply_translation((0.0, height_mm, base_thickness))
# 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)
return base, green_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
# Apply centering offset when plate dimensions are used
mesh.apply_translation((x_offset, height_mm - y_offset, base_thickness))
color_parts[name] = 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:
@@ -168,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):
@@ -192,20 +374,29 @@ 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': 1, 'green': 2, 'white': 3}
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 +408,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 +419,54 @@ 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)
# 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': 1, 'green': 2, 'white': 3}
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:
@@ -273,59 +487,68 @@ 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_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_path = output_prefix.with_name(output_prefix.stem + "_green.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_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
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
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
# 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)
save_3mf_parts({
'base': base_mesh,
'green': green_mesh,
'white': white_mesh,
}, parts_3mf_path)
print("Created files:")
# Export as separate parts in assembly for multi-color selection
parts_dict = {'base': base_mesh}
parts_dict.update(color_parts)
save_3mf_parts(parts_dict, parts_3mf_path)
print("\nCreated files:")
if saved_base:
print(f" Base STL: {saved_base}")
if saved_green:
print(f" Green STL: {saved_green}")
if saved_white:
print(f" White 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__":

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

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

BIN
underground-magnetics.eps Normal file

Binary file not shown.