feat: Add initial implementation for converting SVG nametag designs to multi-part STL files.
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.venv/
|
||||
|
||||
295
convert_nametags.py
Normal file
295
convert_nametags.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
from svg.path import parse_path
|
||||
from xml.dom import minidom
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants based on specs
|
||||
BACKGROUND_THICKNESS = 3.0
|
||||
TEXT_THICKNESS = 2.0
|
||||
TEXT_Z_OFFSET = 3.0 # Starts on top of background
|
||||
CURVE_RESOLUTION = 64 # number of segments per circle
|
||||
|
||||
# SVG Class mappings
|
||||
CLASS_WHITE = 'st2'
|
||||
CLASS_CYAN = 'st1'
|
||||
|
||||
def setup_args():
|
||||
parser = argparse.ArgumentParser(description='Convert SVGs to Nametag STLs')
|
||||
parser.add_argument('input_dir', help='Directory containing SVG files')
|
||||
parser.add_argument('--output-dir', help='Output directory for STLs', default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
def get_paths_from_svg(svg_file):
|
||||
"""
|
||||
Parses the SVG file and separates paths into Background, White, and Cyan.
|
||||
Returns a dictionary with list of svg.path objects.
|
||||
"""
|
||||
doc = minidom.parse(svg_file)
|
||||
paths = {
|
||||
'background': [],
|
||||
'white': [],
|
||||
'cyan': []
|
||||
}
|
||||
|
||||
# Process all path elements
|
||||
for path_node in doc.getElementsByTagName('path'):
|
||||
d_str = path_node.getAttribute('d')
|
||||
class_str = path_node.getAttribute('class')
|
||||
|
||||
if not d_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
path_obj = parse_path(d_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse path in {svg_file}: {e}")
|
||||
continue
|
||||
|
||||
# Classify based on your specs
|
||||
if class_str == CLASS_WHITE:
|
||||
paths['white'].append(path_obj)
|
||||
elif class_str == CLASS_CYAN:
|
||||
paths['cyan'].append(path_obj)
|
||||
else:
|
||||
# Assume no class or other classes are background/outline
|
||||
# But specifically, look for the main outline which usually has no class or a specific structure
|
||||
# based on your SVG, the background is the first path with no class.
|
||||
# We will treat any non-color path as background for now, or refine if needed.
|
||||
paths['background'].append(path_obj)
|
||||
|
||||
doc.unlink()
|
||||
return paths
|
||||
|
||||
def svg_path_to_shapely(path_obj, segments=CURVE_RESOLUTION):
|
||||
"""
|
||||
Converts an svg.path object to a shapely Polygon.
|
||||
Handles curves by discretizing them.
|
||||
"""
|
||||
points = []
|
||||
# Sample the path
|
||||
# If it's a closed path, we want a polygon.
|
||||
# Note: svg.path is a list of segments (Line, CubicBezier, etc.)
|
||||
|
||||
# Simple sampling approach
|
||||
n_points = segments
|
||||
for i in range(n_points + 1):
|
||||
f = i / n_points
|
||||
c = path_obj.point(f)
|
||||
points.append((c.real, c.imag))
|
||||
|
||||
# Check if closed
|
||||
if points[0] != points[-1]:
|
||||
points.append(points[0]) # force close if needed, though svg paths might be open
|
||||
|
||||
# Create polygon. If inconsistent winding, trimesh usually handles it,
|
||||
# but shapely needs valid rings.
|
||||
try:
|
||||
# Shapely requires valid linear rings
|
||||
if len(points) < 3:
|
||||
return None
|
||||
return Polygon(points)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def create_extrusion(paths, height, z_offset=0.0, scale=1.0):
|
||||
"""
|
||||
Takes a list of svg.path objects and converts to a 3D mesh.
|
||||
Uses Shapely's symmetric_difference to handle holes in paths correctly (Even-Odd rule).
|
||||
"""
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
||||
path_polygons = []
|
||||
|
||||
for p in paths:
|
||||
path_list = list(p)
|
||||
if not path_list:
|
||||
continue
|
||||
|
||||
# Split fixed single path into separate loops (sub-paths)
|
||||
loops = []
|
||||
current_loop = [path_list[0]]
|
||||
for i in range(1, len(path_list)):
|
||||
# Detect jumps in the path (e.g. Move segments or new contours)
|
||||
if abs(path_list[i].start - path_list[i-1].end) > 1e-4:
|
||||
loops.append(current_loop)
|
||||
current_loop = []
|
||||
current_loop.append(path_list[i])
|
||||
loops.append(current_loop)
|
||||
|
||||
# Build the final polygon for THIS path by XORing its loops
|
||||
this_path_poly = Polygon()
|
||||
for loop in loops:
|
||||
pts = []
|
||||
# Start of loop
|
||||
start_p = loop[0].point(0)
|
||||
pts.append((start_p.real * scale, -start_p.imag * scale))
|
||||
|
||||
for segment in loop:
|
||||
seg_type = segment.__class__.__name__
|
||||
# Adaptive sampling
|
||||
if seg_type == 'Line':
|
||||
num_samples = 1
|
||||
elif seg_type in ['QuadraticBezier', 'CubicBezier', 'Arc']:
|
||||
try:
|
||||
length = segment.length()
|
||||
num_samples = int(length * scale / 2.0)
|
||||
num_samples = max(4, min(num_samples, 20))
|
||||
except:
|
||||
num_samples = 8
|
||||
else:
|
||||
num_samples = 4
|
||||
|
||||
for j in range(1, num_samples + 1):
|
||||
point = segment.point(j / num_samples)
|
||||
pts.append((point.real * scale, -point.imag * scale))
|
||||
|
||||
if len(pts) < 3:
|
||||
continue
|
||||
|
||||
loop_poly = Polygon(pts)
|
||||
if not loop_poly.is_valid:
|
||||
loop_poly = loop_poly.buffer(0)
|
||||
|
||||
# Symmetric difference handles nested loops (Even-Odd fill)
|
||||
# This is how most vector engines handle compound paths (holes)
|
||||
this_path_poly = this_path_poly.symmetric_difference(loop_poly)
|
||||
|
||||
if not this_path_poly.is_empty:
|
||||
path_polygons.append(this_path_poly)
|
||||
|
||||
if not path_polygons:
|
||||
return None
|
||||
|
||||
# Merge all separate paths of the same color
|
||||
# unary_union joins overlapping shapes (e.g. adjacent letters in a logo)
|
||||
try:
|
||||
merged_poly = unary_union(path_polygons)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to merge polygons: {e}")
|
||||
return None
|
||||
|
||||
if merged_poly.is_empty:
|
||||
return None
|
||||
|
||||
# Extrude the result
|
||||
try:
|
||||
# trimesh.creation.extrude_polygon expects a Polygon, so we iterate if it's a MultiPolygon
|
||||
if hasattr(merged_poly, 'geoms'):
|
||||
polys = list(merged_poly.geoms)
|
||||
else:
|
||||
polys = [merged_poly]
|
||||
|
||||
meshes = []
|
||||
for poly in polys:
|
||||
# extrude_polygon handles Polygon with holes correctly
|
||||
m = trimesh.creation.extrude_polygon(poly, height=height)
|
||||
meshes.append(m)
|
||||
|
||||
if not meshes:
|
||||
return None
|
||||
|
||||
# Concatenate all parts (separate letters, etc.) into one mesh
|
||||
if len(meshes) == 1:
|
||||
mesh = meshes[0]
|
||||
else:
|
||||
mesh = trimesh.util.concatenate(meshes)
|
||||
|
||||
mesh.apply_translation([0, 0, z_offset])
|
||||
return mesh
|
||||
except Exception as e:
|
||||
logger.error(f"Extrusion failed: {e}")
|
||||
return None
|
||||
|
||||
def process_file(filepath, output_dir):
|
||||
filename = os.path.basename(filepath)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
|
||||
logger.info(f"Processing {filename}...")
|
||||
|
||||
paths = get_paths_from_svg(filepath)
|
||||
|
||||
parts = {
|
||||
'Black': {'paths': paths['background'], 'height': BACKGROUND_THICKNESS, 'z': 0.0},
|
||||
'White': {'paths': paths['white'], 'height': TEXT_THICKNESS, 'z': TEXT_Z_OFFSET},
|
||||
'Cyan': {'paths': paths['cyan'], 'height': TEXT_THICKNESS, 'z': TEXT_Z_OFFSET}
|
||||
}
|
||||
|
||||
# Calculate scale factor based on background
|
||||
target_width = 87.80
|
||||
scale_factor = 1.0
|
||||
|
||||
if parts['Black']['paths']:
|
||||
all_bg_pts = []
|
||||
for p in parts['Black']['paths']:
|
||||
for i in range(11):
|
||||
pt = p.point(i/10)
|
||||
all_bg_pts.append(pt.real)
|
||||
|
||||
if all_bg_pts:
|
||||
min_x = min(all_bg_pts)
|
||||
max_x = max(all_bg_pts)
|
||||
current_width = max_x - min_x
|
||||
if current_width > 0:
|
||||
scale_factor = target_width / current_width
|
||||
logger.info(f" Scaling factor: {scale_factor:.4f} (Original width: {current_width:.2f} -> {target_width})")
|
||||
|
||||
file_output_dir = os.path.join(output_dir, base_name)
|
||||
if not os.path.exists(file_output_dir):
|
||||
os.makedirs(file_output_dir)
|
||||
|
||||
for color, data in parts.items():
|
||||
if not data['paths']:
|
||||
logging.info(f" No paths for {color} in {filename}")
|
||||
continue
|
||||
|
||||
mesh = create_extrusion(data['paths'], data['height'], data['z'], scale=scale_factor)
|
||||
if mesh:
|
||||
out_name = f"{base_name}_{color}.stl"
|
||||
out_path = os.path.join(file_output_dir, out_name)
|
||||
mesh.export(out_path)
|
||||
logging.info(f" Exported {out_path}")
|
||||
|
||||
def main():
|
||||
args = setup_args()
|
||||
|
||||
input_path = args.input_dir
|
||||
if not os.path.exists(input_path):
|
||||
logger.error(f"Input path {input_path} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = args.output_dir if args.output_dir else os.path.join(os.path.dirname(input_path) if os.path.isfile(input_path) else input_path, 'stl_output')
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
files_to_process = []
|
||||
if os.path.isfile(input_path):
|
||||
if input_path.lower().endswith('.svg'):
|
||||
files_to_process.append(input_path)
|
||||
else:
|
||||
for file in os.listdir(input_path):
|
||||
if file.lower().endswith('.svg') and not file.startswith('._'):
|
||||
full_path = os.path.join(input_path, file)
|
||||
if os.path.isfile(full_path):
|
||||
files_to_process.append(full_path)
|
||||
|
||||
for full_path in files_to_process:
|
||||
try:
|
||||
process_file(full_path, output_dir)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Failed to process {full_path}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
13
count_tris.py
Normal file
13
count_tris.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import trimesh
|
||||
import sys
|
||||
|
||||
def count_triangles(file_path):
|
||||
try:
|
||||
mesh = trimesh.load(file_path)
|
||||
print(f"{file_path}: {len(mesh.faces)} triangles")
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
for f in sys.argv[1:]:
|
||||
count_triangles(f)
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
trimesh
|
||||
shapely
|
||||
numpy
|
||||
scipy
|
||||
lxml
|
||||
svg.path
|
||||
networkx
|
||||
mapbox_earcut
|
||||
Reference in New Issue
Block a user