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.
@@ -0,0 +1,7 @@
1
+ """forgekernel — the gitcad-native B-rep kernel, reference implementation.
2
+
3
+ Exact rational arithmetic for every topological decision; approximation
4
+ only with tracked error bounds. See README for the three-oracle chain.
5
+ """
6
+
7
+ __version__ = "0.1.0"
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)