-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmake-real-objects.py
More file actions
363 lines (292 loc) · 14.1 KB
/
make-real-objects.py
File metadata and controls
363 lines (292 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import math
import os
import sys
from typing import List, Tuple
from xml.etree import ElementTree as ET
import numpy as np
import trimesh
from shapely.geometry import Polygon
from svgpathtools import parse_path
# Type aliases
Point = Tuple[float, float]
Shape2D = Tuple[List[Point], List[List[Point]]] # outer, holes
def rect_to_polygon(x: float, y: float, width: float, height: float) -> List[Point]:
"""Convert an SVG rect to a list of polygon points (closed loop)."""
return [
(x, y),
(x + width, y),
(x + width, y + height),
(x, y + height),
(x, y) # Closed loop
]
def approximate_circle(cx: float, cy: float, r: float, segments: int = 32) -> List[Point]:
"""Approximate a circle as a polygon."""
return [
(
cx + r * math.cos(2 * math.pi * i / segments),
cy + r * math.sin(2 * math.pi * i / segments)
)
for i in range(segments)
]
def parse_svg_shapes(svg_path: str) -> List[Tuple[str, Polygon]]:
"""Parse rectangles, circles, and paths from SVG and return filled polygons."""
tree = ET.parse(svg_path)
root = tree.getroot()
ns = {'svg': 'http://www.w3.org/2000/svg'}
shapes: List[Tuple[str, Polygon]] = []
for elem in root.iter():
tag = elem.tag.split('}')[-1]
# Get fill color from fill attribute or style
fill = elem.attrib.get('fill', '#000000')
if fill == 'none':
fill = '#000000'
# Also check style attribute
style = elem.attrib.get('style', '')
for part in style.split(';'):
if part.strip().startswith('fill:'):
style_fill = part.split(':')[1].strip()
if style_fill != 'none':
fill = style_fill
if tag == 'rect':
x = float(elem.attrib['x'])
y = float(elem.attrib['y'])
w = float(elem.attrib['width'])
h = float(elem.attrib['height'])
poly = Polygon(rect_to_polygon(x, y, w, h))
shapes.append((fill, poly))
elif tag == 'circle':
cx = float(elem.attrib['cx'])
cy = float(elem.attrib['cy'])
r = float(elem.attrib['r'])
points = approximate_circle(cx, cy, r, segments=32) # Reduced segments
shapes.append((fill, Polygon(points)))
elif tag == 'ellipse':
cx = float(elem.attrib['cx'])
cy = float(elem.attrib['cy'])
rx = float(elem.attrib['rx'])
ry = float(elem.attrib['ry'])
points = [
(
cx + rx * math.cos(2 * math.pi * i / 32),
cy + ry * math.sin(2 * math.pi * i / 32)
) for i in range(32)
]
shapes.append((fill, Polygon(points)))
elif tag == 'path':
d = elem.attrib.get('d')
if d:
try:
# The path contains multiple shapes separated by Z M commands
# We need to parse the whole thing first to get all sub-paths
path = parse_path(d)
# Now we need to detect where new sub-paths start by looking for large jumps
points_groups = []
current_group = []
last_point = None
for seg in path:
# Get starting point of this segment
start_pt = (seg.start.real, seg.start.imag)
# Check if this is a new sub-path (large jump from previous endpoint)
if last_point is not None:
distance = ((start_pt[0] - last_point[0])**2 + (start_pt[1] - last_point[1])**2)**0.5
if distance > 10: # Significant jump = new sub-path
if len(current_group) >= 3:
points_groups.append(current_group)
current_group = []
# Sample this segment
samples = max(5, min(20, int(seg.length() * 0.5)))
if samples > 0:
seg_points = [
(seg.point(t).real, seg.point(t).imag)
for t in np.linspace(0, 1, samples)
]
current_group.extend(seg_points)
# Update last point
last_point = (seg.end.real, seg.end.imag)
# Don't forget the last group
if len(current_group) >= 3:
points_groups.append(current_group)
# Create polygons from each group
for idx, points in enumerate(points_groups):
if len(points) >= 3:
try:
poly = Polygon(points)
# Fix invalid polygons
if not poly.is_valid:
try:
poly = poly.buffer(0) # Fix self-intersections
except:
continue
if poly.is_valid and poly.area > 0.1:
shapes.append((fill, poly))
except Exception as e:
print(f"Failed to create polygon from sub-path {idx}: {e}")
except Exception as e:
print(f"Failed to parse path: {e}")
return shapes
def extract_shapes(rects: List[Tuple[str, Polygon]]) -> List[Shape2D]:
"""Group colored polygons with holes and return list of 2D compound shapes.
FOR STENCIL MODE:
1. Find the largest shape (bounding box) - this becomes the outer solid
2. Take ALL smaller shapes and union them - these become HOLES in the stencil
3. The result is: bounding box with all content shapes as holes
"""
if not rects:
return []
# Deduplicate shapes by geometry (same area and similar bounds)
unique_shapes = []
for color, poly in rects:
is_duplicate = False
for existing_color, existing_poly in unique_shapes:
# Check if shapes are essentially the same (same area and similar bounds)
if (abs(poly.area - existing_poly.area) < 1.0 and
poly.bounds == existing_poly.bounds):
is_duplicate = True
break
if not is_duplicate:
unique_shapes.append((color, poly))
print(f"After deduplication: {len(unique_shapes)} unique shapes")
# Sort by area, largest first
sorted_shapes = sorted(unique_shapes, key=lambda x: x[1].area, reverse=True)
if len(sorted_shapes) == 0:
return []
# Check if we have an even-odd fill rule situation:
# - One very large shape (bounding box)
# - Multiple smaller shapes inside it
largest_area = sorted_shapes[0][1].area
# If the largest shape is MUCH larger than the second, it's likely a bounding box
if len(sorted_shapes) > 1:
second_area = sorted_shapes[1][1].area
if largest_area > second_area * 5: # 5x larger = likely bounding box
print(f"Detected bounding box pattern - creating STENCIL (largest {largest_area:.2f} >> second {second_area:.2f})")
# The BOUNDING BOX becomes the outer shape
bounding_box = sorted_shapes[0][1]
# Union all the smaller shapes - these become HOLES
content_shapes = [p for c, p in sorted_shapes[1:]]
if content_shapes:
holes_union = content_shapes[0]
for shape in content_shapes[1:]:
try:
holes_union = holes_union.union(shape)
except Exception as e:
print(f"Failed to union hole shapes: {e}")
# Handle MultiPolygon case - we need to extract all individual holes
holes = []
if hasattr(holes_union, 'geoms'):
# Multiple disconnected holes
for geom in holes_union.geoms:
if geom.area > 1.0: # Filter tiny artifacts
holes.append(list(geom.exterior.coords[:-1]))
print(f"Added hole with area {geom.area:.2f}")
else:
# Single hole
holes.append(list(holes_union.exterior.coords[:-1]))
print(f"Added single hole with area {holes_union.area:.2f}")
# Return the bounding box with all content as holes
return [(list(bounding_box.exterior.coords[:-1]), holes)]
# Fallback: Use the largest shape as main, others as holes
main_color, main_shape = sorted_shapes[0]
print(f"Using largest shape as main: color={main_color}, area={main_shape.area:.2f}")
holes = []
for color, hole_shape in sorted_shapes[1:]:
try:
if hole_shape.area < main_shape.area * 0.8:
if main_shape.contains(hole_shape):
holes.append(list(hole_shape.exterior.coords[:-1]))
print(f"Added hole: color={color}, area={hole_shape.area:.2f}")
except Exception as e:
print(f"Failed to process potential hole: {e}")
continue
return [(list(main_shape.exterior.coords[:-1]), holes)]
def simplify_polygon(polygon: Polygon, tolerance: float = 1.0) -> Polygon:
"""Simplify a polygon to reduce complexity and prevent triangulation issues."""
try:
# Simplify the exterior ring
simplified_exterior = polygon.exterior.simplify(tolerance, preserve_topology=True)
# Simplify holes if they exist
simplified_holes = []
for hole in polygon.interiors:
simplified_hole = hole.simplify(tolerance, preserve_topology=True)
simplified_holes.append(simplified_hole)
return Polygon(simplified_exterior, simplified_holes)
except Exception as e:
print(f"Failed to simplify polygon: {e}")
return polygon
def extrude_with_position(polygon: Polygon, height: float) -> trimesh.Trimesh:
"""Extrudes a shapely polygon and keeps it positioned in world space."""
# Simplify the polygon first to prevent triangulation issues
simplified_polygon = simplify_polygon(polygon, tolerance=2.0)
try:
mesh = trimesh.creation.extrude_polygon(simplified_polygon, height=height)
return mesh
except Exception as e:
print(f"Failed to extrude polygon: {e}")
# Try with even more simplification
very_simple = simplify_polygon(polygon, tolerance=5.0)
mesh = trimesh.creation.extrude_polygon(very_simple, height=height)
return mesh
def main():
print("Starting...")
svg_file = sys.argv[1] if len(sys.argv) > 1 else "bender.svg" # Your SVG file
# Validate SVG file
if not svg_file.lower().endswith('.svg'):
print(f"Error: Input file must be an SVG file (got: {svg_file})")
print("Usage: python make-real-objects.py <input.svg>")
sys.exit(1)
if not os.path.exists(svg_file):
print(f"Error: SVG file not found: {svg_file}")
print("Usage: python make-real-objects.py <input.svg>")
sys.exit(1)
output_stl = svg_file[:-4] + "_converted.stl"
print(f"Parsing SVG...{svg_file}")
# Step 1: Parse shapes from SVG
shapes_data = parse_svg_shapes(svg_file)
print(f"Found {len(shapes_data)} shapes in SVG")
# Debug: show summary
total_area = sum(poly.area for _, poly in shapes_data)
print(f"Total area parsed: {total_area:.2f}")
# Step 2: Build compound shapes (outer + holes)
shapes = extract_shapes(shapes_data)
print(f"Created {len(shapes)} compound shapes")
if not shapes:
print("No valid shapes found!")
return
# Step 3: Extrude each shape and collect meshes
all_meshes = []
height = 5
for i, (outer, holes) in enumerate(shapes):
print(f"Processing shape {i}: outer has {len(outer)} points, {len(holes)} holes")
try:
shape = Polygon(outer, holes)
if not shape.is_valid:
print(f"Shape {i} is invalid, attempting to fix...")
# Try multiple methods to fix invalid geometry
try:
shape = shape.buffer(0) # Try to fix invalid geometry
except:
try:
# Try simplifying first
simplified_outer = simplify_polygon(Polygon(outer), tolerance=5.0)
shape = Polygon(simplified_outer.exterior.coords[:-1], holes)
except:
# Last resort: use just the outer boundary
shape = Polygon(outer)
if shape.is_valid and shape.area > 0:
mesh = extrude_with_position(shape, height)
all_meshes.append(mesh)
print(f"Successfully extruded shape {i}")
else:
print(f"Skipping invalid or empty shape {i} (valid={shape.is_valid}, area={shape.area})")
except Exception as e:
print(f"Failed to process shape {i}: {e}")
if not all_meshes:
print("No valid meshes created!")
return
# Step 4: Combine and export as STL
print("Combining meshes...")
combined = trimesh.util.concatenate(all_meshes)
combined.export(output_stl)
print(f"Exported {len(all_meshes)} objects to {output_stl}")
print(f"Final mesh has {len(combined.vertices)} vertices and {len(combined.faces)} faces")
if __name__ == "__main__":
main()