forgekernel 0.9.1__tar.gz → 0.9.4__tar.gz
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-0.9.1/src/forgekernel.egg-info → forgekernel-0.9.4}/PKG-INFO +1 -1
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/__init__.py +1 -1
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/brep.py +17 -29
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/exact.py +9 -16
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/kernel.py +63 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/quadric.py +206 -14
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/surd.py +54 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/SOURCES.txt +2 -0
- forgekernel-0.9.4/tests/test_boss.py +134 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k1_exact.py +18 -3
- forgekernel-0.9.4/tests/test_rotate.py +87 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/LICENSE +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/README.md +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/pyproject.toml +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/setup.cfg +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/hlr.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/io.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/mesh2d.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/tess.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_hlr.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_step.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_scaffold.py +0 -0
|
@@ -188,25 +188,21 @@ class Solid:
|
|
|
188
188
|
moment) and closure requires the SIGNED interval coverage on every
|
|
189
189
|
line to cancel exactly. Zero everywhere == closed surface."""
|
|
190
190
|
from collections import defaultdict
|
|
191
|
-
from math import gcd as _gcd
|
|
192
191
|
|
|
193
192
|
def canon_dir(d: Vec) -> Vec | None:
|
|
194
|
-
|
|
193
|
+
# Canonical (scale- and sign-invariant) representative of a
|
|
194
|
+
# direction: divide through by the first nonzero component so any
|
|
195
|
+
# scalar multiple ±λ·d maps to the same tuple. Works whether the
|
|
196
|
+
# components are rational (ℚ) or quadratic surds (ℚ[√d]) — an
|
|
197
|
+
# exactly-rotated solid carries √d edge directions.
|
|
198
|
+
lead = None
|
|
195
199
|
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
200
|
if v != 0:
|
|
206
|
-
|
|
207
|
-
ints = [-w for w in ints]
|
|
201
|
+
lead = v
|
|
208
202
|
break
|
|
209
|
-
|
|
203
|
+
if lead is None:
|
|
204
|
+
return None
|
|
205
|
+
return tuple(v / lead for v in d)
|
|
210
206
|
|
|
211
207
|
lines: dict = defaultdict(list)
|
|
212
208
|
for p in self.polys:
|
|
@@ -298,24 +294,16 @@ def _ear_clip(loop: list[tuple]) -> list[tuple]:
|
|
|
298
294
|
|
|
299
295
|
|
|
300
296
|
def _canon_dir(d: Vec):
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
297
|
+
# Scale- and sign-invariant direction key: divide by the first nonzero
|
|
298
|
+
# component. Rational (ℚ) or quadratic-surd (ℚ[√d], from exact rotation).
|
|
299
|
+
lead = None
|
|
304
300
|
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
301
|
if v != 0:
|
|
315
|
-
|
|
316
|
-
ints = [-w for w in ints]
|
|
302
|
+
lead = v
|
|
317
303
|
break
|
|
318
|
-
|
|
304
|
+
if lead is None:
|
|
305
|
+
return None
|
|
306
|
+
return tuple(v / lead for v in d)
|
|
319
307
|
|
|
320
308
|
|
|
321
309
|
def logical_edges(solid: Solid) -> list[dict]:
|
|
@@ -87,24 +87,17 @@ class Plane:
|
|
|
87
87
|
return Plane(neg(self.n), -self.d)
|
|
88
88
|
|
|
89
89
|
def canonical(self) -> tuple:
|
|
90
|
-
"""Hashable canonical form: normal
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
g = 0
|
|
98
|
-
for v in ints:
|
|
99
|
-
g = gcd(g, abs(v))
|
|
100
|
-
if g:
|
|
101
|
-
ints = [v // g for v in ints]
|
|
102
|
-
for v in ints[:3]:
|
|
90
|
+
"""Hashable canonical form: (normal, d) divided through by the normal's
|
|
91
|
+
first nonzero component, so any scalar multiple ±λ·(n, d) maps to the
|
|
92
|
+
same tuple — coplanarity becomes tuple equality. Works over ℚ and over
|
|
93
|
+
ℚ[√d] (an exactly-rotated solid carries surd face normals)."""
|
|
94
|
+
nums = (self.n[0], self.n[1], self.n[2], self.d)
|
|
95
|
+
lead = None
|
|
96
|
+
for v in nums[:3]:
|
|
103
97
|
if v != 0:
|
|
104
|
-
|
|
105
|
-
ints = [-w for w in ints]
|
|
98
|
+
lead = v
|
|
106
99
|
break
|
|
107
|
-
return tuple(
|
|
100
|
+
return tuple(v / lead for v in nums)
|
|
108
101
|
|
|
109
102
|
def coplanar_key(self) -> tuple:
|
|
110
103
|
"""Canonical form ignoring orientation (for adjacency grouping)."""
|
|
@@ -51,6 +51,69 @@ def rotate_quarter(s: Solid, axis: str, quarters: int) -> Solid:
|
|
|
51
51
|
return s.rotated_quarter(axis, quarters)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _cos_sin_deg(deg):
|
|
55
|
+
"""Exact (cos, sin) of an angle in degrees, in ℚ[√d] — or None if the angle
|
|
56
|
+
needs a larger field. Covers the multiples of 30° and 45° (so circular
|
|
57
|
+
patterns of 3/4/6/8/12 and the diagonal sketch-plane rotations are exact)."""
|
|
58
|
+
from forgekernel.surd import SurdVal
|
|
59
|
+
|
|
60
|
+
from fractions import Fraction
|
|
61
|
+
|
|
62
|
+
d = int(deg) % 360
|
|
63
|
+
if deg != int(deg):
|
|
64
|
+
return None
|
|
65
|
+
if d % 90 == 0:
|
|
66
|
+
return [(F(1), F(0)), (F(0), F(1)), (F(-1), F(0)), (F(0), F(-1))][d // 90]
|
|
67
|
+
h = Fraction(1, 2)
|
|
68
|
+
r3, r2 = SurdVal(0, h, 3), SurdVal(0, h, 2) # √3/2, √2/2
|
|
69
|
+
table = {30: (r3, h), 60: (h, r3), 120: (-h, r3), 150: (-r3, h),
|
|
70
|
+
210: (-r3, -h), 240: (-h, -r3), 300: (h, -r3), 330: (r3, -h),
|
|
71
|
+
45: (r2, r2), 135: (-r2, r2), 225: (-r2, -r2), 315: (r2, -r2)}
|
|
72
|
+
return table.get(d)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rotation_matrix(axis, deg):
|
|
76
|
+
"""Exact Rodrigues rotation matrix (list of 3 rows) about ``axis`` by
|
|
77
|
+
``deg``, over ℚ[√d]. Raises ValueError if the angle is not representable."""
|
|
78
|
+
from forgekernel.surd import SurdVal, sqrt_rational
|
|
79
|
+
|
|
80
|
+
cs = _cos_sin_deg(deg)
|
|
81
|
+
if cs is None:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"rotation by {deg}° is not exactly representable in ℚ[√d] "
|
|
84
|
+
"(only multiples of 30° and 45°)")
|
|
85
|
+
c, s = cs
|
|
86
|
+
ax, ay, az = F(axis[0]), F(axis[1]), F(axis[2])
|
|
87
|
+
n2 = ax * ax + ay * ay + az * az # |axis|², rational
|
|
88
|
+
if n2 == 0:
|
|
89
|
+
raise ValueError("zero rotation axis")
|
|
90
|
+
one = SurdVal(1, 0, 1)
|
|
91
|
+
s_over_n = (s if isinstance(s, SurdVal) else SurdVal(s)) / sqrt_rational(n2)
|
|
92
|
+
k2c = (one - (c if isinstance(c, SurdVal) else SurdVal(c))) / n2
|
|
93
|
+
k = [[F(0), -az, ay], [az, F(0), -ax], [-ay, ax, F(0)]] # [axis]×
|
|
94
|
+
k2 = [[sum(k[i][t] * k[t][j] for t in range(3)) for j in range(3)]
|
|
95
|
+
for i in range(3)]
|
|
96
|
+
return [[(F(1) if i == j else F(0)) + s_over_n * k[i][j] + k2c * k2[i][j]
|
|
97
|
+
for j in range(3)] for i in range(3)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rotate(s: Solid, axis, deg) -> Solid:
|
|
101
|
+
"""Exact rotation of a planar solid about an arbitrary axis by an angle in
|
|
102
|
+
ℚ[√d] (multiples of 30°/45°). Rigid motion — volume is preserved exactly and
|
|
103
|
+
the result is a Solid with exact ℚ[√d] coordinates. Raises ValueError for
|
|
104
|
+
angles a larger field would be needed for (the seam turns that into an
|
|
105
|
+
honest refusal)."""
|
|
106
|
+
r = _rotation_matrix(axis, deg)
|
|
107
|
+
|
|
108
|
+
def fn(v):
|
|
109
|
+
x, y, z = v
|
|
110
|
+
return (r[0][0] * x + r[0][1] * y + r[0][2] * z,
|
|
111
|
+
r[1][0] * x + r[1][1] * y + r[1][2] * z,
|
|
112
|
+
r[2][0] * x + r[2][1] * y + r[2][2] * z)
|
|
113
|
+
|
|
114
|
+
return s.mapped(fn)
|
|
115
|
+
|
|
116
|
+
|
|
54
117
|
def chamfer(s: Solid, distance) -> Solid:
|
|
55
118
|
"""Exact chamfer on all convex rational-normal edges, with industrial
|
|
56
119
|
corner-triangle vertex truncation (oracle-matched semantics)."""
|
|
@@ -93,6 +93,35 @@ class Cyl:
|
|
|
93
93
|
return lathe(profile, deflection, float(self.cx), float(self.cy))
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _xy_inside_footprint(solid: Solid, px, py) -> bool:
|
|
97
|
+
"""Exact: is (px, py) inside the solid's xy footprint?
|
|
98
|
+
|
|
99
|
+
The footprint boundary is the xy projection of the lateral (non-horizontal)
|
|
100
|
+
faces. Ray parity along +x with the half-open upward-crossing rule — all
|
|
101
|
+
rational, no tolerance. Needed because "the bore does not CROSS a lateral
|
|
102
|
+
wall" does not distinguish a bore strictly inside from one entirely
|
|
103
|
+
outside: neither crosses anything."""
|
|
104
|
+
segs = set()
|
|
105
|
+
for p in solid.polys:
|
|
106
|
+
n = p.plane.n
|
|
107
|
+
if n[0] == 0 and n[1] == 0:
|
|
108
|
+
continue # horizontal face: no footprint edge
|
|
109
|
+
m = len(p.verts)
|
|
110
|
+
for i in range(m):
|
|
111
|
+
a, b = p.verts[i], p.verts[(i + 1) % m]
|
|
112
|
+
if a[0] == b[0] and a[1] == b[1]:
|
|
113
|
+
continue # vertical edge: projects to a point
|
|
114
|
+
key = ((a[0], a[1]), (b[0], b[1]))
|
|
115
|
+
segs.add(key if key[0] <= key[1] else (key[1], key[0]))
|
|
116
|
+
inside = False
|
|
117
|
+
for (x1, y1), (x2, y2) in segs:
|
|
118
|
+
if (y1 > py) != (y2 > py):
|
|
119
|
+
xc = x1 + (py - y1) * (x2 - x1) / (y2 - y1)
|
|
120
|
+
if px < xc:
|
|
121
|
+
inside = not inside
|
|
122
|
+
return inside
|
|
123
|
+
|
|
124
|
+
|
|
96
125
|
def _dist2_point_seg(px, py, ax, ay, bx, by) -> Fraction:
|
|
97
126
|
"""Exact squared distance from point to segment (all rational)."""
|
|
98
127
|
dx, dy = bx - ax, by - ay
|
|
@@ -140,6 +169,12 @@ class DrilledSolid:
|
|
|
140
169
|
raise ValueError(
|
|
141
170
|
"bore crosses a lateral wall — general quadric "
|
|
142
171
|
"booleans arrive at K2.1")
|
|
172
|
+
# The wall check above proves the bore straddles no wall; it does NOT
|
|
173
|
+
# say which side it is on. Without this, a bore placed entirely outside
|
|
174
|
+
# the footprint is recorded as a phantom hole and its volume silently
|
|
175
|
+
# subtracted from a solid it never touches.
|
|
176
|
+
if not _xy_inside_footprint(self.base, c.cx, c.cy):
|
|
177
|
+
raise ValueError("bore misses the solid in xy (nothing to drill)")
|
|
143
178
|
for o in self.bores:
|
|
144
179
|
if o.cx == c.cx and o.cy == c.cy:
|
|
145
180
|
continue # coaxial stack (counterbore)
|
|
@@ -616,17 +651,23 @@ class AxisStack:
|
|
|
616
651
|
|
|
617
652
|
class RevolveSolid:
|
|
618
653
|
"""A closed line-segment profile in the (r, z) half-plane revolved
|
|
619
|
-
360 degrees about
|
|
620
|
-
V = pi * contour_integral(r^2 dz), each edge contributing
|
|
621
|
-
(z2-z1)(r1^2 + r1 r2 + r2^2)/3.
|
|
654
|
+
360 degrees about a z-parallel axis through ``(cx, cy)``. Green gives
|
|
655
|
+
exact metrics: V = pi * contour_integral(r^2 dz), each edge contributing
|
|
656
|
+
(z2-z1)(r1^2 + r1 r2 + r2^2)/3. The profile need not touch r = 0 — an
|
|
657
|
+
annular loop is a tube (a bored cylinder), still exact."""
|
|
622
658
|
|
|
623
|
-
def __init__(self, loop_rz: list) -> None:
|
|
659
|
+
def __init__(self, loop_rz: list, cx=0, cy=0) -> None:
|
|
624
660
|
self.loop = [(F(r), F(z)) for r, z in loop_rz]
|
|
661
|
+
self.cx, self.cy = F(cx), F(cy)
|
|
625
662
|
if any(r < 0 for r, _ in self.loop):
|
|
626
663
|
raise ValueError("revolve profile must stay at r >= 0")
|
|
627
664
|
if self._v3() < 0:
|
|
628
665
|
self.loop = list(reversed(self.loop))
|
|
629
666
|
|
|
667
|
+
def translated(self, x, y, z) -> "RevolveSolid":
|
|
668
|
+
return RevolveSolid([(r, zz + F(z)) for r, zz in self.loop],
|
|
669
|
+
self.cx + F(x), self.cy + F(y))
|
|
670
|
+
|
|
630
671
|
def _edges(self):
|
|
631
672
|
n = len(self.loop)
|
|
632
673
|
for i in range(n):
|
|
@@ -648,18 +689,53 @@ class RevolveSolid:
|
|
|
648
689
|
num += dz * (z1 * (3 * r1 * r1 + 2 * r1 * r2 + r2 * r2)
|
|
649
690
|
+ z2 * (r1 * r1 + 2 * r1 * r2 + 3 * r2 * r2)) / 12
|
|
650
691
|
v3 = self._v3()
|
|
651
|
-
return (
|
|
692
|
+
return (float(self.cx), float(self.cy),
|
|
693
|
+
float(num / v3 * 3) if v3 else 0.0)
|
|
652
694
|
|
|
653
695
|
def bbox(self):
|
|
654
696
|
rmax = max(r for r, _ in self.loop)
|
|
655
697
|
zs = [z for _, z in self.loop]
|
|
656
|
-
|
|
657
|
-
|
|
698
|
+
cx, cy = float(self.cx), float(self.cy)
|
|
699
|
+
return ((cx - float(rmax), cy - float(rmax), float(min(zs))),
|
|
700
|
+
(cx + float(rmax), cy + float(rmax), float(max(zs))))
|
|
658
701
|
|
|
659
702
|
def tessellate(self, deflection: float = 0.2) -> dict:
|
|
660
703
|
from forgekernel.tess import lathe
|
|
661
704
|
|
|
662
|
-
return lathe([(float(r), float(z)) for r, z in self.loop], deflection
|
|
705
|
+
return lathe([(float(r), float(z)) for r, z in self.loop], deflection,
|
|
706
|
+
float(self.cx), float(self.cy))
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def bore_cyl(cyl: "Cyl", bore: "Cyl") -> object:
|
|
710
|
+
"""Cut a COAXIAL bore out of a z-cylinder, exactly.
|
|
711
|
+
|
|
712
|
+
A cylinder minus a coaxial cylindrical bore is still a solid of
|
|
713
|
+
revolution, so the result is a ``RevolveSolid`` whose (r, z) profile is
|
|
714
|
+
the cylinder's rectangle minus the bore's — exact in ℚ, with the volume
|
|
715
|
+
following in ℚ[π] from Green's theorem. Returns ``cyl`` unchanged when
|
|
716
|
+
the bore misses it in z. Raises ValueError for the cases that are not a
|
|
717
|
+
single revolved region (a bore floating strictly inside — a closed
|
|
718
|
+
cavity — or one that consumes the whole cylinder)."""
|
|
719
|
+
if bore.cx != cyl.cx or bore.cy != cyl.cy:
|
|
720
|
+
raise ValueError("bore is not coaxial with the cylinder — K2.3")
|
|
721
|
+
if bore.r >= cyl.r:
|
|
722
|
+
raise ValueError("bore is not narrower than the cylinder — K2.3")
|
|
723
|
+
z0, z1 = cyl.z0, cyl.z1
|
|
724
|
+
b0, b1 = max(bore.z0, z0), min(bore.z1, z1)
|
|
725
|
+
if b1 <= b0:
|
|
726
|
+
return cyl # bore misses in z: unchanged
|
|
727
|
+
R, rb = cyl.r, bore.r
|
|
728
|
+
if b0 <= z0 and b1 >= z1: # through bore -> annulus
|
|
729
|
+
loop = [(rb, z0), (R, z0), (R, z1), (rb, z1)]
|
|
730
|
+
elif b0 <= z0: # blind from the bottom
|
|
731
|
+
loop = [(rb, z0), (R, z0), (R, z1), (0, z1), (0, b1), (rb, b1)]
|
|
732
|
+
elif b1 >= z1: # blind from the top
|
|
733
|
+
loop = [(0, z0), (R, z0), (R, z1), (rb, z1), (rb, b0), (0, b0)]
|
|
734
|
+
else:
|
|
735
|
+
raise ValueError(
|
|
736
|
+
"bore floats strictly inside the cylinder (a closed cavity) — "
|
|
737
|
+
"general quadric booleans arrive at K2.3")
|
|
738
|
+
return RevolveSolid(loop, cyl.cx, cyl.cy)
|
|
663
739
|
|
|
664
740
|
|
|
665
741
|
def _member_volume(m):
|
|
@@ -696,6 +772,59 @@ class DisjointUnion:
|
|
|
696
772
|
_classify_pair(m, other)
|
|
697
773
|
return DisjointUnion(self.members + [other])
|
|
698
774
|
|
|
775
|
+
def tessellate(self, deflection: float = 0.2) -> dict:
|
|
776
|
+
"""Display mesh = the members' meshes merged. Valid as one mesh
|
|
777
|
+
because the members are disjoint (at most tangent), so the shells
|
|
778
|
+
never interpenetrate."""
|
|
779
|
+
verts: list = []
|
|
780
|
+
tris: list = []
|
|
781
|
+
for m in self.members:
|
|
782
|
+
if not hasattr(m, "tessellate"): # bare Sphere/Cone: via a stack
|
|
783
|
+
m = AxisStack(m.cx, m.cy, [m])
|
|
784
|
+
try:
|
|
785
|
+
sub = m.tessellate(deflection)
|
|
786
|
+
except TypeError: # planar Solid: meshes exactly
|
|
787
|
+
sub = m.tessellate()
|
|
788
|
+
off = len(verts)
|
|
789
|
+
verts.extend(sub["vertices"])
|
|
790
|
+
tris.extend([a + off, b + off, c + off] for a, b, c in sub["triangles"])
|
|
791
|
+
return {"vertices": verts, "triangles": tris}
|
|
792
|
+
|
|
793
|
+
@classmethod
|
|
794
|
+
def _unchecked(cls, members: list) -> "DisjointUnion":
|
|
795
|
+
"""Build without re-running the pairwise predicates — for members
|
|
796
|
+
derived from an already-validated union by an operation that can only
|
|
797
|
+
SHRINK them (see ``cut``)."""
|
|
798
|
+
u = cls.__new__(cls)
|
|
799
|
+
u.members = list(members)
|
|
800
|
+
return u
|
|
801
|
+
|
|
802
|
+
def cut(self, tool) -> "DisjointUnion":
|
|
803
|
+
"""Subtract ``tool`` from every member: (A ∪ B) ∖ C = (A∖C) ∪ (B∖C).
|
|
804
|
+
Members that the tool misses come back unchanged. Disjointness is
|
|
805
|
+
preserved because cutting only shrinks a member — (A∖C) ∩ (B∖C) ⊆
|
|
806
|
+
A ∩ B, already proven measure-zero — so the result needs no
|
|
807
|
+
re-validation (and its members may now be other exact types)."""
|
|
808
|
+
from forgekernel.brep import Solid
|
|
809
|
+
|
|
810
|
+
out = []
|
|
811
|
+
for m in self.members:
|
|
812
|
+
if isinstance(m, Cyl) and isinstance(tool, Cyl):
|
|
813
|
+
out.append(bore_cyl(m, tool))
|
|
814
|
+
elif isinstance(m, (Solid, DrilledSolid)) and isinstance(tool, Cyl):
|
|
815
|
+
base = DrilledSolid(m, []) if isinstance(m, Solid) else m
|
|
816
|
+
try:
|
|
817
|
+
out.append(base.cut(tool))
|
|
818
|
+
except ValueError as exc:
|
|
819
|
+
if "misses the solid in" not in str(exc):
|
|
820
|
+
raise # a real precondition violation
|
|
821
|
+
out.append(m) # tool misses this member (z or xy)
|
|
822
|
+
else:
|
|
823
|
+
raise ValueError(
|
|
824
|
+
f"cut of {type(m).__name__} by {type(tool).__name__} in a "
|
|
825
|
+
"disjoint union arrives at K2.3")
|
|
826
|
+
return DisjointUnion._unchecked(out)
|
|
827
|
+
|
|
699
828
|
def volume(self) -> "PiVal":
|
|
700
829
|
total = PiVal(0, 0)
|
|
701
830
|
for m in self.members:
|
|
@@ -748,10 +877,12 @@ def _classify_pair(a, b) -> None:
|
|
|
748
877
|
if za[1] <= zb[0] or zb[1] <= za[0]:
|
|
749
878
|
return # disjoint in z
|
|
750
879
|
d2 = (a.cx - b.cx) ** 2 + (a.cy - b.cy) ** 2
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
880
|
+
if d2 >= (a.r + b.r) ** 2:
|
|
881
|
+
return # externally clear (or tangent)
|
|
882
|
+
# NOTE: d2 <= (ra−rb)² is CONTAINMENT, not separation — these are solid
|
|
883
|
+
# cylinders, not tubes, so a nested one overlaps with positive measure
|
|
884
|
+
# (identical cylinders land here too, at d2 = 0). Treating it as clear
|
|
885
|
+
# silently doubled the volume; it must refuse.
|
|
755
886
|
raise ValueError(
|
|
756
887
|
"overlapping cylinders — general quadric booleans arrive at K2.3")
|
|
757
888
|
# sphere / planar Solid
|
|
@@ -761,11 +892,20 @@ def _classify_pair(a, b) -> None:
|
|
|
761
892
|
if isinstance(b, Sphere) and isinstance(a, Solid):
|
|
762
893
|
_sphere_solid(b, a)
|
|
763
894
|
return
|
|
895
|
+
# z-cylinder / planar Solid (a mounting boss standing on a face)
|
|
896
|
+
if isinstance(a, Cyl) and isinstance(b, Solid):
|
|
897
|
+
_cyl_solid(a, b)
|
|
898
|
+
return
|
|
899
|
+
if isinstance(b, Cyl) and isinstance(a, Solid):
|
|
900
|
+
_cyl_solid(b, a)
|
|
901
|
+
return
|
|
764
902
|
# sphere / sphere
|
|
765
903
|
if isinstance(a, Sphere) and isinstance(b, Sphere):
|
|
766
904
|
d2 = (a.cx - b.cx) ** 2 + (a.cy - b.cy) ** 2 + (a.cz - b.cz) ** 2
|
|
767
|
-
if d2 >= (a.r + b.r) ** 2
|
|
768
|
-
return
|
|
905
|
+
if d2 >= (a.r + b.r) ** 2:
|
|
906
|
+
return # externally clear (or tangent)
|
|
907
|
+
# containment (d2 ≤ (ra−rb)²) is an overlap, not a separation — see the
|
|
908
|
+
# cylinder note above; SphereOverlap already rejects it as "nesting".
|
|
769
909
|
raise ValueError(
|
|
770
910
|
"overlapping spheres — general quadric booleans arrive at K2.3")
|
|
771
911
|
raise ValueError(
|
|
@@ -773,6 +913,28 @@ def _classify_pair(a, b) -> None:
|
|
|
773
913
|
"at K2.3")
|
|
774
914
|
|
|
775
915
|
|
|
916
|
+
def _require_convex(solid: "Solid", what: str) -> None:
|
|
917
|
+
"""Raise unless every vertex is on the inward side of every face plane.
|
|
918
|
+
|
|
919
|
+
The separating-plane predicates below are sound ONLY for a convex solid: a
|
|
920
|
+
convex body is the intersection of its face half-spaces, so "outside one
|
|
921
|
+
face plane" implies "outside the solid". For a non-convex solid that
|
|
922
|
+
implication fails — a bore sitting inside an L-bracket's arm is outside the
|
|
923
|
+
plane of the other arm's face — and accepting it would silently double-count
|
|
924
|
+
the overlap. Exact (rational comparisons); refuses rather than guesses."""
|
|
925
|
+
planes, verts = {}, set()
|
|
926
|
+
for p in solid.polys:
|
|
927
|
+
planes.setdefault(p.plane.canonical(), (p.plane.n, p.plane.d))
|
|
928
|
+
verts.update(p.verts)
|
|
929
|
+
for n, dpl in planes.values():
|
|
930
|
+
for v in verts:
|
|
931
|
+
if dot(n, v) - dpl > 0:
|
|
932
|
+
raise ValueError(
|
|
933
|
+
f"{what} against a non-convex solid — the separating-plane "
|
|
934
|
+
"test is not sound there; general quadric booleans arrive "
|
|
935
|
+
"at K2.3")
|
|
936
|
+
|
|
937
|
+
|
|
776
938
|
def _sphere_solid(s: "Sphere", solid: "Solid") -> None:
|
|
777
939
|
"""Disjoint/tangent iff the sphere center lies on the far side of (or
|
|
778
940
|
exactly on) some face plane by at least the radius — exact, sqrt-free:
|
|
@@ -780,6 +942,7 @@ def _sphere_solid(s: "Sphere", solid: "Solid") -> None:
|
|
|
780
942
|
g² ≥ r²·(n·n) (both sides squared, exact). Convex-solid sufficient
|
|
781
943
|
condition; a sphere separated from a convex solid is separated by one
|
|
782
944
|
of its face planes."""
|
|
945
|
+
_require_convex(solid, "sphere")
|
|
783
946
|
c = (s.cx, s.cy, s.cz)
|
|
784
947
|
seen = set()
|
|
785
948
|
for p in solid.polys:
|
|
@@ -795,6 +958,35 @@ def _sphere_solid(s: "Sphere", solid: "Solid") -> None:
|
|
|
795
958
|
"sphere overlaps the solid — general quadric booleans arrive at K2.3")
|
|
796
959
|
|
|
797
960
|
|
|
961
|
+
def _cyl_solid(c: "Cyl", solid: "Solid") -> None:
|
|
962
|
+
"""Disjoint/tangent iff some face plane of the solid separates the
|
|
963
|
+
z-cylinder — exact and sqrt-free. For outward plane n·x = d, the
|
|
964
|
+
cylinder's minimum of n·x is
|
|
965
|
+
|
|
966
|
+
n_x·cx + n_y·cy + min(n_z·z0, n_z·z1) − r·√(n_x² + n_y²),
|
|
967
|
+
|
|
968
|
+
so the cylinder is clear of that plane iff A ≥ r·√(n_x²+n_y²) where
|
|
969
|
+
A = n_x·cx + n_y·cy + min(n_z·z0, n_z·z1) − d. Both sides are squared
|
|
970
|
+
(A ≥ 0 and A² ≥ r²·(n_x²+n_y²)) to stay in ℚ. A mounting boss standing
|
|
971
|
+
ON a face gives A = 0 exactly — tangent, measure-zero, admitted.
|
|
972
|
+
Sufficient for a convex solid (separating-plane argument); for a
|
|
973
|
+
non-convex one it is conservative, so it refuses rather than guesses."""
|
|
974
|
+
_require_convex(solid, "cylinder")
|
|
975
|
+
seen = set()
|
|
976
|
+
for p in solid.polys:
|
|
977
|
+
key = p.plane.canonical()
|
|
978
|
+
if key in seen:
|
|
979
|
+
continue
|
|
980
|
+
seen.add(key)
|
|
981
|
+
n, dpl = p.plane.n, p.plane.d
|
|
982
|
+
a = (n[0] * c.cx + n[1] * c.cy
|
|
983
|
+
+ min(n[2] * c.z0, n[2] * c.z1) - dpl)
|
|
984
|
+
if a >= 0 and a * a >= c.r * c.r * (n[0] * n[0] + n[1] * n[1]):
|
|
985
|
+
return # separated by this face plane
|
|
986
|
+
raise ValueError(
|
|
987
|
+
"cylinder overlaps the solid — general quadric booleans arrive at K2.3")
|
|
988
|
+
|
|
989
|
+
|
|
798
990
|
class RoundedBox:
|
|
799
991
|
"""An axis-aligned box with ALL edges and corners filleted radius r —
|
|
800
992
|
the Minkowski sum of the inner core box (a-2r)x(b-2r)x(c-2r) with a
|
|
@@ -76,6 +76,9 @@ class SurdVal:
|
|
|
76
76
|
o = self._co(o)
|
|
77
77
|
return SurdVal(self.a - o.a, self.b - o.b, self._radical(o))
|
|
78
78
|
|
|
79
|
+
def __rsub__(self, o) -> "SurdVal": # o − self, for rational/int on the left
|
|
80
|
+
return self._co(o) - self
|
|
81
|
+
|
|
79
82
|
def __mul__(self, o) -> "SurdVal":
|
|
80
83
|
o = self._co(o)
|
|
81
84
|
if self.b == 0 or o.b == 0:
|
|
@@ -89,12 +92,63 @@ class SurdVal:
|
|
|
89
92
|
|
|
90
93
|
__rmul__ = __mul__
|
|
91
94
|
|
|
95
|
+
def __neg__(self) -> "SurdVal":
|
|
96
|
+
return SurdVal(-self.a, -self.b, self.d)
|
|
97
|
+
|
|
98
|
+
def __truediv__(self, o) -> "SurdVal":
|
|
99
|
+
if isinstance(o, (int, Fraction)):
|
|
100
|
+
r = F(o)
|
|
101
|
+
return SurdVal(self.a / r, self.b / r, self.d)
|
|
102
|
+
o = self._co(o)
|
|
103
|
+
if o.b == 0: # divide by a rational
|
|
104
|
+
return SurdVal(self.a / o.a, self.b / o.a, self.d)
|
|
105
|
+
# divide by (c+e√d) via the conjugate: ·(c−e√d)/(c²−e²d)
|
|
106
|
+
denom = o.a * o.a - o.b * o.b * o.d # rational, nonzero
|
|
107
|
+
num = self * SurdVal(o.a, -o.b, o.d)
|
|
108
|
+
return SurdVal(num.a / denom, num.b / denom, num.d)
|
|
109
|
+
|
|
110
|
+
def __rtruediv__(self, o) -> "SurdVal":
|
|
111
|
+
return self._co(o) / self
|
|
112
|
+
|
|
113
|
+
def _sign(self) -> int:
|
|
114
|
+
"""Exact sign of a + b√d (d ≥ 1, √d > 0) — decides comparisons."""
|
|
115
|
+
if self.b == 0:
|
|
116
|
+
return (self.a > 0) - (self.a < 0)
|
|
117
|
+
if self.a == 0:
|
|
118
|
+
return (self.b > 0) - (self.b < 0)
|
|
119
|
+
sa = (self.a > 0) - (self.a < 0)
|
|
120
|
+
sb = (self.b > 0) - (self.b < 0)
|
|
121
|
+
if sa == sb: # both terms same sign
|
|
122
|
+
return sa
|
|
123
|
+
# opposite signs: compare magnitudes a² vs b²·d (squaring is monotone)
|
|
124
|
+
da, db = self.a * self.a, self.b * self.b * self.d
|
|
125
|
+
if da == db:
|
|
126
|
+
return 0
|
|
127
|
+
return sa if da > db else sb
|
|
128
|
+
|
|
129
|
+
def __lt__(self, o) -> bool:
|
|
130
|
+
return (self - self._co(o))._sign() < 0
|
|
131
|
+
|
|
132
|
+
def __le__(self, o) -> bool:
|
|
133
|
+
return (self - self._co(o))._sign() <= 0
|
|
134
|
+
|
|
135
|
+
def __gt__(self, o) -> bool:
|
|
136
|
+
return (self - self._co(o))._sign() > 0
|
|
137
|
+
|
|
138
|
+
def __ge__(self, o) -> bool:
|
|
139
|
+
return (self - self._co(o))._sign() >= 0
|
|
140
|
+
|
|
92
141
|
def __eq__(self, o: object) -> bool:
|
|
93
142
|
o = self._co(o)
|
|
94
143
|
if self.b == 0 and o.b == 0:
|
|
95
144
|
return self.a == o.a
|
|
96
145
|
return self.a == o.a and self.b == o.b and self.d == o.d
|
|
97
146
|
|
|
147
|
+
def __hash__(self) -> int:
|
|
148
|
+
if self.b == 0: # a pure rational hashes as itself
|
|
149
|
+
return hash(self.a)
|
|
150
|
+
return hash((self.a, self.b, self.d))
|
|
151
|
+
|
|
98
152
|
def __float__(self) -> float:
|
|
99
153
|
return float(self.a) + float(self.b) * math.sqrt(self.d)
|
|
100
154
|
|
|
@@ -27,6 +27,7 @@ src/forgekernel.egg-info/SOURCES.txt
|
|
|
27
27
|
src/forgekernel.egg-info/dependency_links.txt
|
|
28
28
|
src/forgekernel.egg-info/requires.txt
|
|
29
29
|
src/forgekernel.egg-info/top_level.txt
|
|
30
|
+
tests/test_boss.py
|
|
30
31
|
tests/test_hlr.py
|
|
31
32
|
tests/test_k1_exact.py
|
|
32
33
|
tests/test_k37_loft.py
|
|
@@ -39,5 +40,6 @@ tests/test_k7_bsolid.py
|
|
|
39
40
|
tests/test_k7_stepexport.py
|
|
40
41
|
tests/test_k7_trim.py
|
|
41
42
|
tests/test_k7_trimflux.py
|
|
43
|
+
tests/test_rotate.py
|
|
42
44
|
tests/test_rust_oracle.py
|
|
43
45
|
tests/test_scaffold.py
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Bosses and bored cylinders — a z-cylinder standing on a planar face
|
|
2
|
+
(tangent, measure-zero) and coaxial bores through solids of revolution.
|
|
3
|
+
|
|
4
|
+
A cylinder minus a coaxial bore is still a solid of revolution, so the result
|
|
5
|
+
stays an exact ℚ[π] object rather than degrading to a mesh.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from forgekernel.brep import Solid
|
|
16
|
+
from forgekernel.quadric import Cyl, DisjointUnion, RevolveSolid, bore_cyl
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _nonmanifold(mesh):
|
|
20
|
+
ec = defaultdict(int)
|
|
21
|
+
for a, b, c in mesh["triangles"]:
|
|
22
|
+
for e in ((a, b), (b, c), (c, a)):
|
|
23
|
+
ec[tuple(sorted(e))] += 1
|
|
24
|
+
return sum(1 for n in ec.values() if n != 2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_boss_standing_on_a_plate_is_a_tangent_union() -> None:
|
|
28
|
+
# the cylinder's base sits EXACTLY on the plate's top face: contact is
|
|
29
|
+
# measure-zero, so the union volume is exactly the sum.
|
|
30
|
+
plate = Solid.box(30, 30, 3)
|
|
31
|
+
boss = Cyl(15, 15, 4, 3, 9)
|
|
32
|
+
u = DisjointUnion([plate, boss])
|
|
33
|
+
assert float(u.volume()) == pytest.approx(30 * 30 * 3 + math.pi * 16 * 6)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_boss_sunk_into_the_plate_is_refused() -> None:
|
|
37
|
+
plate = Solid.box(30, 30, 3)
|
|
38
|
+
with pytest.raises(ValueError, match="overlaps the solid"):
|
|
39
|
+
DisjointUnion([plate, Cyl(15, 15, 4, 2, 9)]) # base below the top face
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cylinder_beside_the_plate_is_separated_by_a_side_face() -> None:
|
|
43
|
+
plate = Solid.box(10, 10, 10)
|
|
44
|
+
DisjointUnion([plate, Cyl(20, 5, 3, 0, 10)]) # clear in x: no raise
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_non_convex_solids_refuse_rather_than_double_count() -> None:
|
|
48
|
+
"""SOUNDNESS: the separating-plane test only holds for a convex solid.
|
|
49
|
+
|
|
50
|
+
In an L-prism, a bore buried in one arm lies outside the plane of the
|
|
51
|
+
OTHER arm's face — so a plane-only test would call it disjoint and silently
|
|
52
|
+
add its volume twice. Both predicates must refuse instead."""
|
|
53
|
+
from forgekernel import kernel as fk
|
|
54
|
+
from forgekernel.quadric import Sphere
|
|
55
|
+
|
|
56
|
+
ell = fk.prism([(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)], 5)
|
|
57
|
+
for buried in (Cyl(2, 7, 1, 1, 4), Sphere(2, 7, 2, 1)):
|
|
58
|
+
with pytest.raises(ValueError, match="non-convex"):
|
|
59
|
+
DisjointUnion([ell, buried])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_bore_outside_the_footprint_is_not_a_phantom_hole() -> None:
|
|
63
|
+
"""SOUNDNESS: "the bore crosses no lateral wall" does not mean "the bore is
|
|
64
|
+
inside" — a bore entirely OUTSIDE the footprint crosses nothing either. It
|
|
65
|
+
used to be recorded as a hole, silently deleting volume from a solid the
|
|
66
|
+
tool never touched (a Ø4 tool 40 mm away removed 125.66 mm³ from a box)."""
|
|
67
|
+
from forgekernel.quadric import DrilledSolid
|
|
68
|
+
|
|
69
|
+
box = Solid.box(10, 10, 10)
|
|
70
|
+
with pytest.raises(ValueError, match="misses the solid in xy"):
|
|
71
|
+
DrilledSolid(box, []).cut(Cyl(50, 50, 2, 0, 10))
|
|
72
|
+
inside = DrilledSolid(box, []).cut(Cyl(5, 5, 2, 0, 10)) # a real hole
|
|
73
|
+
assert float(inside.volume()) == pytest.approx(1000 - math.pi * 4 * 10)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_cut_leaves_members_the_tool_misses_untouched() -> None:
|
|
77
|
+
# a post standing 50 mm clear of a plate; boring the POST must not shrink
|
|
78
|
+
# the plate (the tool never enters it in xy).
|
|
79
|
+
plate, post = Solid.box(100, 100, 10), Cyl(150, 50, 8, 0, 20)
|
|
80
|
+
out = DisjointUnion([plate, post]).cut(Cyl(150, 50, 4, 0, 20))
|
|
81
|
+
assert float(out.volume()) == pytest.approx(100000 + (64 - 16) * 20 * math.pi)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_coaxial_bore_of_a_cylinder_is_an_exact_revolve() -> None:
|
|
85
|
+
cyl = Cyl(0, 0, 10, 0, 2)
|
|
86
|
+
washer = bore_cyl(cyl, Cyl(0, 0, 4, 0, 2)) # through bore: annulus
|
|
87
|
+
assert isinstance(washer, RevolveSolid)
|
|
88
|
+
assert float(washer.volume()) == pytest.approx(math.pi * (100 - 16) * 2)
|
|
89
|
+
mesh = washer.tessellate(0.05)
|
|
90
|
+
assert _nonmanifold(mesh) == 0 # watertight tube
|
|
91
|
+
|
|
92
|
+
blind = bore_cyl(Cyl(0, 0, 4, 3, 9), Cyl(0, 0, 1, 4, 9)) # blind from top
|
|
93
|
+
assert float(blind.volume()) == pytest.approx(math.pi * 16 * 6 - math.pi * 1 * 5)
|
|
94
|
+
|
|
95
|
+
up = bore_cyl(Cyl(0, 0, 4, 0, 6), Cyl(0, 0, 1, -5, 2)) # blind from below
|
|
96
|
+
assert float(up.volume()) == pytest.approx(math.pi * 16 * 6 - math.pi * 1 * 2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_bore_that_misses_in_z_leaves_the_cylinder_unchanged() -> None:
|
|
100
|
+
cyl = Cyl(0, 0, 4, 0, 6)
|
|
101
|
+
assert bore_cyl(cyl, Cyl(0, 0, 1, 10, 12)) is cyl
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_non_representable_bores_refuse() -> None:
|
|
105
|
+
cyl = Cyl(0, 0, 4, 0, 6)
|
|
106
|
+
with pytest.raises(ValueError, match="not coaxial"):
|
|
107
|
+
bore_cyl(cyl, Cyl(1, 0, 1, 0, 6))
|
|
108
|
+
with pytest.raises(ValueError, match="not narrower"):
|
|
109
|
+
bore_cyl(cyl, Cyl(0, 0, 4, 0, 6))
|
|
110
|
+
with pytest.raises(ValueError, match="closed cavity"):
|
|
111
|
+
bore_cyl(cyl, Cyl(0, 0, 1, 2, 4)) # floats strictly inside
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_cut_distributes_over_a_disjoint_union() -> None:
|
|
115
|
+
# the boss + pilot: (plate ∪ boss) ∖ pilot, pilot blind in the boss only
|
|
116
|
+
plate = Solid.box(30, 30, 3)
|
|
117
|
+
u = DisjointUnion([plate, Cyl(15, 15, 4, 3, 9)])
|
|
118
|
+
out = u.cut(Cyl(15, 15, 1, 4, 9))
|
|
119
|
+
expect = 30 * 30 * 3 + math.pi * 16 * 6 - math.pi * 1 * 5
|
|
120
|
+
assert float(out.volume()) == pytest.approx(expect)
|
|
121
|
+
# the plate member is untouched (the tool misses it in z)
|
|
122
|
+
assert any(isinstance(m, Solid) for m in out.members)
|
|
123
|
+
assert _nonmanifold(out.tessellate(0.05)) == 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_revolve_solid_can_sit_off_axis() -> None:
|
|
127
|
+
loop = [(0, 0), (2, 0), (2, 5), (0, 5)] # a plain cylinder r=2
|
|
128
|
+
at_origin, moved = RevolveSolid(loop), RevolveSolid(loop, 7, -3)
|
|
129
|
+
assert float(at_origin.volume()) == float(moved.volume())
|
|
130
|
+
assert moved.centroid_f()[0] == pytest.approx(7.0)
|
|
131
|
+
assert moved.centroid_f()[1] == pytest.approx(-3.0)
|
|
132
|
+
lo, hi = moved.bbox()
|
|
133
|
+
assert (lo[0], hi[0]) == pytest.approx((5.0, 9.0))
|
|
134
|
+
assert (lo[1], hi[1]) == pytest.approx((-5.0, -1.0))
|
|
@@ -270,11 +270,26 @@ def test_disjoint_in_z_passes_despite_close_axes() -> None:
|
|
|
270
270
|
assert u.volume() == PiVal(0, 1000)
|
|
271
271
|
|
|
272
272
|
|
|
273
|
-
def
|
|
273
|
+
def test_internally_tangent_cylinders_are_an_overlap_not_a_union() -> None:
|
|
274
|
+
"""INTERNAL tangency of two SOLID cylinders is containment, not disjointness.
|
|
275
|
+
|
|
276
|
+
r=10 at the origin and r=4 at (6,0): d² = 36 = (10−4)², so they touch at
|
|
277
|
+
(10,0) — but the small cylinder lies wholly INSIDE the big one (its farthest
|
|
278
|
+
point is 6+4 = 10 = the big radius) and their z-ranges coincide. The union is
|
|
279
|
+
therefore just the big cylinder, 800π — NOT 928π. An earlier version of this
|
|
280
|
+
test asserted 928π, and that is what let ``_classify_pair`` admit nested
|
|
281
|
+
cylinders into a DisjointUnion and silently double-count the overlap. Only
|
|
282
|
+
EXTERNAL separation (d² ≥ (ra+rb)²) certifies disjointness.
|
|
283
|
+
"""
|
|
284
|
+
import pytest
|
|
285
|
+
|
|
274
286
|
from forgekernel.quadric import Cyl, DisjointUnion, PiVal
|
|
275
287
|
|
|
276
|
-
|
|
277
|
-
|
|
288
|
+
with pytest.raises(ValueError, match="overlapping cylinders"):
|
|
289
|
+
DisjointUnion([Cyl.make(10, 8), Cyl.make(4, 8).translated(6, 0, 0)])
|
|
290
|
+
|
|
291
|
+
# externally tangent (d² = (ra+rb)²) IS measure-zero contact: exact sum
|
|
292
|
+
u = DisjointUnion([Cyl.make(10, 8), Cyl.make(4, 8).translated(14, 0, 0)])
|
|
278
293
|
assert u.volume() == PiVal(0, (100 + 16) * 8)
|
|
279
294
|
|
|
280
295
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Exact rotation over ℚ[√d] — the K2.2 circular-pattern / diagonal-sketch-plane
|
|
2
|
+
enabler. Rotation is a rigid motion, so volume is preserved EXACTLY (rational),
|
|
3
|
+
and representable angles (multiples of 30°/45°) keep coordinates exact."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from forgekernel import kernel as fk
|
|
12
|
+
from forgekernel.surd import SurdVal, sqrt_rational
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_rotation_preserves_volume_exactly() -> None:
|
|
16
|
+
b = fk.box(10, 4, 3)
|
|
17
|
+
for axis, deg in [((0, 0, 1), 60), ((0, 0, 1), 45), ((1, 0, 0), 30),
|
|
18
|
+
((1, 1, 1), 120)]:
|
|
19
|
+
r = fk.rotate(b, axis, deg)
|
|
20
|
+
assert r.volume() == 120 # exact rational, not a float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_six_sixty_degree_rotations_are_the_identity() -> None:
|
|
24
|
+
b = fk.box(7, 5, 2)
|
|
25
|
+
acc = b
|
|
26
|
+
for _ in range(6):
|
|
27
|
+
acc = fk.rotate(acc, (0, 0, 1), 60)
|
|
28
|
+
orig = {tuple(float(c) for c in v) for p in b.polys for v in p.verts}
|
|
29
|
+
back = {tuple(float(c) for c in v) for p in acc.polys for v in p.verts}
|
|
30
|
+
assert orig == back # 6·60° = 360° back to start, exactly
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_diagonal_120_is_a_rational_axis_permutation() -> None:
|
|
34
|
+
p = fk.rotate(fk.box(3, 5, 7), (1, 1, 1), 120)
|
|
35
|
+
# 120° about the body diagonal cyclically permutes the axes — the surds
|
|
36
|
+
# cancel, leaving purely rational coordinates.
|
|
37
|
+
assert all(not isinstance(c, SurdVal) or c.b == 0
|
|
38
|
+
for poly in p.polys for v in poly.verts for c in v)
|
|
39
|
+
assert p.volume() == 105
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_unrepresentable_angle_is_refused() -> None:
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
fk.rotate(fk.box(2, 2, 2), (0, 0, 1), 37)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_boolean_union_of_rotated_copies_is_watertight_and_exact() -> None:
|
|
48
|
+
# the circular-pattern path: union disjoint rotated copies (surd coords)
|
|
49
|
+
seed = fk.translate(fk.box(5, 2, 3), 10, -1, 0)
|
|
50
|
+
out = seed
|
|
51
|
+
for i in range(1, 6):
|
|
52
|
+
out = fk.boolean("union", out, fk.rotate(seed, (0, 0, 1), 60 * i))
|
|
53
|
+
assert out.volume() == 6 * 5 * 2 * 3 # disjoint -> exact sum, no leaks
|
|
54
|
+
assert out.watertight_violations() == []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_overlapping_boolean_mixes_rational_and_surd_coords() -> None:
|
|
58
|
+
# the case disjoint patterns miss: an axis-aligned (ℚ) body booleaned with
|
|
59
|
+
# an OVERLAPPING rotated (ℚ[√3]) body — BSP clipping makes mixed-type
|
|
60
|
+
# polygons, so Fraction−SurdVal must resolve (needs SurdVal.__rsub__).
|
|
61
|
+
from fractions import Fraction as F
|
|
62
|
+
|
|
63
|
+
a = fk.box(2, 2, 2)
|
|
64
|
+
b = fk.rotate(fk.translate(fk.box(2, 2, 2), F(1, 2), F(1, 2), 0), (0, 0, 1), 30)
|
|
65
|
+
vu = float(fk.boolean("union", a, b).volume())
|
|
66
|
+
vi = float(fk.boolean("intersect", a, b).volume())
|
|
67
|
+
vc = float(fk.boolean("cut", a, b).volume())
|
|
68
|
+
assert vi > 0 # they genuinely overlap
|
|
69
|
+
assert abs(vu + vi - 16) < 1e-9 # |A∪B| + |A∩B| = |A| + |B|
|
|
70
|
+
assert abs(vc - (8 - vi)) < 1e-9 # |A∖B| = |A| − |A∩B|
|
|
71
|
+
for op in ("union", "cut", "intersect"):
|
|
72
|
+
assert fk.boolean(op, a, b).watertight_violations() == []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_surd_is_an_ordered_field() -> None:
|
|
76
|
+
import random
|
|
77
|
+
random.seed(0)
|
|
78
|
+
for _ in range(2000):
|
|
79
|
+
d = random.choice([2, 3, 5, 6])
|
|
80
|
+
from fractions import Fraction as F
|
|
81
|
+
x = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
|
|
82
|
+
F(random.randint(-9, 9), random.randint(1, 4)), d)
|
|
83
|
+
y = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
|
|
84
|
+
F(random.randint(-9, 9), random.randint(1, 4)), d)
|
|
85
|
+
if abs(float(x) - float(y)) > 1e-9:
|
|
86
|
+
assert (x < y) == (float(x) < float(y))
|
|
87
|
+
assert (x / y) * y == x if float(y) != 0 else True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|