forgekernel 0.9.2__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.2/src/forgekernel.egg-info → forgekernel-0.9.4}/PKG-INFO +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/__init__.py +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/quadric.py +206 -14
- {forgekernel-0.9.2 → forgekernel-0.9.4/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/SOURCES.txt +1 -0
- forgekernel-0.9.4/tests/test_boss.py +134 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k1_exact.py +18 -3
- {forgekernel-0.9.2 → forgekernel-0.9.4}/LICENSE +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/README.md +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/pyproject.toml +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/setup.cfg +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/brep.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/exact.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/hlr.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/io.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/kernel.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/mesh2d.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/surd.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/tess.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_hlr.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_step.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_rotate.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_scaffold.py +0 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|