forgekernel 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.
- forgekernel/__init__.py +7 -0
- forgekernel/brep.py +624 -0
- forgekernel/bsolid.py +295 -0
- forgekernel/csg.py +158 -0
- forgekernel/curve.py +166 -0
- forgekernel/exact.py +121 -0
- forgekernel/interval.py +141 -0
- forgekernel/io.py +56 -0
- forgekernel/kernel.py +93 -0
- forgekernel/loft.py +185 -0
- forgekernel/nurbs.py +481 -0
- forgekernel/profile2d.py +228 -0
- forgekernel/quadric.py +979 -0
- forgekernel/ssi.py +524 -0
- forgekernel/stepio.py +460 -0
- forgekernel/surd.py +104 -0
- forgekernel/surfacing.py +341 -0
- forgekernel/tess.py +74 -0
- forgekernel/trim.py +193 -0
- forgekernel-0.1.0.dist-info/METADATA +82 -0
- forgekernel-0.1.0.dist-info/RECORD +24 -0
- forgekernel-0.1.0.dist-info/WHEEL +5 -0
- forgekernel-0.1.0.dist-info/licenses/LICENSE +201 -0
- forgekernel-0.1.0.dist-info/top_level.txt +1 -0
forgekernel/__init__.py
ADDED
forgekernel/brep.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Polyhedral B-rep — convex-faceted solids with native lineage (K1).
|
|
2
|
+
|
|
3
|
+
A Solid is a closed set of convex polygons, each carrying the id of the
|
|
4
|
+
ORIGINAL face it descends from — lineage is data in the model, not a
|
|
5
|
+
service bolted on afterward (the ADR-0018 identity requirement). Mass
|
|
6
|
+
properties are exact rational integrals (signed tetrahedra / divergence
|
|
7
|
+
theorem); validation checks watertightness by exact edge pairing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
|
|
14
|
+
from forgekernel.exact import (F, Plane, Vec, add, centroid, cross, dot,
|
|
15
|
+
is_zero, neg, smul, sub, vec)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Polygon:
|
|
19
|
+
"""Convex planar polygon, CCW around its outward plane normal."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("verts", "plane", "source")
|
|
22
|
+
|
|
23
|
+
def __init__(self, verts: list[Vec], source: str,
|
|
24
|
+
plane: Plane | None = None) -> None:
|
|
25
|
+
if len(verts) < 3:
|
|
26
|
+
raise ValueError("polygon needs >= 3 vertices")
|
|
27
|
+
self.verts = verts
|
|
28
|
+
self.plane = plane or Plane.from_points(verts[0], verts[1], verts[2])
|
|
29
|
+
self.source = source
|
|
30
|
+
|
|
31
|
+
def flipped(self) -> "Polygon":
|
|
32
|
+
return Polygon(list(reversed(self.verts)), self.source,
|
|
33
|
+
self.plane.flipped())
|
|
34
|
+
|
|
35
|
+
def area2(self) -> Fraction:
|
|
36
|
+
"""Twice the area times |n| — zero iff degenerate (exact test)."""
|
|
37
|
+
acc = (Fraction(0), Fraction(0), Fraction(0))
|
|
38
|
+
v0 = self.verts[0]
|
|
39
|
+
for a, b in zip(self.verts[1:], self.verts[2:]):
|
|
40
|
+
acc = add(acc, cross(sub(a, v0), sub(b, v0)))
|
|
41
|
+
return dot(acc, acc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Solid:
|
|
45
|
+
"""A (intended-closed) collection of convex polygons."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("polys",)
|
|
48
|
+
|
|
49
|
+
def __init__(self, polys: list[Polygon]) -> None:
|
|
50
|
+
self.polys = [p for p in polys if p.area2() != 0]
|
|
51
|
+
|
|
52
|
+
# -- constructors ---------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def box(cls, dx, dy, dz, source_prefix: str = "box") -> "Solid":
|
|
56
|
+
x, y, z = F(dx), F(dy), F(dz)
|
|
57
|
+
if x <= 0 or y <= 0 or z <= 0:
|
|
58
|
+
raise ValueError("box wants positive dimensions")
|
|
59
|
+
o = Fraction(0)
|
|
60
|
+
v = [vec(o, o, o), vec(x, o, o), vec(x, y, o), vec(o, y, o),
|
|
61
|
+
vec(o, o, z), vec(x, o, z), vec(x, y, z), vec(o, y, z)]
|
|
62
|
+
faces = [([0, 3, 2, 1], "bottom"), ([4, 5, 6, 7], "top"),
|
|
63
|
+
([0, 1, 5, 4], "front"), ([2, 3, 7, 6], "back"),
|
|
64
|
+
([1, 2, 6, 5], "right"), ([3, 0, 4, 7], "left")]
|
|
65
|
+
return cls([Polygon([v[i] for i in idx], f"{source_prefix}.{name}")
|
|
66
|
+
for idx, name in faces])
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def prism(cls, loop_xy: list[tuple], height,
|
|
70
|
+
source_prefix: str = "prism") -> "Solid":
|
|
71
|
+
"""Extrude a simple CCW polygon (2D loop, no repeated last point)
|
|
72
|
+
along +z. Caps are ear-clipped into triangles — exact orientation
|
|
73
|
+
and containment tests, so non-convex profiles are fine."""
|
|
74
|
+
h = F(height)
|
|
75
|
+
if h <= 0:
|
|
76
|
+
raise ValueError("prism wants positive height")
|
|
77
|
+
loop = [(F(px), F(py)) for px, py in loop_xy]
|
|
78
|
+
if _loop_area2(loop) < 0:
|
|
79
|
+
loop = list(reversed(loop))
|
|
80
|
+
tris = _ear_clip(loop)
|
|
81
|
+
polys: list[Polygon] = []
|
|
82
|
+
for a, b, c in tris:
|
|
83
|
+
polys.append(Polygon([vec(*a, 0), vec(*c, 0), vec(*b, 0)],
|
|
84
|
+
f"{source_prefix}.bottom"))
|
|
85
|
+
polys.append(Polygon([vec(*a, h), vec(*b, h), vec(*c, h)],
|
|
86
|
+
f"{source_prefix}.top"))
|
|
87
|
+
n = len(loop)
|
|
88
|
+
for i in range(n):
|
|
89
|
+
(x1, y1), (x2, y2) = loop[i], loop[(i + 1) % n]
|
|
90
|
+
polys.append(Polygon(
|
|
91
|
+
[vec(x1, y1, 0), vec(x2, y2, 0), vec(x2, y2, h),
|
|
92
|
+
vec(x1, y1, h)], f"{source_prefix}.side{i}"))
|
|
93
|
+
return cls(polys)
|
|
94
|
+
|
|
95
|
+
# -- rigid/affine ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def mapped(self, fn) -> "Solid":
|
|
98
|
+
out = []
|
|
99
|
+
for p in self.polys:
|
|
100
|
+
out.append(Polygon([fn(v) for v in p.verts], p.source))
|
|
101
|
+
return Solid(out)
|
|
102
|
+
|
|
103
|
+
def translated(self, t: Vec) -> "Solid":
|
|
104
|
+
return self.mapped(lambda v: add(v, t))
|
|
105
|
+
|
|
106
|
+
def scaled(self, fx, fy, fz) -> "Solid":
|
|
107
|
+
sx, sy, sz = F(fx), F(fy), F(fz)
|
|
108
|
+
if sx == 0 or sy == 0 or sz == 0:
|
|
109
|
+
raise ValueError("zero scale factor")
|
|
110
|
+
s = self.mapped(lambda v: (v[0] * sx, v[1] * sy, v[2] * sz))
|
|
111
|
+
if sx * sy * sz < 0: # orientation flip
|
|
112
|
+
s = Solid([p.flipped() for p in s.polys])
|
|
113
|
+
return s
|
|
114
|
+
|
|
115
|
+
def mirrored(self, axis: str) -> "Solid":
|
|
116
|
+
i = "xyz".index(axis)
|
|
117
|
+
|
|
118
|
+
def fn(v: Vec) -> Vec:
|
|
119
|
+
w = list(v)
|
|
120
|
+
w[i] = -w[i]
|
|
121
|
+
return (w[0], w[1], w[2])
|
|
122
|
+
|
|
123
|
+
return Solid([p.flipped() for p in self.mapped(fn).polys])
|
|
124
|
+
|
|
125
|
+
def rotated_quarter(self, axis: str, quarters: int) -> "Solid":
|
|
126
|
+
"""Exact rotation by multiples of 90° about a principal axis."""
|
|
127
|
+
q = quarters % 4
|
|
128
|
+
ax = "xyz".index(axis)
|
|
129
|
+
|
|
130
|
+
def rot(v: Vec) -> Vec:
|
|
131
|
+
a, b = (ax + 1) % 3, (ax + 2) % 3
|
|
132
|
+
w = list(v)
|
|
133
|
+
for _ in range(q):
|
|
134
|
+
w[a], w[b] = -w[b], w[a]
|
|
135
|
+
return (w[0], w[1], w[2])
|
|
136
|
+
|
|
137
|
+
return self.mapped(rot)
|
|
138
|
+
|
|
139
|
+
# -- exact metrics --------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def volume6(self) -> Fraction:
|
|
142
|
+
"""Six times the signed volume — exact (sum of origin tetrahedra)."""
|
|
143
|
+
acc = Fraction(0)
|
|
144
|
+
for p in self.polys:
|
|
145
|
+
v0 = p.verts[0]
|
|
146
|
+
for a, b in zip(p.verts[1:], p.verts[2:]):
|
|
147
|
+
acc += dot(v0, cross(a, b))
|
|
148
|
+
return acc
|
|
149
|
+
|
|
150
|
+
def volume(self) -> Fraction:
|
|
151
|
+
return self.volume6() / 6
|
|
152
|
+
|
|
153
|
+
def centroid(self) -> Vec:
|
|
154
|
+
"""Exact volume centroid (tetrahedron decomposition)."""
|
|
155
|
+
v6 = self.volume6()
|
|
156
|
+
if v6 == 0:
|
|
157
|
+
raise ValueError("centroid of zero-volume solid")
|
|
158
|
+
acc = (Fraction(0), Fraction(0), Fraction(0))
|
|
159
|
+
for p in self.polys:
|
|
160
|
+
v0 = p.verts[0]
|
|
161
|
+
for a, b in zip(p.verts[1:], p.verts[2:]):
|
|
162
|
+
w = dot(v0, cross(a, b))
|
|
163
|
+
acc = add(acc, smul(w, add(add(v0, a), b)))
|
|
164
|
+
return smul(Fraction(1, 4) / v6, acc)
|
|
165
|
+
|
|
166
|
+
def bbox(self) -> tuple[Vec, Vec]:
|
|
167
|
+
xs = [v for p in self.polys for v in p.verts]
|
|
168
|
+
lo = (min(v[0] for v in xs), min(v[1] for v in xs),
|
|
169
|
+
min(v[2] for v in xs))
|
|
170
|
+
hi = (max(v[0] for v in xs), max(v[1] for v in xs),
|
|
171
|
+
max(v[2] for v in xs))
|
|
172
|
+
return lo, hi
|
|
173
|
+
|
|
174
|
+
# -- topology projections -------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def logical_faces(self) -> dict[tuple, list[Polygon]]:
|
|
177
|
+
"""Fragments grouped by (plane canonical, lineage source) — the
|
|
178
|
+
face an engineer means, reassembled from BSP shards."""
|
|
179
|
+
out: dict[tuple, list[Polygon]] = {}
|
|
180
|
+
for p in self.polys:
|
|
181
|
+
out.setdefault((p.plane.canonical(), p.source), []).append(p)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
def watertight_violations(self) -> list[str]:
|
|
185
|
+
"""Exact closure test, T-junction tolerant: BSP output is
|
|
186
|
+
geometrically closed but combinatorially fragmented, so edges are
|
|
187
|
+
grouped by their carrier LINE (canonical direction + Plücker
|
|
188
|
+
moment) and closure requires the SIGNED interval coverage on every
|
|
189
|
+
line to cancel exactly. Zero everywhere == closed surface."""
|
|
190
|
+
from collections import defaultdict
|
|
191
|
+
from math import gcd as _gcd
|
|
192
|
+
|
|
193
|
+
def canon_dir(d: Vec) -> Vec | None:
|
|
194
|
+
den = 1
|
|
195
|
+
for v in d:
|
|
196
|
+
den = den * v.denominator // _gcd(den, v.denominator)
|
|
197
|
+
ints = [int(v * den) for v in d]
|
|
198
|
+
g = 0
|
|
199
|
+
for v in ints:
|
|
200
|
+
g = _gcd(g, abs(v))
|
|
201
|
+
if g == 0:
|
|
202
|
+
return None
|
|
203
|
+
ints = [v // g for v in ints]
|
|
204
|
+
for v in ints:
|
|
205
|
+
if v != 0:
|
|
206
|
+
if v < 0:
|
|
207
|
+
ints = [-w for w in ints]
|
|
208
|
+
break
|
|
209
|
+
return (F(ints[0]), F(ints[1]), F(ints[2]))
|
|
210
|
+
|
|
211
|
+
lines: dict = defaultdict(list)
|
|
212
|
+
for p in self.polys:
|
|
213
|
+
n = len(p.verts)
|
|
214
|
+
for i in range(n):
|
|
215
|
+
a, b = p.verts[i], p.verts[(i + 1) % n]
|
|
216
|
+
d = sub(b, a)
|
|
217
|
+
cd = canon_dir(d)
|
|
218
|
+
if cd is None:
|
|
219
|
+
continue
|
|
220
|
+
key = (cd, cross(a, cd)) # moment: line-invariant
|
|
221
|
+
ta, tb = dot(a, cd), dot(b, cd)
|
|
222
|
+
sign = 1 if ta < tb else -1
|
|
223
|
+
lines[key].append((min(ta, tb), max(ta, tb), sign))
|
|
224
|
+
bad: list[str] = []
|
|
225
|
+
for key, segs in lines.items():
|
|
226
|
+
cuts = sorted({t for lo, hi, _ in segs for t in (lo, hi)})
|
|
227
|
+
for lo, hi in zip(cuts, cuts[1:]):
|
|
228
|
+
cov = sum(s for slo, shi, s in segs if slo <= lo and hi <= shi)
|
|
229
|
+
if cov != 0:
|
|
230
|
+
bad.append(f"open-boundary:line-dir={tuple(float(v) for v in key[0])}"
|
|
231
|
+
f":t=[{float(lo):g},{float(hi):g}]:coverage={cov}")
|
|
232
|
+
if len(bad) >= 8:
|
|
233
|
+
return bad + ["..."]
|
|
234
|
+
break
|
|
235
|
+
return bad
|
|
236
|
+
|
|
237
|
+
def tessellate(self) -> dict[str, list]:
|
|
238
|
+
verts: list[list[float]] = []
|
|
239
|
+
tris: list[list[int]] = []
|
|
240
|
+
index: dict[Vec, int] = {}
|
|
241
|
+
for p in self.polys:
|
|
242
|
+
ids = []
|
|
243
|
+
for v in p.verts:
|
|
244
|
+
if v not in index:
|
|
245
|
+
index[v] = len(verts)
|
|
246
|
+
verts.append([float(v[0]), float(v[1]), float(v[2])])
|
|
247
|
+
ids.append(index[v])
|
|
248
|
+
for a, b in zip(ids[1:], ids[2:]):
|
|
249
|
+
tris.append([ids[0], a, b])
|
|
250
|
+
return {"vertices": verts, "triangles": tris}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _pt(v: Vec) -> str:
|
|
254
|
+
return f"({float(v[0]):g},{float(v[1]):g},{float(v[2]):g})"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _loop_area2(loop: list[tuple]) -> Fraction:
|
|
258
|
+
acc = Fraction(0)
|
|
259
|
+
n = len(loop)
|
|
260
|
+
for i in range(n):
|
|
261
|
+
(x1, y1), (x2, y2) = loop[i], loop[(i + 1) % n]
|
|
262
|
+
acc += x1 * y2 - x2 * y1
|
|
263
|
+
return acc
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _ear_clip(loop: list[tuple]) -> list[tuple]:
|
|
267
|
+
"""Exact ear clipping of a simple CCW polygon -> triangles."""
|
|
268
|
+
|
|
269
|
+
def orient(a, b, c) -> Fraction:
|
|
270
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
271
|
+
|
|
272
|
+
def inside(p, a, b, c) -> bool:
|
|
273
|
+
return (orient(a, b, p) > 0 and orient(b, c, p) > 0
|
|
274
|
+
and orient(c, a, p) > 0)
|
|
275
|
+
|
|
276
|
+
pts = list(loop)
|
|
277
|
+
tris: list[tuple] = []
|
|
278
|
+
guard = 0
|
|
279
|
+
while len(pts) > 3:
|
|
280
|
+
guard += 1
|
|
281
|
+
if guard > 10000:
|
|
282
|
+
raise ValueError("ear clipping did not converge (self-intersecting loop?)")
|
|
283
|
+
n = len(pts)
|
|
284
|
+
for i in range(n):
|
|
285
|
+
a, b, c = pts[(i - 1) % n], pts[i], pts[(i + 1) % n]
|
|
286
|
+
if orient(a, b, c) <= 0:
|
|
287
|
+
continue # reflex or degenerate
|
|
288
|
+
if any(inside(p, a, b, c) for j, p in enumerate(pts)
|
|
289
|
+
if p not in (a, b, c)):
|
|
290
|
+
continue
|
|
291
|
+
tris.append((a, b, c))
|
|
292
|
+
pts.pop(i)
|
|
293
|
+
break
|
|
294
|
+
else:
|
|
295
|
+
raise ValueError("no ear found (degenerate loop)")
|
|
296
|
+
tris.append((pts[0], pts[1], pts[2]))
|
|
297
|
+
return tris
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _canon_dir(d: Vec):
|
|
301
|
+
from math import gcd as _g
|
|
302
|
+
|
|
303
|
+
den = 1
|
|
304
|
+
for v in d:
|
|
305
|
+
den = den * v.denominator // _g(den, v.denominator)
|
|
306
|
+
ints = [int(v * den) for v in d]
|
|
307
|
+
g = 0
|
|
308
|
+
for v in ints:
|
|
309
|
+
g = _g(g, abs(v))
|
|
310
|
+
if g == 0:
|
|
311
|
+
return None
|
|
312
|
+
ints = [v // g for v in ints]
|
|
313
|
+
for v in ints:
|
|
314
|
+
if v != 0:
|
|
315
|
+
if v < 0:
|
|
316
|
+
ints = [-w for w in ints]
|
|
317
|
+
break
|
|
318
|
+
return (F(ints[0]), F(ints[1]), F(ints[2]))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def logical_edges(solid: Solid) -> list[dict]:
|
|
322
|
+
"""Solid edges as carrier lines with their two adjacent face planes —
|
|
323
|
+
derived by exact grouping of polygon boundary segments. An edge is a
|
|
324
|
+
line where exactly two distinct face planes meet."""
|
|
325
|
+
from collections import defaultdict
|
|
326
|
+
|
|
327
|
+
lines: dict = defaultdict(lambda: {"planes": {}, "tmin": None,
|
|
328
|
+
"tmax": None, "point": None,
|
|
329
|
+
"dir": None})
|
|
330
|
+
for p in solid.polys:
|
|
331
|
+
n = len(p.verts)
|
|
332
|
+
for i in range(n):
|
|
333
|
+
a, b = p.verts[i], p.verts[(i + 1) % n]
|
|
334
|
+
cd = _canon_dir(sub(b, a))
|
|
335
|
+
if cd is None:
|
|
336
|
+
continue
|
|
337
|
+
key = (cd, cross(a, cd))
|
|
338
|
+
e = lines[key]
|
|
339
|
+
e["planes"][p.plane.canonical()] = p.plane
|
|
340
|
+
e["dir"] = cd
|
|
341
|
+
ta, tb = dot(a, cd), dot(b, cd)
|
|
342
|
+
lo, hi = min(ta, tb), max(ta, tb)
|
|
343
|
+
e["tmin"] = lo if e["tmin"] is None else min(e["tmin"], lo)
|
|
344
|
+
e["tmax"] = hi if e["tmax"] is None else max(e["tmax"], hi)
|
|
345
|
+
if e["point"] is None:
|
|
346
|
+
e["point"] = a
|
|
347
|
+
out = []
|
|
348
|
+
for e in lines.values():
|
|
349
|
+
if len(e["planes"]) == 2:
|
|
350
|
+
pa, pb = list(e["planes"].values())
|
|
351
|
+
out.append({"point": e["point"], "dir": e["dir"],
|
|
352
|
+
"tmin": e["tmin"], "tmax": e["tmax"],
|
|
353
|
+
"plane_a": pa, "plane_b": pb})
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _unit_normal(plane: Plane) -> Vec | None:
|
|
358
|
+
"""Exact unit normal when it exists in the rationals (axis-aligned
|
|
359
|
+
faces, and any face whose |n| is a perfect rational square)."""
|
|
360
|
+
c = plane.canonical()[:3]
|
|
361
|
+
nn = c[0] * c[0] + c[1] * c[1] + c[2] * c[2]
|
|
362
|
+
import math as _m
|
|
363
|
+
|
|
364
|
+
root = _m.isqrt(int(nn))
|
|
365
|
+
if root * root != int(nn):
|
|
366
|
+
return None
|
|
367
|
+
return (F(c[0]) / root, F(c[1]) / root, F(c[2]) / root)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def chamfer_planar(solid: Solid, distance, edges: list[dict] | None = None) -> Solid:
|
|
371
|
+
"""Exact chamfer on convex edges whose face normals admit rational
|
|
372
|
+
unit vectors (axis-aligned and Pythagorean orientations). Each edge
|
|
373
|
+
is cut by the plane through the two lines offset ``distance`` along
|
|
374
|
+
each adjacent face — a parallelepiped tool per edge, subtracted with
|
|
375
|
+
the exact boolean engine. Non-rational orientations refuse (K2
|
|
376
|
+
brings bounded-error constructions)."""
|
|
377
|
+
from forgekernel import csg
|
|
378
|
+
|
|
379
|
+
d = F(distance)
|
|
380
|
+
if d <= 0:
|
|
381
|
+
raise ValueError("chamfer wants positive distance")
|
|
382
|
+
todo = edges if edges is not None else logical_edges(solid)
|
|
383
|
+
lo, hi = solid.bbox()
|
|
384
|
+
extent = (hi[0] - lo[0]) + (hi[1] - lo[1]) + (hi[2] - lo[2]) + 1
|
|
385
|
+
out = solid
|
|
386
|
+
for e in todo:
|
|
387
|
+
pa, pb = e["plane_a"], e["plane_b"]
|
|
388
|
+
na, nb = _unit_normal(pa), _unit_normal(pb)
|
|
389
|
+
if na is None or nb is None:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
"chamfer: face normal is not rational-unit (arrives at K2)")
|
|
392
|
+
u = e["dir"]
|
|
393
|
+
p0 = e["point"]
|
|
394
|
+
# direction from the edge into each face: perpendicular to both the
|
|
395
|
+
# edge and the face normal, signed to point into the OTHER face's
|
|
396
|
+
# negative half-space (exact convexity-aware sign choice)
|
|
397
|
+
ca = cross(u, na)
|
|
398
|
+
if pb.side(add(p0, ca)) > 0:
|
|
399
|
+
ca = neg(ca)
|
|
400
|
+
cb = cross(u, nb)
|
|
401
|
+
if pa.side(add(p0, cb)) > 0:
|
|
402
|
+
cb = neg(cb)
|
|
403
|
+
if pb.side(add(p0, ca)) >= 0 or pa.side(add(p0, cb)) >= 0:
|
|
404
|
+
continue # reflex edge: skip in K1.1
|
|
405
|
+
qa = add(p0, smul(d, ca))
|
|
406
|
+
qb = add(p0, smul(d, cb))
|
|
407
|
+
span = sub(qb, qa)
|
|
408
|
+
if is_zero(span):
|
|
409
|
+
continue
|
|
410
|
+
# parallelepiped tool: rectangle spanning the cut plane, extruded
|
|
411
|
+
# toward the edge (the material side)
|
|
412
|
+
mid = smul(Fraction(1, 2), add(qa, qb))
|
|
413
|
+
toward = sub(p0, mid) # cut plane -> edge direction
|
|
414
|
+
e1 = smul(extent / _norm1(u), u)
|
|
415
|
+
e2 = smul(extent / _norm1(span), span)
|
|
416
|
+
e3 = smul(Fraction(2), toward)
|
|
417
|
+
base = sub(sub(mid, smul(Fraction(1, 2), e1)), smul(Fraction(1, 2), e2))
|
|
418
|
+
tool = _parallelepiped(base, e1, e2, e3, "chamfer")
|
|
419
|
+
out = csg.cut(out, tool)
|
|
420
|
+
return out
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _norm1(v: Vec) -> Fraction:
|
|
424
|
+
return abs(v[0]) + abs(v[1]) + abs(v[2])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _parallelepiped(base: Vec, e1: Vec, e2: Vec, e3: Vec,
|
|
428
|
+
source: str) -> Solid:
|
|
429
|
+
v = [base, add(base, e1), add(add(base, e1), e2), add(base, e2)]
|
|
430
|
+
v += [add(p, e3) for p in v]
|
|
431
|
+
faces = [([0, 3, 2, 1], "b"), ([4, 5, 6, 7], "t"), ([0, 1, 5, 4], "f"),
|
|
432
|
+
([2, 3, 7, 6], "k"), ([1, 2, 6, 5], "r"), ([3, 0, 4, 7], "l")]
|
|
433
|
+
s = Solid([Polygon([v[i] for i in idx], f"{source}.{n}")
|
|
434
|
+
for idx, n in faces])
|
|
435
|
+
return s if s.volume() > 0 else Solid([p.flipped() for p in s.polys])
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _unit_dir(cd: Vec) -> Vec | None:
|
|
439
|
+
import math as _m
|
|
440
|
+
|
|
441
|
+
nn = int(cd[0] * cd[0] + cd[1] * cd[1] + cd[2] * cd[2])
|
|
442
|
+
root = _m.isqrt(nn)
|
|
443
|
+
if root * root != nn:
|
|
444
|
+
return None
|
|
445
|
+
return (cd[0] / root, cd[1] / root, cd[2] / root)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _solve3(rows: list[Vec], rhs: list[Fraction]) -> Vec | None:
|
|
449
|
+
"""Exact 3x3 linear solve (Cramer). None when singular."""
|
|
450
|
+
a, b, c = rows
|
|
451
|
+
det = dot(a, cross(b, c))
|
|
452
|
+
if det == 0:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
def rep(i: int, col: Vec) -> Fraction:
|
|
456
|
+
m = [list(a), list(b), list(c)]
|
|
457
|
+
for r, v in zip(m, rhs):
|
|
458
|
+
r[i] = v
|
|
459
|
+
return dot((m[0][0], m[0][1], m[0][2]),
|
|
460
|
+
cross((m[1][0], m[1][1], m[1][2]),
|
|
461
|
+
(m[2][0], m[2][1], m[2][2]))) / det
|
|
462
|
+
|
|
463
|
+
# column replacement via transpose trick: solve A x = rhs
|
|
464
|
+
ax = dot((rhs[0], a[1], a[2]), cross((rhs[1], b[1], b[2]), (rhs[2], c[1], c[2]))) / det
|
|
465
|
+
ay = dot((a[0], rhs[0], a[2]), cross((b[0], rhs[1], b[2]), (c[0], rhs[2], c[2]))) / det
|
|
466
|
+
az = dot((a[0], a[1], rhs[0]), cross((b[0], b[1], rhs[1]), (c[0], c[1], rhs[2]))) / det
|
|
467
|
+
return (ax, ay, az)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _tetra(p0: Vec, p1: Vec, p2: Vec, p3: Vec, source: str) -> Solid:
|
|
471
|
+
s = Solid([Polygon([p0, p1, p2], f"{source}.a"),
|
|
472
|
+
Polygon([p0, p2, p3], f"{source}.b"),
|
|
473
|
+
Polygon([p0, p3, p1], f"{source}.c"),
|
|
474
|
+
Polygon([p1, p3, p2], f"{source}.d")])
|
|
475
|
+
return s if s.volume() > 0 else Solid([q.flipped() for q in s.polys])
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def chamfer_corners(solid: Solid, distance,
|
|
479
|
+
edges: list[dict]) -> Solid:
|
|
480
|
+
"""Vertex truncation matching industrial chamfer semantics (the
|
|
481
|
+
OCCT/SolidWorks corner facet). Geometry, derived exactly from the
|
|
482
|
+
first real ref-vs-OCCT disagreement (5568 pure plane-cuts vs 16688/3
|
|
483
|
+
oracle; delta d^3/12 per corner, hand-verified both ways):
|
|
484
|
+
|
|
485
|
+
at a corner where three chamfered edges meet, the remaining apex
|
|
486
|
+
pyramid is bounded by the three chamfer planes; the corner facet
|
|
487
|
+
passes through the three points where PAIRWISE chamfer-plane
|
|
488
|
+
intersection lines pierce the original faces. The removed piece is
|
|
489
|
+
the exact rational tetrahedron (facet triangle + chamfer triple
|
|
490
|
+
point), cut with the exact boolean engine."""
|
|
491
|
+
from collections import defaultdict
|
|
492
|
+
|
|
493
|
+
from forgekernel import csg
|
|
494
|
+
|
|
495
|
+
d = F(distance)
|
|
496
|
+
at_vertex: dict = defaultdict(list)
|
|
497
|
+
for e in edges:
|
|
498
|
+
cd = e["dir"]
|
|
499
|
+
nn = dot(cd, cd)
|
|
500
|
+
p0 = e["point"]
|
|
501
|
+
t0 = dot(p0, cd)
|
|
502
|
+
for t_end, sign in ((e["tmin"], 1), (e["tmax"], -1)):
|
|
503
|
+
v = add(p0, smul((t_end - t0) / nn, cd))
|
|
504
|
+
at_vertex[v].append((smul(F(sign), cd),
|
|
505
|
+
e["plane_a"], e["plane_b"]))
|
|
506
|
+
out = solid
|
|
507
|
+
for v, incident in at_vertex.items():
|
|
508
|
+
if len(incident) != 3:
|
|
509
|
+
continue
|
|
510
|
+
units = [_unit_dir(cd) for cd, _, _ in incident]
|
|
511
|
+
if any(u is None for u in units):
|
|
512
|
+
continue # K2: non-rational dirs
|
|
513
|
+
# chamfer plane of edge k: normal = u_i + u_j, through v + d*u_i
|
|
514
|
+
m = [add(units[(k + 1) % 3], units[(k + 2) % 3]) for k in range(3)]
|
|
515
|
+
rhs = [dot(m[k], add(v, smul(d, units[(k + 1) % 3])))
|
|
516
|
+
for k in range(3)]
|
|
517
|
+
apex = _solve3(m, rhs)
|
|
518
|
+
if apex is None:
|
|
519
|
+
continue
|
|
520
|
+
# face_k = the original face shared by edges i and j
|
|
521
|
+
pts = []
|
|
522
|
+
ok = True
|
|
523
|
+
for k in range(3):
|
|
524
|
+
i, j = (k + 1) % 3, (k + 2) % 3
|
|
525
|
+
keys_i = {incident[i][1].coplanar_key(),
|
|
526
|
+
incident[i][2].coplanar_key()}
|
|
527
|
+
shared = None
|
|
528
|
+
for pl in (incident[j][1], incident[j][2]):
|
|
529
|
+
if pl.coplanar_key() in keys_i:
|
|
530
|
+
shared = pl
|
|
531
|
+
break
|
|
532
|
+
if shared is None:
|
|
533
|
+
ok = False
|
|
534
|
+
break
|
|
535
|
+
p = _solve3([m[i], m[j], shared.n], [rhs[i], rhs[j], shared.d])
|
|
536
|
+
if p is None:
|
|
537
|
+
ok = False
|
|
538
|
+
break
|
|
539
|
+
pts.append(p)
|
|
540
|
+
if not ok:
|
|
541
|
+
continue
|
|
542
|
+
tool = _tetra(pts[0], pts[1], pts[2], apex, "corner")
|
|
543
|
+
if tool.volume() == 0:
|
|
544
|
+
continue
|
|
545
|
+
out = csg.cut(out, tool)
|
|
546
|
+
return out
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def prismatoid(bottom: list[tuple], z0, top: list[tuple], z1,
|
|
550
|
+
source: str = "prismatoid") -> "Solid":
|
|
551
|
+
"""Exact solid between two same-count CCW xy loops at heights z0<z1:
|
|
552
|
+
bottom cap, top cap, and side quads (each split into 2 triangles so a
|
|
553
|
+
twisted/tapered side stays exactly planar-triangulated and closed)."""
|
|
554
|
+
z0, z1 = F(z0), F(z1)
|
|
555
|
+
b = [(F(x), F(y)) for x, y in bottom]
|
|
556
|
+
tp = [(F(x), F(y)) for x, y in top]
|
|
557
|
+
if len(b) != len(tp) or len(b) < 3:
|
|
558
|
+
raise ValueError("prismatoid needs two equal-length loops (>=3)")
|
|
559
|
+
if _loop_area2(b) < 0:
|
|
560
|
+
b, tp = list(reversed(b)), list(reversed(tp))
|
|
561
|
+
polys: list[Polygon] = []
|
|
562
|
+
for a, bb, c in _ear_clip(b):
|
|
563
|
+
polys.append(Polygon([vec(*a, z0), vec(*c, z0), vec(*bb, z0)],
|
|
564
|
+
f"{source}.bottom"))
|
|
565
|
+
for a, bb, c in _ear_clip(tp):
|
|
566
|
+
polys.append(Polygon([vec(*a, z1), vec(*bb, z1), vec(*c, z1)],
|
|
567
|
+
f"{source}.top"))
|
|
568
|
+
n = len(b)
|
|
569
|
+
for i in range(n):
|
|
570
|
+
j = (i + 1) % n
|
|
571
|
+
b0, b1 = b[i], b[j]
|
|
572
|
+
t0, t1 = tp[i], tp[j]
|
|
573
|
+
# side quad b0-b1-t1-t0 -> two triangles (consistent winding)
|
|
574
|
+
polys.append(Polygon([vec(*b0, z0), vec(*b1, z0), vec(*t1, z1)],
|
|
575
|
+
f"{source}.side{i}"))
|
|
576
|
+
polys.append(Polygon([vec(*b0, z0), vec(*t1, z1), vec(*t0, z1)],
|
|
577
|
+
f"{source}.side{i}"))
|
|
578
|
+
return Solid(polys)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def draft_box(solid: Solid, t: Fraction, neutral_z: Fraction) -> Solid:
|
|
582
|
+
"""Draft ALL four vertical faces of an axis-aligned rectangular prism
|
|
583
|
+
into a frustum, exact. Inset at height z is (z-neutral_z)*t on each
|
|
584
|
+
side. General (non-rectangular) prism draft arrives at K2.3."""
|
|
585
|
+
lo, hi = solid.bbox()
|
|
586
|
+
x0, y0, z0 = lo
|
|
587
|
+
x1, y1, z1 = hi
|
|
588
|
+
# verify rectangular footprint: all vertices at the 4 xy corners
|
|
589
|
+
corners = {(x0, y0), (x1, y0), (x1, y1), (x0, y1)}
|
|
590
|
+
for p in solid.polys:
|
|
591
|
+
for vx, vy, _vz in p.verts:
|
|
592
|
+
if (vx, vy) not in corners:
|
|
593
|
+
raise ValueError("draft of a non-rectangular prism arrives at K2.3")
|
|
594
|
+
|
|
595
|
+
def rect(z):
|
|
596
|
+
d = (z - neutral_z) * t
|
|
597
|
+
return [(x0 + d, y0 + d), (x1 - d, y0 + d),
|
|
598
|
+
(x1 - d, y1 - d), (x0 + d, y1 - d)]
|
|
599
|
+
|
|
600
|
+
return prismatoid(rect(z0), z0, rect(z1), z1, "draft")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def shell_box(solid: Solid, thickness) -> Solid:
|
|
604
|
+
"""Hollow an axis-aligned rectangular prism to wall thickness t (all
|
|
605
|
+
faces closed — a shell with no openings). Result = outer minus the
|
|
606
|
+
inner box inset by t on every face. Exact. Non-box or t too large
|
|
607
|
+
refuse (K2.3 / invalid)."""
|
|
608
|
+
from forgekernel import csg
|
|
609
|
+
|
|
610
|
+
t = F(thickness)
|
|
611
|
+
lo, hi = solid.bbox()
|
|
612
|
+
x0, y0, z0 = lo
|
|
613
|
+
x1, y1, z1 = hi
|
|
614
|
+
corners = {(x0, y0), (x1, y0), (x1, y1), (x0, y1)}
|
|
615
|
+
for p in solid.polys:
|
|
616
|
+
for vx, vy, vz in p.verts:
|
|
617
|
+
if (vx, vy) not in corners or vz not in (z0, z1):
|
|
618
|
+
raise ValueError("shell of a non-box solid arrives at K2.3")
|
|
619
|
+
if 2 * t >= min(x1 - x0, y1 - y0, z1 - z0):
|
|
620
|
+
raise ValueError("shell thickness exceeds half the smallest dimension")
|
|
621
|
+
inner = Solid.box(x1 - x0 - 2 * t, y1 - y0 - 2 * t, z1 - z0 - 2 * t,
|
|
622
|
+
"shell.void").translated(
|
|
623
|
+
(x0 + t, y0 + t, z0 + t))
|
|
624
|
+
return csg.cut(solid, inner)
|