ventforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ventforge/__init__.py ADDED
File without changes
ventforge/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """One entry point for both programs, so a bundled app can also serve as the MCP server:
2
+ ventforge [design.vent.json] desktop editor
3
+ ventforge --mcp MCP server (stdio)
4
+ """
5
+ import sys
6
+
7
+
8
+ def run():
9
+ if "--mcp" in sys.argv[1:]:
10
+ from .mcp_server import main
11
+ return main()
12
+ try:
13
+ from .gui import main
14
+ except ImportError as e:
15
+ if not (e.name or "").startswith("PySide6"):
16
+ raise
17
+ raise SystemExit('The VentForge editor needs the GUI extra: pip install "ventforge[gui]"') from None
18
+ return main()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ raise SystemExit(run())
ventforge/export.py ADDED
@@ -0,0 +1,629 @@
1
+ """Exporters: SVG, PNG, DXF, STEP, STL, plus wrap-onto-cylinder and cut-into-existing-part.
2
+
3
+ All inputs are a pattern `Result` (shapely polygons in mm, y-up) and the `Design`.
4
+ 3D plates are built by direct construction (one face with inner wires, then a prism),
5
+ never by boolean cuts: ~100x faster for thousands of holes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import math
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import shapely
15
+ from shapely.geometry import MultiPolygon, Polygon
16
+ from shapely.geometry.polygon import orient
17
+ from shapely.strtree import STRtree
18
+
19
+ from .model import Design, Result
20
+
21
+
22
+ def _circle(result: Result, i: int):
23
+ return result.circles[i] if i < len(result.circles) else None
24
+
25
+
26
+ def _parts(geom) -> list[Polygon]:
27
+ if geom.is_empty:
28
+ return []
29
+ if isinstance(geom, Polygon):
30
+ return [geom]
31
+ return [g for g in getattr(geom, "geoms", []) if isinstance(g, Polygon) and not g.is_empty]
32
+
33
+
34
+ def _ring_xy(ring) -> list[tuple[float, float]]:
35
+ return list(ring.coords)[:-1]
36
+
37
+
38
+ # --------------------------------------------------------------------------- SVG
39
+ def to_svg(result: Result, design: Design, path) -> None:
40
+ minx, miny, maxx, maxy = result.outline.bounds
41
+ w, h = maxx - minx, maxy - miny
42
+
43
+ def d(poly: Polygon) -> str:
44
+ out = []
45
+ for ring in [poly.exterior, *poly.interiors]:
46
+ pts = _ring_xy(ring)
47
+ out.append("M" + " L".join(f"{x:.4f},{-y:.4f}" for x, y in pts) + " Z")
48
+ return " ".join(out)
49
+
50
+ color = design.dot.color
51
+ lines = [
52
+ '<?xml version="1.0" encoding="UTF-8"?>',
53
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{w:.4f}mm" height="{h:.4f}mm" '
54
+ f'viewBox="{minx:.4f} {-maxy:.4f} {w:.4f} {h:.4f}">',
55
+ f'<title>{_xml(design.name)}</title>',
56
+ f'<path id="outline" d="{d(orient(result.outline))}" fill="none" stroke="#000000" '
57
+ f'stroke-width="{max(w, h) / 1000:.4f}"/>',
58
+ f'<g id="holes" fill="{_xml(color)}" stroke="none" fill-rule="evenodd">',
59
+ ]
60
+ for i, hole in enumerate(result.holes):
61
+ c = _circle(result, i)
62
+ if c:
63
+ lines.append(f'<circle cx="{c[0]:.4f}" cy="{-c[1]:.4f}" r="{c[2]:.4f}"/>')
64
+ else:
65
+ for p in _parts(hole):
66
+ lines.append(f'<path d="{d(p)}"/>')
67
+ lines += ["</g>", "</svg>"]
68
+ Path(path).write_text("\n".join(lines), encoding="utf-8")
69
+
70
+
71
+ def _xml(s: str) -> str:
72
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
73
+
74
+
75
+ # --------------------------------------------------------------------------- PNG
76
+ def to_png(result: Result, design: Design, path=None, px: int = 1200,
77
+ transparent: bool = True, show_outline: bool = True) -> bytes:
78
+ from PIL import Image, ImageDraw
79
+
80
+ ss = 3 # supersample factor
81
+ minx, miny, maxx, maxy = result.outline.bounds
82
+ pad = 0.01 * max(maxx - minx, maxy - miny)
83
+ minx, miny, maxx, maxy = minx - pad, miny - pad, maxx + pad, maxy + pad
84
+ s = px / max(maxx - minx, maxy - miny)
85
+ W, H = max(1, round((maxx - minx) * s)), max(1, round((maxy - miny) * s))
86
+ k = s * ss
87
+
88
+ def tr(pts):
89
+ return [((x - minx) * k, (maxy - y) * k) for x, y in pts]
90
+
91
+ img = Image.new("RGBA", (W * ss, H * ss), (0, 0, 0, 0) if transparent else (255, 255, 255, 255))
92
+ dr = ImageDraw.Draw(img)
93
+ color = design.dot.color
94
+ for i, hole in enumerate(result.holes):
95
+ c = _circle(result, i)
96
+ if c:
97
+ cx, cy = (c[0] - minx) * k, (maxy - c[1]) * k
98
+ r = c[2] * k
99
+ dr.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
100
+ continue
101
+ for p in _parts(hole):
102
+ dr.polygon(tr(_ring_xy(p.exterior)), fill=color)
103
+ for ring in p.interiors: # islands inside a hole: punch back to background
104
+ dr.polygon(tr(_ring_xy(ring)), fill=img.getpixel((0, 0)))
105
+ if show_outline:
106
+ lw = max(1, round(ss * px / 600))
107
+ for p in _parts(result.outline):
108
+ for ring in [p.exterior, *p.interiors]:
109
+ pts = tr(ring.coords)
110
+ dr.line(pts, fill="#333333", width=lw, joint="curve")
111
+ img = img.resize((W, H), Image.Resampling.BOX)
112
+ buf = io.BytesIO()
113
+ img.save(buf, "PNG", optimize=False, compress_level=1)
114
+ data = buf.getvalue()
115
+ if path:
116
+ Path(path).write_bytes(data)
117
+ return data
118
+
119
+
120
+ # --------------------------------------------------------------------------- DXF
121
+ def to_dxf(result: Result, path) -> None:
122
+ import ezdxf
123
+ from ezdxf import units
124
+
125
+ doc = ezdxf.new("R2010", setup=False)
126
+ doc.units = units.MM
127
+ doc.layers.add("OUTLINE", color=7)
128
+ doc.layers.add("HOLES", color=1)
129
+ msp = doc.modelspace()
130
+
131
+ def poly(p: Polygon, layer: str):
132
+ for ring in [p.exterior, *p.interiors]:
133
+ msp.add_lwpolyline(_ring_xy(ring), close=True, dxfattribs={"layer": layer})
134
+
135
+ for p in _parts(result.outline):
136
+ poly(p, "OUTLINE")
137
+ for i, hole in enumerate(result.holes):
138
+ c = _circle(result, i)
139
+ if c:
140
+ msp.add_circle((c[0], c[1]), c[2], dxfattribs={"layer": "HOLES"})
141
+ else:
142
+ for p in _parts(hole):
143
+ poly(p, "HOLES")
144
+ doc.saveas(str(path))
145
+
146
+
147
+ # --------------------------------------------------------------------------- OCC helpers
148
+ def _occ_wire_poly(pts, z=0.0, frame=None):
149
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
150
+ from OCP.gp import gp_Pnt
151
+
152
+ mp = BRepBuilderAPI_MakePolygon()
153
+ for x, y in pts:
154
+ mp.Add(frame(x, y) if frame else gp_Pnt(x, y, z))
155
+ mp.Close()
156
+ return mp.Wire()
157
+
158
+
159
+ def _occ_wire_circle(cx, cy, r, ccw: bool, ax=None):
160
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire
161
+ from OCP.gp import gp_Ax2, gp_Circ, gp_Dir, gp_Pnt
162
+
163
+ if ax is None:
164
+ ax = gp_Ax2(gp_Pnt(cx, cy, 0), gp_Dir(0, 0, 1 if ccw else -1))
165
+ return BRepBuilderAPI_MakeWire(BRepBuilderAPI_MakeEdge(gp_Circ(ax, r)).Edge()).Wire()
166
+
167
+
168
+ def _clean(p: Polygon) -> Polygon:
169
+ # drop micro-segments from shapely booleans; OCC rejects near-degenerate edges
170
+ q = p.simplify(1e-4, preserve_topology=True)
171
+ return orient(q if q.is_valid and not q.is_empty else p, 1.0)
172
+
173
+
174
+ def _split(result: Result):
175
+ """-> (simple, complex): simple = [(hole, (cx,cy,r))] exact circles with clearance from the
176
+ outline edge and every other hole; complex = all other hole polygons (to be unioned in 2D)."""
177
+ holes = result.holes
178
+ if not holes:
179
+ return [], []
180
+ tree = STRtree(holes)
181
+ edge = result.outline.boundary
182
+ simple, complex_ = [], []
183
+ for i, h in enumerate(holes):
184
+ c = _circle(result, i)
185
+ eps = 0.005 + 0.01 * c[2] if c else 0 # polygon vs exact arc bulge + slack
186
+ if (c and result.outline.contains(shapely.Point(c[0], c[1]))
187
+ and edge.distance(shapely.Point(c[0], c[1])) > c[2] + eps
188
+ and len(tree.query(h, predicate="dwithin", distance=eps)) == 1):
189
+ simple.append((h, c))
190
+ else:
191
+ complex_.append(h)
192
+ return simple, complex_
193
+
194
+
195
+ def _plate_faces(result: Result):
196
+ """Planar faces at z=0 = outline minus holes, plus the expected area (for validation)."""
197
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
198
+
199
+ simple, complex_ = _split(result)
200
+ # grow holes by 1e-5 mm so edges that coincide with the outline (clipped holes) cut cleanly
201
+ # instead of leaving zero-width slivers, which OCC sees as self-intersecting wires
202
+ cut = shapely.union_all(complex_).buffer(1e-5, join_style="mitre") if complex_ else None
203
+ plate = result.outline.difference(cut) if cut is not None else result.outline
204
+ parts = [_clean(p) for p in _parts(plate) if p.area > 1e-9]
205
+ area = sum(p.area for p in parts)
206
+
207
+ builders = []
208
+ for p in parts:
209
+ mf = BRepBuilderAPI_MakeFace(_occ_wire_poly(_ring_xy(p.exterior)), True)
210
+ for ring in p.interiors:
211
+ mf.Add(_occ_wire_poly(_ring_xy(ring)))
212
+ builders.append(mf)
213
+ if simple:
214
+ ptree = STRtree(parts)
215
+ for _, (cx, cy, r) in simple:
216
+ idx = ptree.query(shapely.Point(cx, cy), predicate="intersects")
217
+ if len(idx):
218
+ builders[int(idx[0])].Add(_occ_wire_circle(cx, cy, r, ccw=False))
219
+ area -= math.pi * r * r
220
+ faces = []
221
+ for mf in builders:
222
+ if not mf.IsDone():
223
+ raise RuntimeError("VentForge export: could not build plate face")
224
+ faces.append(mf.Face())
225
+ return faces, area
226
+
227
+
228
+ def _compound(shapes):
229
+ from OCP.BRep import BRep_Builder
230
+ from OCP.TopoDS import TopoDS_Compound
231
+
232
+ comp = TopoDS_Compound()
233
+ b = BRep_Builder()
234
+ b.MakeCompound(comp)
235
+ for s in shapes:
236
+ b.Add(comp, s)
237
+ return comp
238
+
239
+
240
+ FULL_CHECK_EDGES = 3000
241
+
242
+
243
+ def _check(shape, what: str, expect_volume=None):
244
+ """BRepCheck the result. Its face check is O(edges^2) (40 s for 2000 hexes), so above
245
+ FULL_CHECK_EDGES we rely on the 2D validity guaranteed by shapely and check the volume.
246
+ shortcut: big shapes skip BRepCheck, raise FULL_CHECK_EDGES if you need it always."""
247
+ from OCP.BRepCheck import BRepCheck_Analyzer
248
+ from OCP.TopAbs import TopAbs_EDGE
249
+ from OCP.TopExp import TopExp_Explorer
250
+
251
+ if not solids(shape):
252
+ raise RuntimeError(f"VentForge export: {what} produced no solid")
253
+ n, ex = 0, TopExp_Explorer(shape, TopAbs_EDGE)
254
+ while ex.More() and n <= 2 * FULL_CHECK_EDGES: # explorer visits shared edges twice
255
+ n += 1
256
+ ex.Next()
257
+ if n <= 2 * FULL_CHECK_EDGES and not BRepCheck_Analyzer(shape, True, True).IsValid():
258
+ raise RuntimeError(f"VentForge export: {what} produced an invalid solid (BRepCheck failed)")
259
+ if expect_volume is not None:
260
+ v = volume(shape)
261
+ if abs(v - expect_volume) > 1e-3 * abs(expect_volume) + 1e-6:
262
+ raise RuntimeError(f"VentForge export: {what} solid is invalid "
263
+ f"(volume {v:.3f} != expected {expect_volume:.3f} mm^3)")
264
+ return shape
265
+
266
+
267
+ def _write(shape, path, tolerance: float = 0.05):
268
+ path = Path(path)
269
+ if path.suffix.lower() == ".stl":
270
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
271
+ from OCP.StlAPI import StlAPI_Writer
272
+
273
+ BRepMesh_IncrementalMesh(shape, tolerance, False, 0.5, True).Perform()
274
+ w = StlAPI_Writer()
275
+ w.ASCIIMode = False
276
+ if not w.Write(shape, str(path)):
277
+ raise RuntimeError(f"STL write failed: {path}")
278
+ else:
279
+ from OCP.IFSelect import IFSelect_RetDone
280
+ from OCP.Interface import Interface_Static
281
+ from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
282
+
283
+ Interface_Static.SetCVal_s("write.step.unit", "MM")
284
+ w = STEPControl_Writer()
285
+ w.Transfer(shape, STEPControl_AsIs)
286
+ if w.Write(str(path)) != IFSelect_RetDone:
287
+ raise RuntimeError(f"STEP write failed: {path}")
288
+
289
+
290
+ def read_step(path):
291
+ from OCP.IFSelect import IFSelect_RetDone
292
+ from OCP.STEPControl import STEPControl_Reader
293
+
294
+ r = STEPControl_Reader()
295
+ if r.ReadFile(str(path)) != IFSelect_RetDone:
296
+ raise RuntimeError(f"cannot read STEP: {path}")
297
+ r.TransferRoots()
298
+ return r.OneShape()
299
+
300
+
301
+ def solids(shape) -> list:
302
+ from OCP.TopAbs import TopAbs_SOLID
303
+ from OCP.TopExp import TopExp_Explorer
304
+
305
+ out, ex = [], TopExp_Explorer(shape, TopAbs_SOLID)
306
+ while ex.More():
307
+ out.append(ex.Current())
308
+ ex.Next()
309
+ return out
310
+
311
+
312
+ def volume(shape) -> float:
313
+ from OCP.BRepGProp import BRepGProp
314
+ from OCP.GProp import GProp_GProps
315
+
316
+ g = GProp_GProps()
317
+ BRepGProp.VolumeProperties_s(shape, g)
318
+ return g.Mass()
319
+
320
+
321
+ # --------------------------------------------------------------------------- flat plate
322
+ TILE_EDGES = 400 # target edges per top face; OCC STEP write/read is ~O(wires^2) per face
323
+
324
+
325
+ def _count(shape, typ) -> int:
326
+ from OCP.TopExp import TopExp_Explorer
327
+
328
+ n, e = 0, TopExp_Explorer(shape, typ)
329
+ while e.More():
330
+ n += 1
331
+ e.Next()
332
+ return n
333
+
334
+
335
+ def _tiled_prism(face, t: float):
336
+ """Prism of `face` as ONE solid whose top/bottom are split into tiles.
337
+
338
+ A single face with thousands of inner wires makes the STEP writer (and CAD readers)
339
+ quadratic: 41 s for 2000 hexes. Splitting the face with a line grid (exact circles stay
340
+ exact arcs), extruding the connected faces into a compsolid and dissolving the shared
341
+ internal walls gives the same solid at ~linear cost.
342
+ """
343
+ from OCP.BRep import BRep_Builder
344
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter
345
+ from OCP.BRepBndLib import BRepBndLib
346
+ from OCP.Bnd import Bnd_Box
347
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
348
+ from OCP.BRepLib import BRepLib_MakeSolid
349
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
350
+ from OCP.collections import List_TopoDS_Shape
351
+ from OCP.TopAbs import TopAbs_EDGE
352
+ from OCP.TopoDS import TopoDS_CompSolid
353
+ from OCP.gp import gp_Pnt, gp_Vec
354
+
355
+ k = math.ceil(math.sqrt(_count(face, TopAbs_EDGE) / TILE_EDGES))
356
+ if k <= 1:
357
+ return BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, t)).Shape()
358
+ bb = Bnd_Box()
359
+ BRepBndLib.Add_s(face, bb)
360
+ lo, hi = bb.CornerMin(), bb.CornerMax()
361
+ x0, y0, x1, y1 = lo.X(), lo.Y(), hi.X(), hi.Y()
362
+ tools = List_TopoDS_Shape()
363
+ for i in range(1, k):
364
+ # irrational-ish offsets so cut lines rarely hit vertices or run along grid edges
365
+ x = x0 + (i + 0.0137) * (x1 - x0) / k
366
+ y = y0 + (i + 0.0291) * (y1 - y0) / k
367
+ tools.Append(BRepBuilderAPI_MakeEdge(gp_Pnt(x, y0 - 1, 0), gp_Pnt(x, y1 + 1, 0)).Edge())
368
+ tools.Append(BRepBuilderAPI_MakeEdge(gp_Pnt(x0 - 1, y, 0), gp_Pnt(x1 + 1, y, 0)).Edge())
369
+ args = List_TopoDS_Shape()
370
+ args.Append(face)
371
+ sp = BRepAlgoAPI_Splitter()
372
+ sp.SetArguments(args)
373
+ sp.SetTools(tools)
374
+ sp.SetRunParallel(True)
375
+ sp.Build()
376
+ if not sp.IsDone():
377
+ return BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, t)).Shape() # slow but correct
378
+ cells = solids(BRepPrimAPI_MakePrism(sp.Shape(), gp_Vec(0, 0, t)).Shape())
379
+ cs = TopoDS_CompSolid()
380
+ b = BRep_Builder()
381
+ b.MakeCompSolid(cs)
382
+ for c in cells:
383
+ b.Add(cs, c)
384
+ ms = BRepLib_MakeSolid(cs)
385
+ if not ms.IsDone():
386
+ raise RuntimeError("VentForge export: could not merge plate tiles into one solid")
387
+ return ms.Solid()
388
+
389
+
390
+ def _need_cad():
391
+ try:
392
+ import OCP # noqa: F401
393
+ except ImportError:
394
+ raise RuntimeError('STEP/STL export, tube wrap and cutting into parts need the CAD extra: '
395
+ 'pip install "ventforge[cad]"') from None
396
+
397
+
398
+ def plate_shape(result: Result, design: Design):
399
+ _need_cad()
400
+ t = design.canvas.thickness
401
+ faces, area = _plate_faces(result)
402
+ if not faces:
403
+ raise ValueError("VentForge export: holes cover the whole plate, nothing to build")
404
+ sols = [_tiled_prism(f, t) for f in faces]
405
+ shape = sols[0] if len(sols) == 1 else _compound(sols)
406
+ return _check(shape, "flat plate", area * t)
407
+
408
+
409
+ def to_step(result: Result, design: Design, path) -> None:
410
+ _write(plate_shape(result, design), path)
411
+
412
+
413
+ def to_stl(result: Result, design: Design, path, tolerance: float = 0.05) -> None:
414
+ _write(plate_shape(result, design), path, tolerance)
415
+
416
+
417
+ # --------------------------------------------------------------------------- hole tools
418
+ def _hole_regions(result: Result):
419
+ """Merged hole regions: list of (Polygon, circle-or-None)."""
420
+ simple, complex_ = _split(result)
421
+ if complex_:
422
+ simple += [(_clean(p), None) for p in _parts(shapely.union_all(complex_)) if p.area > 1e-9]
423
+ return simple
424
+
425
+
426
+ def _bop(kind, args_, tools_):
427
+ from OCP.collections import List_TopoDS_Shape
428
+
429
+ a, t = List_TopoDS_Shape(), List_TopoDS_Shape()
430
+ for x in args_:
431
+ a.Append(x)
432
+ for x in tools_:
433
+ t.Append(x)
434
+ op = kind()
435
+ op.SetArguments(a)
436
+ op.SetTools(t)
437
+ op.SetRunParallel(True)
438
+ op.Build()
439
+ return op
440
+
441
+
442
+ def _cut(base, tools, what: str, planes=()):
443
+ """One boolean cut of `base` by all `tools`.
444
+
445
+ With `planes` (gp_Pln list) and a single-solid base, the base is first split into cells
446
+ so no face collects thousands of holes (OCC face building and STEP writing are ~quadratic
447
+ in wires per face); the cut cells are then merged back into one solid.
448
+ """
449
+ from OCP.BRep import BRep_Builder
450
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Splitter
451
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
452
+ from OCP.BRepLib import BRepLib_MakeSolid
453
+ from OCP.TopoDS import TopoDS_CompSolid
454
+
455
+ tiled = bool(planes) and len(solids(base)) == 1
456
+ arg = base
457
+ if tiled:
458
+ L = 1e4
459
+ sp = _bop(BRepAlgoAPI_Splitter, [base],
460
+ [BRepBuilderAPI_MakeFace(pl, -L, L, -L, L).Face() for pl in planes])
461
+ tiled = sp.IsDone() and len(solids(sp.Shape())) > 1
462
+ if tiled:
463
+ arg = sp.Shape()
464
+ op = _bop(BRepAlgoAPI_Cut, [arg], tools)
465
+ if not op.IsDone():
466
+ raise RuntimeError(f"VentForge export: boolean cut failed ({what})")
467
+ shape = op.Shape()
468
+ if tiled:
469
+ cs = TopoDS_CompSolid()
470
+ b = BRep_Builder()
471
+ b.MakeCompSolid(cs)
472
+ for c in solids(shape):
473
+ b.Add(cs, c)
474
+ expect = volume(shape)
475
+ ms = BRepLib_MakeSolid(cs)
476
+ if not ms.IsDone():
477
+ raise RuntimeError(f"VentForge export: could not merge cut cells ({what})")
478
+ return _check(ms.Solid(), what, expect)
479
+ return _check(shape, what)
480
+
481
+
482
+ TOOLS_PER_CELL = 50
483
+
484
+
485
+ def _grid_planes(n_tools, u_range, v_range, plane_u, plane_v):
486
+ """k-1 cut planes along each axis, k chosen for ~TOOLS_PER_CELL tools per cell."""
487
+ k = math.ceil(math.sqrt(n_tools / TOOLS_PER_CELL))
488
+ out = []
489
+ for i in range(1, k):
490
+ f = (i + 0.0137) / k # offset so planes rarely pass through grid vertices
491
+ out.append(plane_u(u_range[0] + f * (u_range[1] - u_range[0])))
492
+ out.append(plane_v(v_range[0] + f * (v_range[1] - v_range[0])))
493
+ return out
494
+
495
+
496
+ def wrap_cylinder(result: Result, design: Design, path, radius=None, wall=None, height=None) -> dict:
497
+ """Wrap the pattern around a tube (outer radius `radius`, wall inward).
498
+
499
+ Canvas x -> arc length on the outer surface, canvas y -> z (z = y + height/2).
500
+ Each hole is cut by a straight prism along the radial direction through its centre,
501
+ so it is exact at the outer surface; wide holes get slightly narrower inside.
502
+ """
503
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
504
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
505
+ from OCP.gp import gp_Ax2, gp_Dir, gp_Pln, gp_Pnt, gp_Vec
506
+
507
+ _need_cad()
508
+ t0 = time.perf_counter()
509
+ c = design.canvas
510
+ R = radius or c.width / (2 * math.pi)
511
+ wall = wall or c.thickness
512
+ H = height or c.height
513
+ if wall >= R:
514
+ raise ValueError("wall must be smaller than radius")
515
+ ri = R - wall
516
+
517
+ mf = BRepBuilderAPI_MakeFace(_occ_wire_circle(0, 0, R, True), True)
518
+ mf.Add(_occ_wire_circle(0, 0, ri, False))
519
+ tube = BRepPrimAPI_MakePrism(mf.Face(), gp_Vec(0, 0, H)).Shape()
520
+
521
+ tools = []
522
+ for poly, circ in _hole_regions(result):
523
+ xc = circ[0] if circ else poly.centroid.x
524
+ th = xc / R
525
+ n = (math.cos(th), math.sin(th))
526
+ tg = (-math.sin(th), math.cos(th))
527
+ minx, _, maxx, _ = poly.bounds
528
+ hw = max(xc - minx, maxx - xc)
529
+ d0 = math.sqrt(max((ri - 0.5) ** 2 - hw * hw, 0.0)) # start inside the bore
530
+ length = R + 1.0 - d0
531
+
532
+ def P(x, y, d0=d0, n=n, tg=tg, xc=xc):
533
+ u, z = x - xc, y + H / 2
534
+ return gp_Pnt(n[0] * d0 + tg[0] * u, n[1] * d0 + tg[1] * u, z)
535
+
536
+ if circ:
537
+ ax = gp_Ax2(P(circ[0], circ[1]), gp_Dir(n[0], n[1], 0), gp_Dir(tg[0], tg[1], 0))
538
+ f = BRepBuilderAPI_MakeFace(_occ_wire_circle(0, 0, circ[2], True, ax=ax), True)
539
+ else:
540
+ f = BRepBuilderAPI_MakeFace(_occ_wire_poly(_ring_xy(poly.exterior), frame=P), True)
541
+ for ring in poly.interiors:
542
+ f.Add(_occ_wire_poly(_ring_xy(ring), frame=P))
543
+ tools.append(BRepPrimAPI_MakePrism(f.Face(), gp_Vec(n[0] * length, n[1] * length, 0)).Shape())
544
+ planes = _grid_planes(
545
+ len(tools), (0, math.pi), (0, H),
546
+ lambda a: gp_Pln(gp_Pnt(0, 0, 0), gp_Dir(-math.sin(a), math.cos(a), 0)),
547
+ lambda z: gp_Pln(gp_Pnt(0, 0, z), gp_Dir(0, 0, 1)))
548
+ shape = _cut(tube, tools, "wrap_cylinder", planes) if tools else tube
549
+ t1 = time.perf_counter()
550
+ _write(shape, path)
551
+ return {"holes": len(tools), "radius": R, "wall": wall, "height": H,
552
+ "seconds": round(time.perf_counter() - t0, 3), "write_seconds": round(time.perf_counter() - t1, 3)}
553
+
554
+
555
+ def cut_into_part(result: Result, design: Design, part_path, out_path, origin=(0, 0, 0),
556
+ normal=(0, 0, 1), x_dir=(1, 0, 0), depth=None) -> dict:
557
+ """Cut the pattern into an existing STEP part.
558
+
559
+ The pattern's (x, y) plane is placed at `origin` with +z = `normal` and +x = `x_dir`;
560
+ hole tools start 1 mm above the plane and go along -normal by `depth` (default: through
561
+ the whole part bounding box).
562
+ """
563
+ from OCP.Bnd import Bnd_Box
564
+ from OCP.BRepBndLib import BRepBndLib
565
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
566
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
567
+ from OCP.gp import gp_Ax2, gp_Dir, gp_Pln, gp_Pnt, gp_Vec
568
+
569
+ _need_cad()
570
+ t0 = time.perf_counter()
571
+ part = read_step(part_path)
572
+ ax = gp_Ax2(gp_Pnt(*origin), gp_Dir(*normal), gp_Dir(*x_dir)) # re-orthogonalises x_dir
573
+ O, N, X, Y = ax.Location(), ax.Direction(), ax.XDirection(), ax.YDirection()
574
+ lift = 1.0
575
+ if depth is None:
576
+ bb = Bnd_Box()
577
+ BRepBndLib.Add_s(part, bb)
578
+ lo, hi = bb.CornerMin(), bb.CornerMax()
579
+ x0, y0, z0, x1, y1, z1 = lo.X(), lo.Y(), lo.Z(), hi.X(), hi.Y(), hi.Z()
580
+ depth = max(-((px - O.X()) * N.X() + (py - O.Y()) * N.Y() + (pz - O.Z()) * N.Z())
581
+ for px in (x0, x1) for py in (y0, y1) for pz in (z0, z1)) + 1.0
582
+ depth = max(depth, 0.0)
583
+
584
+ def P(x, y):
585
+ return gp_Pnt(O.X() + X.X() * x + Y.X() * y + N.X() * lift,
586
+ O.Y() + X.Y() * x + Y.Y() * y + N.Y() * lift,
587
+ O.Z() + X.Z() * x + Y.Z() * y + N.Z() * lift)
588
+
589
+ vec = gp_Vec(-N.X() * (depth + lift), -N.Y() * (depth + lift), -N.Z() * (depth + lift))
590
+ tools = []
591
+ for poly, circ in _hole_regions(result):
592
+ if circ:
593
+ cax = gp_Ax2(P(circ[0], circ[1]), N, X)
594
+ f = BRepBuilderAPI_MakeFace(_occ_wire_circle(0, 0, circ[2], True, ax=cax), True)
595
+ else:
596
+ f = BRepBuilderAPI_MakeFace(_occ_wire_poly(_ring_xy(poly.exterior), frame=P), True)
597
+ for ring in poly.interiors:
598
+ f.Add(_occ_wire_poly(_ring_xy(ring), frame=P))
599
+ tools.append(BRepPrimAPI_MakePrism(f.Face(), vec).Shape())
600
+ regions = [p for p, _ in _hole_regions(result)] or [result.outline]
601
+ minx, miny, maxx, maxy = shapely.union_all(regions).bounds
602
+
603
+ def at(x, y):
604
+ return gp_Pnt(O.X() + X.X() * x + Y.X() * y, O.Y() + X.Y() * x + Y.Y() * y,
605
+ O.Z() + X.Z() * x + Y.Z() * y)
606
+ planes = _grid_planes(len(tools), (minx, maxx), (miny, maxy),
607
+ lambda u: gp_Pln(at(u, 0), X), lambda v: gp_Pln(at(0, v), Y))
608
+ shape = _cut(part, tools, "cut_into_part", planes) if tools else part
609
+ t1 = time.perf_counter()
610
+ _write(shape, out_path)
611
+ return {"holes": len(tools), "depth": depth, "seconds": round(time.perf_counter() - t0, 3),
612
+ "write_seconds": round(time.perf_counter() - t1, 3)}
613
+
614
+
615
+ # --------------------------------------------------------------------------- dispatch
616
+ def export(result: Result, design: Design, path) -> None:
617
+ ext = Path(path).suffix.lower()
618
+ if ext == ".svg":
619
+ to_svg(result, design, path)
620
+ elif ext == ".png":
621
+ to_png(result, design, path)
622
+ elif ext == ".dxf":
623
+ to_dxf(result, path)
624
+ elif ext in (".step", ".stp"):
625
+ to_step(result, design, path)
626
+ elif ext == ".stl":
627
+ to_stl(result, design, path)
628
+ else:
629
+ raise ValueError(f"unsupported export format: {ext!r} (use .svg .png .dxf .step .stp .stl)")