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/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
|