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 +0 -0
- ventforge/__main__.py +22 -0
- ventforge/export.py +629 -0
- ventforge/gui.py +1667 -0
- ventforge/mcp_server.py +206 -0
- ventforge/model.py +301 -0
- ventforge/pattern.py +746 -0
- ventforge/presets/halftone_fade.vent.json +186 -0
- ventforge/presets/hex_honeycomb.vent.json +174 -0
- ventforge/presets/spiral_dashes.vent.json +174 -0
- ventforge/presets/sunflower.vent.json +186 -0
- ventforge/presets/voronoi_stone.vent.json +174 -0
- ventforge/presets/wavy_flowlines.vent.json +174 -0
- ventforge-0.1.0.dist-info/METADATA +84 -0
- ventforge-0.1.0.dist-info/RECORD +19 -0
- ventforge-0.1.0.dist-info/WHEEL +5 -0
- ventforge-0.1.0.dist-info/entry_points.txt +3 -0
- ventforge-0.1.0.dist-info/licenses/LICENSE +674 -0
- ventforge-0.1.0.dist-info/top_level.txt +1 -0
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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)")
|