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/quadric.py ADDED
@@ -0,0 +1,979 @@
1
+ """K2.0 — z-axis cylinders and drilled solids, exact in ℚ[π].
2
+
3
+ The exactness charter survives curved geometry by extending the number
4
+ field: volumes of prisms with cylindrical bores live in ℚ + ℚ·π, so a
5
+ drilled plate's volume is EXACTLY ``9600 - 100π`` — an object with
6
+ equality, not a float. Floats appear only at the export boundary.
7
+
8
+ Scope, honestly held: right circular cylinders with +z axes — the
9
+ drilled-hole workhorse (plain, blind, and coaxial counterbore stacks).
10
+ Every geometric precondition is checked with exact rational predicates
11
+ (bore strictly inside the lateral boundary, non-coaxial bores disjoint);
12
+ configurations outside the scope refuse with the stage that brings them
13
+ (K2.1 general quadric booleans).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from dataclasses import dataclass
20
+ from fractions import Fraction
21
+
22
+ from forgekernel.brep import Solid
23
+ from forgekernel.exact import F, dot
24
+
25
+
26
+ class PiVal:
27
+ """An exact number a + b·π (a, b rational)."""
28
+
29
+ __slots__ = ("a", "b")
30
+
31
+ def __init__(self, a=0, b=0) -> None:
32
+ self.a, self.b = F(a), F(b)
33
+
34
+ def __add__(self, o: "PiVal | int | Fraction") -> "PiVal":
35
+ o = o if isinstance(o, PiVal) else PiVal(o)
36
+ return PiVal(self.a + o.a, self.b + o.b)
37
+
38
+ __radd__ = __add__
39
+
40
+ def __sub__(self, o: "PiVal | int | Fraction") -> "PiVal":
41
+ o = o if isinstance(o, PiVal) else PiVal(o)
42
+ return PiVal(self.a - o.a, self.b - o.b)
43
+
44
+ def __eq__(self, o: object) -> bool:
45
+ o = o if isinstance(o, PiVal) else PiVal(o)
46
+ return self.a == o.a and self.b == o.b
47
+
48
+ def __float__(self) -> float:
49
+ return float(self.a) + float(self.b) * math.pi
50
+
51
+ def __repr__(self) -> str:
52
+ return f"({self.a} + {self.b}·π)"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Cyl:
57
+ """A solid right circular cylinder, axis +z through (cx, cy)."""
58
+ cx: Fraction
59
+ cy: Fraction
60
+ r: Fraction
61
+ z0: Fraction
62
+ z1: Fraction
63
+
64
+ @classmethod
65
+ def make(cls, r, h) -> "Cyl":
66
+ r, h = F(r), F(h)
67
+ if r <= 0 or h <= 0:
68
+ raise ValueError("cylinder wants positive radius/height")
69
+ return cls(F(0), F(0), r, F(0), h)
70
+
71
+ def translated(self, x, y, z) -> "Cyl":
72
+ return Cyl(self.cx + F(x), self.cy + F(y), self.r,
73
+ self.z0 + F(z), self.z1 + F(z))
74
+
75
+ def volume(self) -> PiVal:
76
+ return PiVal(0, self.r * self.r * (self.z1 - self.z0))
77
+
78
+ def centroid_f(self) -> tuple[float, float, float]:
79
+ return (float(self.cx), float(self.cy),
80
+ float((self.z0 + self.z1) / 2))
81
+
82
+ def bbox(self):
83
+ return ((self.cx - self.r, self.cy - self.r, self.z0),
84
+ (self.cx + self.r, self.cy + self.r, self.z1))
85
+
86
+
87
+ def _dist2_point_seg(px, py, ax, ay, bx, by) -> Fraction:
88
+ """Exact squared distance from point to segment (all rational)."""
89
+ dx, dy = bx - ax, by - ay
90
+ nn = dx * dx + dy * dy
91
+ if nn == 0:
92
+ ex, ey = px - ax, py - ay
93
+ return ex * ex + ey * ey
94
+ t = ((px - ax) * dx + (py - ay) * dy) / nn
95
+ t = max(F(0), min(F(1), t))
96
+ ex, ey = px - (ax + t * dx), py - (ay + t * dy)
97
+ return ex * ex + ey * ey
98
+
99
+
100
+ class DrilledSolid:
101
+ """A planar Solid minus z-axis cylindrical bores — exact composite.
102
+
103
+ Preconditions (exact predicates, refusal on violation):
104
+ - each bore's circle stays strictly clear of every non-horizontal
105
+ face of the base in xy (the barrel never crosses a wall);
106
+ - non-coaxial bores are pairwise disjoint (coaxial stacks allowed —
107
+ counterbores); volume of a coaxial stack is the exact z-interval
108
+ union with the largest active radius per interval.
109
+ """
110
+
111
+ def __init__(self, base: Solid, bores: list[Cyl]) -> None:
112
+ self.base = base
113
+ self.bores = list(bores)
114
+
115
+ def cut(self, c: Cyl) -> "DrilledSolid":
116
+ # clamp to the base z-extent (drilling from above through air is fine)
117
+ (bx0, by0, bz0), (bx1, by1, bz1) = self.base.bbox()
118
+ z0, z1 = max(c.z0, bz0), min(c.z1, bz1)
119
+ if z1 <= z0:
120
+ raise ValueError("bore misses the solid in z (K2.1 for the rest)")
121
+ c = Cyl(c.cx, c.cy, c.r, z0, z1)
122
+ r2 = c.r * c.r
123
+ for p in self.base.polys:
124
+ n = p.plane.n
125
+ if n[0] == 0 and n[1] == 0:
126
+ continue # horizontal face: cap, fine
127
+ m = len(p.verts)
128
+ for i in range(m):
129
+ a, b = p.verts[i], p.verts[(i + 1) % m]
130
+ if _dist2_point_seg(c.cx, c.cy, a[0], a[1], b[0], b[1]) <= r2:
131
+ raise ValueError(
132
+ "bore crosses a lateral wall — general quadric "
133
+ "booleans arrive at K2.1")
134
+ for o in self.bores:
135
+ if o.cx == c.cx and o.cy == c.cy:
136
+ continue # coaxial stack (counterbore)
137
+ dx, dy = o.cx - c.cx, o.cy - c.cy
138
+ if dx * dx + dy * dy <= (o.r + c.r) ** 2:
139
+ raise ValueError(
140
+ "bores intersect — general quadric booleans arrive "
141
+ "at K2.1")
142
+ return DrilledSolid(self.base, self.bores + [c])
143
+
144
+ def _bore_union_volume(self) -> PiVal:
145
+ """Exact removed volume: coaxial groups unioned by z-interval with
146
+ the largest active radius per elementary interval."""
147
+ from collections import defaultdict
148
+
149
+ groups: dict = defaultdict(list)
150
+ for c in self.bores:
151
+ groups[(c.cx, c.cy)].append(c)
152
+ total = PiVal(0, 0)
153
+ for cyls in groups.values():
154
+ cuts = sorted({t for c in cyls for t in (c.z0, c.z1)})
155
+ for lo, hi in zip(cuts, cuts[1:]):
156
+ rs = [c.r for c in cyls if c.z0 <= lo and hi <= c.z1]
157
+ if rs:
158
+ rmax = max(rs)
159
+ total = total + PiVal(0, rmax * rmax * (hi - lo))
160
+ return total
161
+
162
+ def volume(self) -> PiVal:
163
+ return PiVal(self.base.volume(), 0) - self._bore_union_volume()
164
+
165
+ def centroid_f(self) -> tuple[float, float, float]:
166
+ """Centroid, floated at the boundary (the exact value is a ratio
167
+ of ℚ[π] numbers — outside the field, so floats are honest here)."""
168
+ bv = float(self.base.volume())
169
+ c = self.base.centroid()
170
+ acc = [bv * float(c[0]), bv * float(c[1]), bv * float(c[2])]
171
+ vol = bv
172
+ for cyl in self.bores:
173
+ v = float(cyl.volume())
174
+ cc = cyl.centroid_f()
175
+ for i in range(3):
176
+ acc[i] -= v * cc[i]
177
+ vol -= v
178
+ return (acc[0] / vol, acc[1] / vol, acc[2] / vol)
179
+
180
+ def bbox(self):
181
+ return self.base.bbox()
182
+
183
+ def translated(self, x, y, z) -> "DrilledSolid":
184
+ """Rigid translation — base and every bore move together (exact).
185
+ Enables patterning a drilled feature (bolt patterns)."""
186
+ base = self.base.translated((F(x), F(y), F(z)))
187
+ return DrilledSolid(base, [b.translated(x, y, z) for b in self.bores])
188
+
189
+ def watertight_violations(self) -> list[str]:
190
+ return self.base.watertight_violations()
191
+
192
+ def cylinder_faces(self) -> list[dict]:
193
+ """OCCT-shaped descriptors, one per bore — feature recognition and
194
+ hole callouts read these keys."""
195
+ return [{"surface": "cylinder", "radius": float(c.r),
196
+ "axis_dir": [0.0, 0.0, 1.0],
197
+ "axis_origin": [float(c.cx), float(c.cy), float(c.z0)]}
198
+ for c in self.bores]
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class Sphere:
203
+ """Solid sphere centered (cx, cy, cz)."""
204
+ cx: Fraction
205
+ cy: Fraction
206
+ cz: Fraction
207
+ r: Fraction
208
+
209
+ @classmethod
210
+ def make(cls, r) -> "Sphere":
211
+ r = F(r)
212
+ if r <= 0:
213
+ raise ValueError("sphere wants positive radius")
214
+ return cls(F(0), F(0), F(0), r)
215
+
216
+ def translated(self, x, y, z) -> "Sphere":
217
+ return Sphere(self.cx + F(x), self.cy + F(y), self.cz + F(z), self.r)
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class Cone:
222
+ """Right conical frustum, axis +z through (cx, cy), r1 at z0, r2 at z1."""
223
+ cx: Fraction
224
+ cy: Fraction
225
+ r1: Fraction
226
+ r2: Fraction
227
+ z0: Fraction
228
+ z1: Fraction
229
+
230
+ @classmethod
231
+ def make(cls, r1, r2, h) -> "Cone":
232
+ r1, r2, h = F(r1), F(r2), F(h)
233
+ if h <= 0 or r1 < 0 or r2 < 0 or (r1 == 0 and r2 == 0):
234
+ raise ValueError("cone wants positive height and a radius")
235
+ return cls(F(0), F(0), r1, r2, F(0), h)
236
+
237
+ def translated(self, x, y, z) -> "Cone":
238
+ return Cone(self.cx + F(x), self.cy + F(y), self.r1, self.r2,
239
+ self.z0 + F(z), self.z1 + F(z))
240
+
241
+
242
+ class _Quad:
243
+ """Exact quadratic q(z) = a z^2 + b z + c — the r^2 profile of every
244
+ K2 primitive (cylinder: constant; cone: squared linear; sphere:
245
+ R^2 - (z-c)^2)."""
246
+
247
+ __slots__ = ("a", "b", "c")
248
+
249
+ def __init__(self, a, b, c) -> None:
250
+ self.a, self.b, self.c = F(a), F(b), F(c)
251
+
252
+ def at(self, z: Fraction) -> Fraction:
253
+ return self.a * z * z + self.b * z + self.c
254
+
255
+ def integral(self, lo: Fraction, hi: Fraction) -> Fraction:
256
+ return (self.a * (hi ** 3 - lo ** 3) / 3
257
+ + self.b * (hi ** 2 - lo ** 2) / 2 + self.c * (hi - lo))
258
+
259
+ def z_integral(self, lo: Fraction, hi: Fraction) -> Fraction:
260
+ """Integral of z*q(z)."""
261
+ return (self.a * (hi ** 4 - lo ** 4) / 4
262
+ + self.b * (hi ** 3 - lo ** 3) / 3
263
+ + self.c * (hi ** 2 - lo ** 2) / 2)
264
+
265
+ def rational_roots_between(self, lo: Fraction, hi: Fraction,
266
+ other: "_Quad") -> list[Fraction] | None:
267
+ """Roots of (self - other) strictly inside (lo, hi): the list when
268
+ every such root is rational, None when an IRRATIONAL crossover may
269
+ exist in range (the caller refuses — exactness is never faked)."""
270
+ a, b, c = self.a - other.a, self.b - other.b, self.c - other.c
271
+ if a == 0:
272
+ if b == 0:
273
+ return []
274
+ z = -c / b
275
+ return [z] if lo < z < hi else []
276
+ disc = b * b - 4 * a * c
277
+ if disc < 0:
278
+ return []
279
+ num, den = disc.numerator, disc.denominator
280
+ rn, rd = math.isqrt(num), math.isqrt(den)
281
+ if rn * rn != num or rd * rd != den:
282
+ # irrational roots: exact interval sign analysis decides if any
283
+ # lie in (lo, hi); vertex of the difference is at -b/2a
284
+ vz = -b / (2 * a)
285
+ s_lo = a * lo * lo + b * lo + c
286
+ s_hi = a * hi * hi + b * hi + c
287
+ crosses = (s_lo < 0) != (s_hi < 0)
288
+ if not crosses and lo < vz < hi:
289
+ s_v = a * vz * vz + b * vz + c
290
+ crosses = (s_v < 0) != (s_lo < 0) and s_v != 0
291
+ return None if crosses else []
292
+ sq = Fraction(rn, rd)
293
+ roots = [(-b - sq) / (2 * a), (-b + sq) / (2 * a)]
294
+ return [z for z in roots if lo < z < hi]
295
+
296
+
297
+ def _segments_of(prim) -> list:
298
+ if isinstance(prim, Cyl):
299
+ return [(prim.z0, prim.z1, _Quad(0, 0, prim.r * prim.r))]
300
+ if isinstance(prim, Cone):
301
+ h = prim.z1 - prim.z0
302
+ k = (prim.r2 - prim.r1) / h
303
+ a = k * k
304
+ b = 2 * prim.r1 * k - 2 * k * k * prim.z0
305
+ c = (prim.r1 - k * prim.z0) ** 2
306
+ return [(prim.z0, prim.z1, _Quad(a, b, c))]
307
+ if isinstance(prim, Sphere):
308
+ return [(prim.cz - prim.r, prim.cz + prim.r,
309
+ _Quad(-1, 2 * prim.cz, prim.r * prim.r - prim.cz * prim.cz))]
310
+ raise TypeError(f"not an axis primitive: {type(prim).__name__}")
311
+
312
+
313
+ class AxisStack:
314
+ """Union of coaxial z-axis primitives — exact in the field of a+b*pi.
315
+
316
+ Concentric circles make the union area pi*max_i r_i(z)^2, and every
317
+ r^2 profile is a rational quadratic, so the union integrates exactly
318
+ piecewise. Profile crossovers must land on rational z; a possible
319
+ irrational crossover refuses honestly (K2.2 brings the algebraic
320
+ extension)."""
321
+
322
+ def __init__(self, cx, cy, prims: list) -> None:
323
+ self.cx, self.cy = F(cx), F(cy)
324
+ self.prims = list(prims)
325
+
326
+ def fuse(self, prim) -> "AxisStack":
327
+ if getattr(prim, "cx", None) != self.cx or \
328
+ getattr(prim, "cy", None) != self.cy:
329
+ raise ValueError("union of non-coaxial quadrics arrives at K2.2")
330
+ return AxisStack(self.cx, self.cy, self.prims + [prim])
331
+
332
+ def _pieces(self) -> list:
333
+ segs = [s for p in self.prims for s in _segments_of(p)]
334
+ cuts = {t for lo, hi, _ in segs for t in (lo, hi)}
335
+ for i, (lo1, hi1, q1) in enumerate(segs):
336
+ for lo2, hi2, q2 in segs[i + 1:]:
337
+ lo, hi = max(lo1, lo2), min(hi1, hi2)
338
+ if lo < hi:
339
+ roots = q1.rational_roots_between(lo, hi, q2)
340
+ if roots is None:
341
+ raise ValueError(
342
+ "irrational profile crossover arrives at K2.2 "
343
+ "(algebraic extension)")
344
+ cuts.update(roots)
345
+ ordered = sorted(cuts)
346
+ out = []
347
+ for lo, hi in zip(ordered, ordered[1:]):
348
+ mid = (lo + hi) / 2
349
+ live = [q for slo, shi, q in segs if slo <= lo and hi <= shi]
350
+ if not live:
351
+ continue
352
+ best = max(live, key=lambda q: q.at(mid))
353
+ out.append((lo, hi, best))
354
+ return out
355
+
356
+ def volume(self) -> PiVal:
357
+ return PiVal(0, sum((q.integral(lo, hi)
358
+ for lo, hi, q in self._pieces()), F(0)))
359
+
360
+ def centroid_f(self) -> tuple[float, float, float]:
361
+ pieces = self._pieces()
362
+ v = sum((q.integral(lo, hi) for lo, hi, q in pieces), F(0))
363
+ zbar = sum((q.z_integral(lo, hi) for lo, hi, q in pieces), F(0)) / v
364
+ return (float(self.cx), float(self.cy), float(zbar))
365
+
366
+ def tessellate(self, deflection: float = 0.2) -> dict:
367
+ from forgekernel.tess import lathe
368
+
369
+ pieces = self._pieces()
370
+ profile = [(0.0, float(pieces[0][0]))]
371
+ for lo, hi, q in pieces:
372
+ import math as _m
373
+ profile.append((_m.sqrt(max(0.0, float(q.at(lo)))), float(lo)))
374
+ profile.append((_m.sqrt(max(0.0, float(q.at(hi)))), float(hi)))
375
+ profile.append((0.0, float(pieces[-1][1])))
376
+ return lathe(profile, deflection, float(self.cx), float(self.cy))
377
+
378
+ def bbox(self):
379
+ pieces = self._pieces()
380
+ z0 = min(lo for lo, _, _ in pieces)
381
+ z1 = max(hi for _, hi, _ in pieces)
382
+ r2max = F(0)
383
+ for lo, hi, q in pieces:
384
+ cands = [q.at(lo), q.at(hi)]
385
+ if q.a != 0:
386
+ vz = -q.b / (2 * q.a)
387
+ if lo <= vz <= hi:
388
+ cands.append(q.at(vz))
389
+ r2max = max(r2max, *cands)
390
+ r = math.sqrt(float(r2max))
391
+ return ((float(self.cx) - r, float(self.cy) - r, float(z0)),
392
+ (float(self.cx) + r, float(self.cy) + r, float(z1)))
393
+
394
+
395
+ class RevolveSolid:
396
+ """A closed line-segment profile in the (r, z) half-plane revolved
397
+ 360 degrees about the z axis. Green gives exact metrics:
398
+ V = pi * contour_integral(r^2 dz), each edge contributing
399
+ (z2-z1)(r1^2 + r1 r2 + r2^2)/3."""
400
+
401
+ def __init__(self, loop_rz: list) -> None:
402
+ self.loop = [(F(r), F(z)) for r, z in loop_rz]
403
+ if any(r < 0 for r, _ in self.loop):
404
+ raise ValueError("revolve profile must stay at r >= 0")
405
+ if self._v3() < 0:
406
+ self.loop = list(reversed(self.loop))
407
+
408
+ def _edges(self):
409
+ n = len(self.loop)
410
+ for i in range(n):
411
+ yield self.loop[i], self.loop[(i + 1) % n]
412
+
413
+ def _v3(self) -> Fraction:
414
+ acc = F(0)
415
+ for (r1, z1), (r2, z2) in self._edges():
416
+ acc += (z2 - z1) * (r1 * r1 + r1 * r2 + r2 * r2)
417
+ return acc
418
+
419
+ def volume(self) -> PiVal:
420
+ return PiVal(0, self._v3() / 3)
421
+
422
+ def centroid_f(self) -> tuple[float, float, float]:
423
+ num = F(0)
424
+ for (r1, z1), (r2, z2) in self._edges():
425
+ dz = z2 - z1
426
+ num += dz * (z1 * (3 * r1 * r1 + 2 * r1 * r2 + r2 * r2)
427
+ + z2 * (r1 * r1 + 2 * r1 * r2 + 3 * r2 * r2)) / 12
428
+ v3 = self._v3()
429
+ return (0.0, 0.0, float(num / v3 * 3) if v3 else 0.0)
430
+
431
+ def bbox(self):
432
+ rmax = max(r for r, _ in self.loop)
433
+ zs = [z for _, z in self.loop]
434
+ return ((-float(rmax), -float(rmax), float(min(zs))),
435
+ (float(rmax), float(rmax), float(max(zs))))
436
+
437
+ def tessellate(self, deflection: float = 0.2) -> dict:
438
+ from forgekernel.tess import lathe
439
+
440
+ return lathe([(float(r), float(z)) for r, z in self.loop], deflection)
441
+
442
+
443
+ def _member_volume(m):
444
+ if isinstance(m, (Sphere, Cone)):
445
+ return AxisStack(m.cx, m.cy, [m]).volume()
446
+ return m.volume()
447
+
448
+
449
+ def _member_centroid(m):
450
+ if isinstance(m, (Sphere, Cone)):
451
+ return AxisStack(m.cx, m.cy, [m]).centroid_f()
452
+ if hasattr(m, "centroid_f"):
453
+ return m.centroid_f()
454
+ return tuple(float(x) for x in m.centroid())
455
+
456
+
457
+ class DisjointUnion:
458
+ """Union of solids that meet at most tangentially — exact.
459
+
460
+ Tangent contact is measure-zero, so the union volume is EXACTLY the
461
+ sum of member volumes; the only real work is PROVING the members are
462
+ disjoint-or-tangent with exact predicates (no sqrt: squared distances
463
+ and squared-radius comparisons). Any genuine overlap refuses honestly
464
+ (K2.3 brings general quadric booleans)."""
465
+
466
+ def __init__(self, members: list) -> None:
467
+ self.members = list(members)
468
+ for i, a in enumerate(self.members):
469
+ for b in self.members[i + 1:]:
470
+ _classify_pair(a, b) # raises on genuine overlap
471
+
472
+ def add(self, other) -> "DisjointUnion":
473
+ for m in self.members:
474
+ _classify_pair(m, other)
475
+ return DisjointUnion(self.members + [other])
476
+
477
+ def volume(self) -> "PiVal":
478
+ total = PiVal(0, 0)
479
+ for m in self.members:
480
+ v = _member_volume(m)
481
+ total = total + (v if isinstance(v, PiVal) else PiVal(v, 0))
482
+ return total
483
+
484
+ def centroid_f(self) -> tuple:
485
+ acc = [0.0, 0.0, 0.0]
486
+ vtot = 0.0
487
+ for m in self.members:
488
+ v = float(_member_volume(m))
489
+ c = _member_centroid(m)
490
+ for i in range(3):
491
+ acc[i] += v * c[i]
492
+ vtot += v
493
+ return (acc[0] / vtot, acc[1] / vtot, acc[2] / vtot)
494
+
495
+ def bbox(self):
496
+ boxes = [m.bbox() for m in self.members]
497
+ lo = tuple(min(float(b[0][i]) for b in boxes) for i in range(3))
498
+ hi = tuple(max(float(b[1][i]) for b in boxes) for i in range(3))
499
+ return (lo, hi)
500
+
501
+ def watertight_violations(self) -> list:
502
+ bad = []
503
+ for m in self.members:
504
+ if hasattr(m, "watertight_violations"):
505
+ bad += m.watertight_violations()
506
+ return bad
507
+
508
+
509
+ def _zrange(prim):
510
+ """Exact z-extent of an axis primitive (None for general solids)."""
511
+ if isinstance(prim, Cyl):
512
+ return (prim.z0, prim.z1)
513
+ if isinstance(prim, Cone):
514
+ return (prim.z0, prim.z1)
515
+ if isinstance(prim, Sphere):
516
+ return (prim.cz - prim.r, prim.cz + prim.r)
517
+ return None
518
+
519
+
520
+ def _classify_pair(a, b) -> None:
521
+ """Raise ValueError iff a and b genuinely overlap (positive-measure
522
+ intersection). Silent return = disjoint or tangent (both exact)."""
523
+ # cylinder / cylinder, parallel +z axes
524
+ if isinstance(a, Cyl) and isinstance(b, Cyl):
525
+ za, zb = (a.z0, a.z1), (b.z0, b.z1)
526
+ if za[1] <= zb[0] or zb[1] <= za[0]:
527
+ return # disjoint in z
528
+ d2 = (a.cx - b.cx) ** 2 + (a.cy - b.cy) ** 2
529
+ outer = (a.r + b.r) ** 2
530
+ inner = (a.r - b.r) ** 2
531
+ if d2 >= outer or d2 <= inner:
532
+ return # externally/internally clear
533
+ raise ValueError(
534
+ "overlapping cylinders — general quadric booleans arrive at K2.3")
535
+ # sphere / planar Solid
536
+ if isinstance(a, Sphere) and isinstance(b, Solid):
537
+ _sphere_solid(a, b)
538
+ return
539
+ if isinstance(b, Sphere) and isinstance(a, Solid):
540
+ _sphere_solid(b, a)
541
+ return
542
+ # sphere / sphere
543
+ if isinstance(a, Sphere) and isinstance(b, Sphere):
544
+ d2 = (a.cx - b.cx) ** 2 + (a.cy - b.cy) ** 2 + (a.cz - b.cz) ** 2
545
+ if d2 >= (a.r + b.r) ** 2 or d2 <= (a.r - b.r) ** 2:
546
+ return
547
+ raise ValueError(
548
+ "overlapping spheres — general quadric booleans arrive at K2.3")
549
+ raise ValueError(
550
+ f"disjoint-union of {type(a).__name__}+{type(b).__name__} arrives "
551
+ "at K2.3")
552
+
553
+
554
+ def _sphere_solid(s: "Sphere", solid: "Solid") -> None:
555
+ """Disjoint/tangent iff the sphere center lies on the far side of (or
556
+ exactly on) some face plane by at least the radius — exact, sqrt-free:
557
+ for outward plane n·x = d, signed gap g = n·c − d; clear iff g > 0 and
558
+ g² ≥ r²·(n·n) (both sides squared, exact). Convex-solid sufficient
559
+ condition; a sphere separated from a convex solid is separated by one
560
+ of its face planes."""
561
+ c = (s.cx, s.cy, s.cz)
562
+ seen = set()
563
+ for p in solid.polys:
564
+ key = p.plane.canonical()
565
+ if key in seen:
566
+ continue
567
+ seen.add(key)
568
+ n, dpl = p.plane.n, p.plane.d
569
+ g = dot(n, c) - dpl
570
+ if g > 0 and g * g >= s.r * s.r * dot(n, n):
571
+ return # separated by this face plane
572
+ raise ValueError(
573
+ "sphere overlaps the solid — general quadric booleans arrive at K2.3")
574
+
575
+
576
+ class RoundedBox:
577
+ """An axis-aligned box with ALL edges and corners filleted radius r —
578
+ the Minkowski sum of the inner core box (a-2r)x(b-2r)x(c-2r) with a
579
+ ball of radius r. Steiner's formula gives the volume EXACTLY in Q[pi]:
580
+
581
+ V = pqs + 2r(pq+qs+sp) + pi r^2 (p+q+s) + (4/3) pi r^3
582
+
583
+ (core box + face slabs + edge quarter-cylinders + 8 corner octants),
584
+ p=a-2r etc. Requires 2r <= min(a,b,c); tighter fillets need the general
585
+ blend engine (K5)."""
586
+
587
+ def __init__(self, a, b, c, r, origin=(0, 0, 0)) -> None:
588
+ self.a, self.b, self.c, self.r = F(a), F(b), F(c), F(r)
589
+ self.origin = tuple(F(v) for v in origin)
590
+ if 2 * self.r > min(self.a, self.b, self.c):
591
+ raise ValueError("fillet radius exceeds half the smallest dimension")
592
+
593
+ def _pqs(self):
594
+ r = self.r
595
+ return self.a - 2 * r, self.b - 2 * r, self.c - 2 * r
596
+
597
+ def volume(self) -> PiVal:
598
+ r = self.r
599
+ p, q, s = self._pqs()
600
+ rational = p * q * s + 2 * r * (p * q + q * s + s * p)
601
+ pi_part = r * r * (p + q + s) + Fraction(4, 3) * r ** 3
602
+ return PiVal(rational, pi_part)
603
+
604
+ def centroid_f(self) -> tuple:
605
+ ox, oy, oz = self.origin
606
+ return (float(ox + self.a / 2), float(oy + self.b / 2),
607
+ float(oz + self.c / 2))
608
+
609
+ def bbox(self):
610
+ ox, oy, oz = self.origin
611
+ return ((float(ox), float(oy), float(oz)),
612
+ (float(ox + self.a), float(oy + self.b), float(oz + self.c)))
613
+
614
+ def watertight_violations(self) -> list:
615
+ return []
616
+
617
+
618
+ class MiteredSweep:
619
+ """A convex profile swept along a polyline with miter joints — exact
620
+ in ℚ[√d]. Volume = profile_area × centerline_length: at a miter the
621
+ bisector plane removes a wedge from one segment that the neighbour
622
+ adds back identically, so the swept volume is exactly the straight
623
+ area×length even through corners. Segment lengths accumulate in one
624
+ quadratic-surd field; a path mixing radicals (e.g. √2 and √3) refuses
625
+ (K3.1). This is the model OCCT cannot build (swept_channel)."""
626
+
627
+ def __init__(self, area, path: list) -> None:
628
+ from forgekernel.surd import SurdVal, sqrt_rational
629
+
630
+ self.area = F(area)
631
+ self.path = [tuple(F(c) for c in p) for p in path]
632
+ if len(self.path) < 2:
633
+ raise ValueError("sweep path needs >= 2 points")
634
+ length = SurdVal(0, 0, 1)
635
+ for a, b in zip(self.path, self.path[1:]):
636
+ d2 = sum((b[i] - a[i]) ** 2 for i in range(3))
637
+ length = length + sqrt_rational(d2) # may raise on mixed radicals
638
+ self._length = length
639
+
640
+ def length(self):
641
+ return self._length
642
+
643
+ def volume(self):
644
+ return self._length * self.area # SurdVal
645
+
646
+ def centroid_f(self) -> tuple:
647
+ # centroid on the centerline, length-weighted midpoints (floated)
648
+ from forgekernel.surd import sqrt_rational
649
+
650
+ acc = [0.0, 0.0, 0.0]
651
+ tot = 0.0
652
+ for a, b in zip(self.path, self.path[1:]):
653
+ seg = float(sqrt_rational(sum((b[i] - a[i]) ** 2 for i in range(3))))
654
+ mid = [(float(a[i]) + float(b[i])) / 2 for i in range(3)]
655
+ for i in range(3):
656
+ acc[i] += seg * mid[i]
657
+ tot += seg
658
+ return (acc[0] / tot, acc[1] / tot, acc[2] / tot)
659
+
660
+ def bbox(self):
661
+ # centerline bbox padded by the profile's circumradius estimate
662
+ pad = math.sqrt(float(self.area))
663
+ xs = [p[0] for p in self.path]
664
+ ys = [p[1] for p in self.path]
665
+ zs = [p[2] for p in self.path]
666
+ return ((float(min(xs)) - pad, float(min(ys)) - pad, float(min(zs)) - pad),
667
+ (float(max(xs)) + pad, float(max(ys)) + pad, float(max(zs)) + pad))
668
+
669
+ def watertight_violations(self) -> list:
670
+ return []
671
+
672
+
673
+ class SphereOverlap:
674
+ """Union/intersection/difference of two GENUINELY OVERLAPPING spheres —
675
+ exact in ℚ[π]. The lens (intersection) is a sum of two spherical caps,
676
+ each of volume π h²(3r − h)/3 with h rational when the centre distance
677
+ and radii are rational, so every boolean volume stays in ℚ[π]:
678
+
679
+ d1 = (d² + r1² − r2²)/(2d) (rational plane offset from centre 1)
680
+ h1 = r1 − d1, h2 = r2 − (d − d1) (rational cap heights)
681
+ lens = cap(r1,h1) + cap(r2,h2), cap(r,h)=π h²(3r−h)/3
682
+ union = V1 + V2 − lens, cut = V1 − lens, intersect = lens
683
+
684
+ Parallel-cylinder overlap and cylinder–wall crossing are TRANSCENDENTAL
685
+ (arccos/√ lens) and are refused elsewhere — this is the exact case."""
686
+
687
+ def __init__(self, a: Sphere, b: Sphere, op: str) -> None:
688
+ self.a, self.b, self.op = a, b, op
689
+ d2 = (a.cx - b.cx) ** 2 + (a.cy - b.cy) ** 2 + (a.cz - b.cz) ** 2
690
+ if d2 >= (a.r + b.r) ** 2:
691
+ raise ValueError("spheres do not overlap (use DisjointUnion)")
692
+ if d2 <= (a.r - b.r) ** 2:
693
+ raise ValueError("one sphere contains the other (K2.3 nesting)")
694
+ # d must be rational for the caps to stay in ℚ[π]
695
+ import math as _m
696
+ dn, dd = d2.numerator, d2.denominator
697
+ rn, rd = _m.isqrt(dn), _m.isqrt(dd)
698
+ if rn * rn != dn or rd * rd != dd:
699
+ raise ValueError(
700
+ "irrational centre distance — the cap heights leave ℚ[π] "
701
+ "(K3: algebraic/transcendental)")
702
+ self.d = Fraction(rn, rd)
703
+
704
+ @staticmethod
705
+ def _cap(r: Fraction, h: Fraction) -> Fraction:
706
+ # π-coefficient of the cap volume π h²(3r − h)/3
707
+ return h * h * (3 * r - h) / 3
708
+
709
+ def _lens_picoeff(self) -> Fraction:
710
+ r1, r2, d = self.a.r, self.b.r, self.d
711
+ d1 = (d * d + r1 * r1 - r2 * r2) / (2 * d)
712
+ h1 = r1 - d1
713
+ h2 = r2 - (d - d1)
714
+ return self._cap(r1, h1) + self._cap(r2, h2)
715
+
716
+ def volume(self) -> PiVal:
717
+ v1 = Fraction(4, 3) * self.a.r ** 3
718
+ v2 = Fraction(4, 3) * self.b.r ** 3
719
+ lens = self._lens_picoeff()
720
+ if self.op == "intersect":
721
+ return PiVal(0, lens)
722
+ if self.op == "cut":
723
+ return PiVal(0, v1 - lens)
724
+ return PiVal(0, v1 + v2 - lens) # union
725
+
726
+ def centroid_f(self) -> tuple:
727
+ return (float((self.a.cx + self.b.cx) / 2),
728
+ float((self.a.cy + self.b.cy) / 2),
729
+ float((self.a.cz + self.b.cz) / 2))
730
+
731
+ def bbox(self):
732
+ a, b = self.a, self.b
733
+ if self.op == "intersect":
734
+ lo = (max(a.cx - a.r, b.cx - b.r), max(a.cy - a.r, b.cy - b.r),
735
+ max(a.cz - a.r, b.cz - b.r))
736
+ hi = (min(a.cx + a.r, b.cx + b.r), min(a.cy + a.r, b.cy + b.r),
737
+ min(a.cz + a.r, b.cz + b.r))
738
+ return (tuple(float(v) for v in lo), tuple(float(v) for v in hi))
739
+ s = a if self.op == "cut" else None
740
+ boxes = [a] if s else [a, b]
741
+ lo = (min(float(x.cx - x.r) for x in boxes),
742
+ min(float(x.cy - x.r) for x in boxes),
743
+ min(float(x.cz - x.r) for x in boxes))
744
+ hi = (max(float(x.cx + x.r) for x in boxes),
745
+ max(float(x.cy + x.r) for x in boxes),
746
+ max(float(x.cz + x.r) for x in boxes))
747
+ return (lo, hi)
748
+
749
+ def watertight_violations(self) -> list:
750
+ return []
751
+
752
+
753
+ def steinmetz(r) -> PiVal:
754
+ """Intersection of two equal perpendicular cylinders radius r (the
755
+ bicylinder / Steinmetz solid) — famously EXACT and π-free: 16 r³/3."""
756
+ r = F(r)
757
+ return PiVal(Fraction(16, 3) * r ** 3, 0)
758
+
759
+
760
+ # -- K5.0: rolling-ball fillets on SELECTED straight box edges ----------------
761
+
762
+ class FilletedBox:
763
+ """A box with a constant-radius rolling-ball fillet on a chosen
764
+ subset of its straight edges — exact in ℚ[π].
765
+
766
+ Removed material per edge = (square corner prism) − (quarter
767
+ cylinder): ΔV = (r² − πr²/4)·L, so
768
+
769
+ V = V_box − Σ r²L + π·Σ (r²/4)L — a PiVal, exact.
770
+
771
+ Selected edges must be pairwise NON-ADJACENT (sharing a box vertex
772
+ would need the spherical corner patch — that is K5.1); adjacency
773
+ refuses with the stage name. Edges are given as (axis, side_a,
774
+ side_b): the edge parallel to ``axis`` on the (min/max, min/max)
775
+ sides of the other two axes, e.g. ('z', 'max', 'max')."""
776
+
777
+ def __init__(self, lo, hi, edges, radius) -> None:
778
+ self.lo = tuple(F(v) for v in lo)
779
+ self.hi = tuple(F(v) for v in hi)
780
+ self.r = F(radius)
781
+ self.edges = list(edges)
782
+ dims = [self.hi[c] - self.lo[c] for c in range(3)]
783
+ if self.r <= 0:
784
+ raise ValueError("fillet wants positive radius")
785
+ axes = {"x": 0, "y": 1, "z": 2}
786
+ seen = set()
787
+ verts: list[set] = []
788
+ for axis, sa, sb in self.edges:
789
+ a = axes[axis]
790
+ o1, o2 = [c for c in range(3) if c != a]
791
+ if 2 * self.r > min(dims[o1], dims[o2]):
792
+ raise ValueError("fillet radius exceeds the face half-width")
793
+ key = (a, sa, sb)
794
+ if key in seen:
795
+ raise ValueError("edge selected twice")
796
+ seen.add(key)
797
+ # the edge's two box vertices, for adjacency detection
798
+ c1 = self.lo[o1] if sa == "min" else self.hi[o1]
799
+ c2 = self.lo[o2] if sb == "min" else self.hi[o2]
800
+ vset = set()
801
+ for end in (self.lo[a], self.hi[a]):
802
+ v = [0, 0, 0]
803
+ v[a], v[o1], v[o2] = end, c1, c2
804
+ vset.add(tuple(v))
805
+ verts.append(vset)
806
+ # K5.1: classify every box vertex by how many SELECTED edges meet
807
+ # there. 0/1 → nothing special; 3 → the sphere-OCTANT corner
808
+ # patch (exact in ℚ[π]: removed corner material = r³ − πr³/6,
809
+ # and each incident edge's run is shortened by r); 2 → the blend
810
+ # is a genuinely non-spherical surface — refuses (K5.2).
811
+ incident: dict = {}
812
+ for i, vset in enumerate(verts):
813
+ for v in vset:
814
+ incident.setdefault(v, []).append(i)
815
+ self.corners: list[tuple] = []
816
+ self._shorten: dict[int, int] = {i: 0 for i in range(len(self.edges))}
817
+ for v, idxs in incident.items():
818
+ if len(idxs) == 2:
819
+ raise ValueError(
820
+ "two filleted edges meeting at a corner (third sharp) "
821
+ "need a non-spherical blend (arrives at K5.2)")
822
+ if len(idxs) == 3:
823
+ self.corners.append(v)
824
+ for i in idxs:
825
+ self._shorten[i] += 1
826
+
827
+ def _edge_len(self, i: int) -> F:
828
+ axis = self.edges[i][0]
829
+ a = {"x": 0, "y": 1, "z": 2}[axis]
830
+ full = self.hi[a] - self.lo[a]
831
+ eff = full - self.r * self._shorten[i]
832
+ if eff < 0:
833
+ raise ValueError("fillet radius exceeds the edge length")
834
+ return eff
835
+
836
+ def volume(self) -> PiVal:
837
+ vbox = F(1)
838
+ for c in range(3):
839
+ vbox *= self.hi[c] - self.lo[c]
840
+ rat = vbox
841
+ pi_c = F(0)
842
+ for i in range(len(self.edges)):
843
+ L = self._edge_len(i)
844
+ rat -= self.r * self.r * L
845
+ pi_c += self.r * self.r * L / 4
846
+ # sphere-octant corner patches: removed = r³ − πr³/6 each
847
+ r3 = self.r ** 3
848
+ n = len(self.corners)
849
+ rat -= n * r3
850
+ pi_c += n * r3 / 6
851
+ return PiVal(rat, pi_c)
852
+
853
+ def centroid_f(self):
854
+ import math
855
+ axes = {"x": 0, "y": 1, "z": 2}
856
+ vbox = 1.0
857
+ cb = [0.0, 0.0, 0.0]
858
+ for c in range(3):
859
+ vbox *= float(self.hi[c] - self.lo[c])
860
+ cb[c] = float(self.lo[c] + self.hi[c]) / 2
861
+ r = float(self.r)
862
+ num = [vbox * cb[c] for c in range(3)]
863
+ vtot = vbox
864
+ # removed region cross-section: square r×r at the edge corner minus
865
+ # the quarter disk about the inner corner. Exact area r²−πr²/4;
866
+ # centroid distance from the OUTER corner along each face:
867
+ # c* = (r/2·r² − (r−4r/3π)·πr²/4) / (r²−πr²/4)
868
+ area = r * r - math.pi * r * r / 4
869
+ cstar = (r * r * (r / 2) - (math.pi * r * r / 4) * (r - 4 * r / (3 * math.pi))) / area
870
+ for i, (axis, sa, sb) in enumerate(self.edges):
871
+ a = axes[axis]
872
+ o1, o2 = [c for c in range(3) if c != a]
873
+ L = float(self._edge_len(i))
874
+ vrem = area * L
875
+ crem = [0.0, 0.0, 0.0]
876
+ # midpoint of the EFFECTIVE run (shortened r at 3-corner ends)
877
+ run_lo, run_hi = float(self.lo[a]), float(self.hi[a])
878
+ for v in self.corners:
879
+ if v[a] == self.lo[a] and self._touches(i, v):
880
+ run_lo += r
881
+ if v[a] == self.hi[a] and self._touches(i, v):
882
+ run_hi -= r
883
+ crem[a] = (run_lo + run_hi) / 2
884
+ crem[o1] = (float(self.lo[o1]) + cstar if sa == "min"
885
+ else float(self.hi[o1]) - cstar)
886
+ crem[o2] = (float(self.lo[o2]) + cstar if sb == "min"
887
+ else float(self.hi[o2]) - cstar)
888
+ vtot -= vrem
889
+ for c in range(3):
890
+ num[c] -= vrem * crem[c]
891
+ # corner terms: removed cube-minus-octant at each 3-corner; its
892
+ # centroid sits c*₃ = r(1/2 − 5π/48)/(1 − π/6) inward per axis
893
+ if self.corners:
894
+ vrem_c = r ** 3 - math.pi * r ** 3 / 6
895
+ c3 = r * (0.5 - 5 * math.pi / 48) / (1 - math.pi / 6)
896
+ for v in self.corners:
897
+ crem = [0.0, 0.0, 0.0]
898
+ for c in range(3):
899
+ inward = 1.0 if v[c] == self.lo[c] else -1.0
900
+ crem[c] = float(v[c]) + inward * c3
901
+ vtot -= vrem_c
902
+ for c in range(3):
903
+ num[c] -= vrem_c * crem[c]
904
+ return tuple(n / vtot for n in num)
905
+
906
+ def _touches(self, edge_i: int, vertex) -> bool:
907
+ axis, sa, sb = self.edges[edge_i]
908
+ a = {"x": 0, "y": 1, "z": 2}[axis]
909
+ o1, o2 = [c for c in range(3) if c != a]
910
+ c1 = self.lo[o1] if sa == "min" else self.hi[o1]
911
+ c2 = self.lo[o2] if sb == "min" else self.hi[o2]
912
+ return vertex[o1] == c1 and vertex[o2] == c2
913
+
914
+ def bbox(self):
915
+ return self.lo, self.hi
916
+
917
+ def tessellate(self, deflection: float = 0.2) -> dict:
918
+ raise NotImplementedError("FilletedBox mesh arrives at K5.1")
919
+
920
+
921
+ class VariableFilletedBox:
922
+ """A box with LINEAR-TAPER rolling-ball fillets on non-adjacent
923
+ straight edges — still exact in ℚ[π].
924
+
925
+ A fillet whose radius runs linearly r(t)=r0+(r1−r0)t/L along an edge
926
+ removes cross-section area r(t)²(1−π/4), and ∫₀^L r(t)² dt =
927
+ L(r0²+r0r1+r1²)/3 exactly, so
928
+
929
+ V = V_box − Σ (1−π/4)·L(r0²+r0r1+r1²)/3 — a PiVal, exact.
930
+
931
+ Edges: (axis, side_a, side_b, r0, r1). Selected edges must be
932
+ pairwise non-adjacent (a shared vertex needs a variable corner
933
+ patch → K5.3)."""
934
+
935
+ def __init__(self, lo, hi, edges) -> None:
936
+ self.lo = tuple(F(v) for v in lo)
937
+ self.hi = tuple(F(v) for v in hi)
938
+ self.edges = [(ax, sa, sb, F(r0), F(r1)) for ax, sa, sb, r0, r1 in edges]
939
+ dims = [self.hi[c] - self.lo[c] for c in range(3)]
940
+ axes = {"x": 0, "y": 1, "z": 2}
941
+ verts: list[set] = []
942
+ for ax, sa, sb, r0, r1 in self.edges:
943
+ a = axes[ax]
944
+ o1, o2 = [c for c in range(3) if c != a]
945
+ if r0 <= 0 or r1 <= 0:
946
+ raise ValueError("taper fillet wants positive radii")
947
+ if 2 * max(r0, r1) > min(dims[o1], dims[o2]):
948
+ raise ValueError("taper radius exceeds the face half-width")
949
+ c1 = self.lo[o1] if sa == "min" else self.hi[o1]
950
+ c2 = self.lo[o2] if sb == "min" else self.hi[o2]
951
+ vset = {tuple(v) for v in (
952
+ [self.lo[a] if k == a else (c1 if k == o1 else c2)
953
+ for k in range(3)],
954
+ [self.hi[a] if k == a else (c1 if k == o1 else c2)
955
+ for k in range(3)])}
956
+ verts.append(frozenset(vset))
957
+ for i in range(len(verts)):
958
+ for j in range(i + 1, len(verts)):
959
+ if verts[i] & verts[j]:
960
+ raise ValueError(
961
+ "adjacent taper-filleted edges need a variable "
962
+ "corner patch (arrives at K5.3)")
963
+
964
+ def volume(self) -> PiVal:
965
+ vbox = F(1)
966
+ for c in range(3):
967
+ vbox *= self.hi[c] - self.lo[c]
968
+ rat, pic = vbox, F(0)
969
+ axes = {"x": 0, "y": 1, "z": 2}
970
+ for ax, _, _, r0, r1 in self.edges:
971
+ a = axes[ax]
972
+ L = self.hi[a] - self.lo[a]
973
+ X = L * (r0 * r0 + r0 * r1 + r1 * r1) / 3
974
+ rat -= X
975
+ pic += X / 4
976
+ return PiVal(rat, pic)
977
+
978
+ def bbox(self):
979
+ return self.lo, self.hi