forgekernel 0.9.2__tar.gz → 0.9.5__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.5}/PKG-INFO +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/__init__.py +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/quadric.py +206 -14
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/stepio.py +109 -9
- {forgekernel-0.9.2 → forgekernel-0.9.5/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/SOURCES.txt +3 -1
- forgekernel-0.9.5/tests/test_boss.py +134 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k1_exact.py +18 -3
- forgekernel-0.9.5/tests/test_step_drilled.py +112 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/LICENSE +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/README.md +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/pyproject.toml +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/setup.cfg +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/brep.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/exact.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/hlr.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/io.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/kernel.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/mesh2d.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/surd.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/tess.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_hlr.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_step.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_rotate.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.9.2 → forgekernel-0.9.5}/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
|
|
@@ -359,10 +359,32 @@ def _perp(n):
|
|
|
359
359
|
return (p[0] / m, p[1] / m, p[2] / m)
|
|
360
360
|
|
|
361
361
|
|
|
362
|
-
def
|
|
363
|
-
"""
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
def _poly_contains_xy(verts, px, py) -> bool:
|
|
363
|
+
"""Ray parity in xy for a face polygon (used to decide which cap fragment
|
|
364
|
+
a bore breaks through)."""
|
|
365
|
+
inside = False
|
|
366
|
+
n = len(verts)
|
|
367
|
+
for i in range(n):
|
|
368
|
+
(x1, y1) = verts[i][0], verts[i][1]
|
|
369
|
+
(x2, y2) = verts[(i + 1) % n][0], verts[(i + 1) % n][1]
|
|
370
|
+
if (y1 > py) != (y2 > py):
|
|
371
|
+
if px < x1 + (py - y1) * (x2 - x1) / (y2 - y1):
|
|
372
|
+
inside = not inside
|
|
373
|
+
return inside
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def write_step_planar_solid(solid, *, name: str = "gitcad_part",
|
|
377
|
+
bores=()) -> str:
|
|
378
|
+
"""Emit a forge solid as a valid AP214 STEP file (full product structure +
|
|
379
|
+
MANIFOLD_SOLID_BREP with shared edges).
|
|
380
|
+
|
|
381
|
+
``solid`` supplies the planar faces; ``bores`` is an optional list of z-axis
|
|
382
|
+
``Cyl`` bores drilled through it. A bore contributes a CYLINDRICAL_SURFACE
|
|
383
|
+
wall (as two half-faces, so no periodic seam is needed), a circular inner
|
|
384
|
+
bound on each cap it breaks through, and a flat disk face where it ends
|
|
385
|
+
blind. Circles and cylinders are emitted as EXACT analytic surfaces, not
|
|
386
|
+
faceted — a drilled plate exports as a real cylindrical hole that any CAD
|
|
387
|
+
system reads back as a hole."""
|
|
366
388
|
lines: list[str] = []
|
|
367
389
|
nid = [0]
|
|
368
390
|
|
|
@@ -424,6 +446,36 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
|
|
|
424
446
|
ka, kb)
|
|
425
447
|
return edges[key]
|
|
426
448
|
|
|
449
|
+
def zplacement(cx, cy, z, up=1):
|
|
450
|
+
"""AXIS2_PLACEMENT_3D on the +z (or −z) axis through (cx, cy, z),
|
|
451
|
+
ref direction +x — the frame every circle and cylinder here uses."""
|
|
452
|
+
o = emit(f"CARTESIAN_POINT('',({_dec(cx)},{_dec(cy)},{_dec(z)}))")
|
|
453
|
+
ax = emit(f"DIRECTION('',(0.,0.,{'1.' if up > 0 else '-1.'}))")
|
|
454
|
+
rd = emit(f"DIRECTION('',(1.,0.,0.))")
|
|
455
|
+
return emit(f"AXIS2_PLACEMENT_3D('',#{o},#{ax},#{rd})")
|
|
456
|
+
|
|
457
|
+
def rim_edges(c, z):
|
|
458
|
+
"""The two half-circle EDGE_CURVEs of a bore's rim at height z, split at
|
|
459
|
+
angles 0 and π so the cylindrical face needs no periodic seam. Cached,
|
|
460
|
+
so the cap's inner bound and the wall share the very same edges."""
|
|
461
|
+
key = ("rim", c.cx, c.cy, c.r, z)
|
|
462
|
+
if key in edges:
|
|
463
|
+
return edges[key]
|
|
464
|
+
p0 = (c.cx + c.r, c.cy, z)
|
|
465
|
+
p1 = (c.cx - c.r, c.cy, z)
|
|
466
|
+
circ = emit(f"CIRCLE('',#{zplacement(c.cx, c.cy, z)},{_dec(c.r)})")
|
|
467
|
+
e_a = emit(f"EDGE_CURVE('',#{vertex(p0)},#{vertex(p1)},#{circ},.T.)")
|
|
468
|
+
e_b = emit(f"EDGE_CURVE('',#{vertex(p1)},#{vertex(p0)},#{circ},.T.)")
|
|
469
|
+
edges[key] = (e_a, e_b, p0, p1)
|
|
470
|
+
return edges[key]
|
|
471
|
+
|
|
472
|
+
def oriented(eid, fwd):
|
|
473
|
+
return emit(f"ORIENTED_EDGE('',*,*,#{eid},{'.T.' if fwd else '.F.'})")
|
|
474
|
+
|
|
475
|
+
def loop_of(oes):
|
|
476
|
+
return emit(f"EDGE_LOOP('',({','.join('#' + str(x) for x in oes)}))")
|
|
477
|
+
|
|
478
|
+
bores = list(bores)
|
|
427
479
|
face_ids = []
|
|
428
480
|
for poly in solid.polys:
|
|
429
481
|
vs = [tuple(v) for v in poly.verts]
|
|
@@ -432,10 +484,24 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
|
|
|
432
484
|
a, b = vs[i], vs[(i + 1) % len(vs)]
|
|
433
485
|
ec, ka, kb = edge_curve(a, b)
|
|
434
486
|
fwd = (a[0], a[1], a[2]) == ka
|
|
435
|
-
oe.append(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
bound
|
|
487
|
+
oe.append(oriented(ec, fwd))
|
|
488
|
+
bound = emit(f"FACE_OUTER_BOUND('',#{loop_of(oe)},.T.)")
|
|
489
|
+
bounds = [bound]
|
|
490
|
+
# a z-cap gets a circular INNER bound for every bore that breaks it
|
|
491
|
+
n = poly.plane.n
|
|
492
|
+
if n[0] == 0 and n[1] == 0:
|
|
493
|
+
zc = vs[0][2]
|
|
494
|
+
for c in bores:
|
|
495
|
+
if (c.z0 < zc < c.z1 or c.z0 == zc or c.z1 == zc) and \
|
|
496
|
+
_poly_contains_xy(vs, c.cx, c.cy):
|
|
497
|
+
a_e, b_e, _, _ = rim_edges(c, zc)
|
|
498
|
+
# each rim half-circle is shared with the bore wall, which
|
|
499
|
+
# traverses the LOWER rim forward and the UPPER rim
|
|
500
|
+
# backward; the cap must take the opposite sense so every
|
|
501
|
+
# edge is used once .T. and once .F. (manifold closure).
|
|
502
|
+
fwd = n[2] > 0 # top cap vs bottom cap
|
|
503
|
+
hole = loop_of([oriented(a_e, fwd), oriented(b_e, fwd)])
|
|
504
|
+
bounds.append(emit(f"FACE_BOUND('',#{hole},.T.)"))
|
|
439
505
|
(nx, ny, nz), _ = _unit3(poly.plane.n)
|
|
440
506
|
rx, ry, rz = _perp((nx, ny, nz))
|
|
441
507
|
origin = emit(f"CARTESIAN_POINT('',({_dec(vs[0][0])},{_dec(vs[0][1])},"
|
|
@@ -444,7 +510,41 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
|
|
|
444
510
|
rdir = emit(f"DIRECTION('',({fnum(rx)},{fnum(ry)},{fnum(rz)}))")
|
|
445
511
|
place = emit(f"AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{rdir})")
|
|
446
512
|
plane = emit(f"PLANE('',#{place})")
|
|
447
|
-
|
|
513
|
+
blist = ",".join("#" + str(b) for b in bounds)
|
|
514
|
+
face_ids.append(emit(f"ADVANCED_FACE('',({blist}),#{plane},.T.)"))
|
|
515
|
+
|
|
516
|
+
# -- bore walls (two half-cylinder faces) + blind end caps ----------------
|
|
517
|
+
(_, _, sz0), (_, _, sz1) = solid.bbox()
|
|
518
|
+
for c in bores:
|
|
519
|
+
z0, z1 = max(c.z0, sz0), min(c.z1, sz1)
|
|
520
|
+
if z1 <= z0:
|
|
521
|
+
continue
|
|
522
|
+
lo_a, lo_b, lp0, lp1 = rim_edges(c, z0)
|
|
523
|
+
hi_a, hi_b, hp0, hp1 = rim_edges(c, z1)
|
|
524
|
+
seam0 = edge_curve(lp0, hp0)[0] # the two vertical seam edges
|
|
525
|
+
seam1 = edge_curve(lp1, hp1)[0]
|
|
526
|
+
cyl = emit(f"CYLINDRICAL_SURFACE('',#{zplacement(c.cx, c.cy, z0)},"
|
|
527
|
+
f"{_dec(c.r)})")
|
|
528
|
+
for lo_e, hi_e, s_from, s_to in ((lo_a, hi_a, seam1, seam0),
|
|
529
|
+
(lo_b, hi_b, seam0, seam1)):
|
|
530
|
+
oes = [oriented(lo_e, True), oriented(s_from, True),
|
|
531
|
+
oriented(hi_e, False), oriented(s_to, False)]
|
|
532
|
+
b = emit(f"FACE_OUTER_BOUND('',#{loop_of(oes)},.T.)")
|
|
533
|
+
# material lies OUTSIDE a bore, so the solid's outward normal points
|
|
534
|
+
# toward the axis — opposite the cylinder's own normal: .F.
|
|
535
|
+
face_ids.append(emit(f"ADVANCED_FACE('',(#{b}),#{cyl},.F.)"))
|
|
536
|
+
# A blind end gets a flat disk. Its outward normal points INTO the bore
|
|
537
|
+
# (material is on the far side): +z at the floor, −z at the ceiling.
|
|
538
|
+
# The loop sense is independent of that — it mirrors the wall, which
|
|
539
|
+
# takes the lower rim forward and the upper rim backward.
|
|
540
|
+
for zb, axis_up, fwd in ((z0, 1, False), (z1, -1, True)):
|
|
541
|
+
if (zb == sz0 and c.z0 <= sz0) or (zb == sz1 and c.z1 >= sz1):
|
|
542
|
+
continue # breaks through: no disk here
|
|
543
|
+
a_e, b_e, _, _ = rim_edges(c, zb)
|
|
544
|
+
disk = loop_of([oriented(a_e, fwd), oriented(b_e, fwd)])
|
|
545
|
+
b = emit(f"FACE_OUTER_BOUND('',#{disk},.T.)")
|
|
546
|
+
pl = emit(f"PLANE('',#{zplacement(c.cx, c.cy, zb, axis_up)})")
|
|
547
|
+
face_ids.append(emit(f"ADVANCED_FACE('',(#{b}),#{pl},.T.)"))
|
|
448
548
|
|
|
449
549
|
shell = emit(f"CLOSED_SHELL('',({','.join('#' + str(x) for x in face_ids)}))")
|
|
450
550
|
brep = emit(f"MANIFOLD_SOLID_BREP('{name}',#{shell})")
|
|
@@ -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
|
|
@@ -41,4 +42,5 @@ tests/test_k7_trim.py
|
|
|
41
42
|
tests/test_k7_trimflux.py
|
|
42
43
|
tests/test_rotate.py
|
|
43
44
|
tests/test_rust_oracle.py
|
|
44
|
-
tests/test_scaffold.py
|
|
45
|
+
tests/test_scaffold.py
|
|
46
|
+
tests/test_step_drilled.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,112 @@
|
|
|
1
|
+
"""STEP export of drilled solids — exact cylindrical holes, not facets.
|
|
2
|
+
|
|
3
|
+
A hole is emitted as a real CYLINDRICAL_SURFACE with CIRCLE edges, so any CAD
|
|
4
|
+
system reads it back as a hole (with a diameter you can query) rather than a
|
|
5
|
+
polygon soup. The oracle here is topological: in a valid MANIFOLD_SOLID_BREP
|
|
6
|
+
every edge is used exactly twice, once in each direction — an orientation slip
|
|
7
|
+
anywhere in the caps, walls or blind disks breaks that immediately.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from forgekernel.brep import Solid
|
|
18
|
+
from forgekernel.quadric import Cyl, DrilledSolid
|
|
19
|
+
from forgekernel.stepio import write_step_planar_solid
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _entities(text):
|
|
23
|
+
ent = {}
|
|
24
|
+
for m in re.finditer(r"^#(\d+) = ([A-Z0-9_]+)\((.*)\);$", text, re.M):
|
|
25
|
+
ent[int(m.group(1))] = (m.group(2), m.group(3))
|
|
26
|
+
return ent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _refs(s):
|
|
30
|
+
return [int(x) for x in re.findall(r"#(\d+)", s)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _edge_uses(text):
|
|
34
|
+
"""edge id -> list of traversal senses across every face loop."""
|
|
35
|
+
ent = _entities(text)
|
|
36
|
+
use = defaultdict(list)
|
|
37
|
+
for fid, (t, args) in ent.items():
|
|
38
|
+
if t != "ADVANCED_FACE":
|
|
39
|
+
continue
|
|
40
|
+
for b in _refs(args):
|
|
41
|
+
if ent.get(b, ("", ""))[0] not in ("FACE_OUTER_BOUND", "FACE_BOUND"):
|
|
42
|
+
continue
|
|
43
|
+
for lp in _refs(ent[b][1]):
|
|
44
|
+
if ent.get(lp, ("", ""))[0] != "EDGE_LOOP":
|
|
45
|
+
continue
|
|
46
|
+
for oe in _refs(ent[lp][1]):
|
|
47
|
+
if ent.get(oe, ("", ""))[0] != "ORIENTED_EDGE":
|
|
48
|
+
continue
|
|
49
|
+
use[_refs(ent[oe][1])[0]].append(
|
|
50
|
+
".T." in ent[oe][1].split(",")[-1])
|
|
51
|
+
return use
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _assert_manifold(text):
|
|
55
|
+
use = _edge_uses(text)
|
|
56
|
+
assert use, "no edges found"
|
|
57
|
+
for eid, senses in use.items():
|
|
58
|
+
assert len(senses) == 2, f"edge #{eid} used {len(senses)}x, want 2"
|
|
59
|
+
assert senses[0] != senses[1], f"edge #{eid} traversed the same way twice"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _assert_refs_resolve(text):
|
|
63
|
+
ids = {int(m) for m in re.findall(r"^#(\d+) =", text, re.M)}
|
|
64
|
+
used = {int(m) for m in re.findall(r"#(\d+)", text)}
|
|
65
|
+
assert not (used - ids), f"dangling refs: {sorted(used - ids)[:5]}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
CASES = {
|
|
69
|
+
"through hole": DrilledSolid(Solid.box(40, 20, 5), [Cyl(20, 10, 4, 0, 5)]),
|
|
70
|
+
"blind from top": DrilledSolid(Solid.box(30, 30, 10), [Cyl(15, 15, 3, 4, 10)]),
|
|
71
|
+
"blind from bottom": DrilledSolid(Solid.box(30, 30, 10), [Cyl(15, 15, 3, 0, 6)]),
|
|
72
|
+
"two holes": DrilledSolid(Solid.box(60, 30, 5),
|
|
73
|
+
[Cyl(15, 15, 3, 0, 5), Cyl(45, 15, 3, 0, 5)]),
|
|
74
|
+
"counterbore": DrilledSolid(Solid.box(30, 30, 10),
|
|
75
|
+
[Cyl(15, 15, 2, 0, 10), Cyl(15, 15, 4, 7, 10)]),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("label", sorted(CASES))
|
|
80
|
+
def test_drilled_step_is_a_closed_oriented_brep(label) -> None:
|
|
81
|
+
solid = CASES[label]
|
|
82
|
+
text = write_step_planar_solid(solid.base, bores=solid.bores)
|
|
83
|
+
assert text.startswith("ISO-10303-21;")
|
|
84
|
+
assert text.rstrip().endswith("END-ISO-10303-21;")
|
|
85
|
+
_assert_refs_resolve(text)
|
|
86
|
+
_assert_manifold(text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_a_hole_is_an_exact_cylinder_not_facets() -> None:
|
|
90
|
+
solid = DrilledSolid(Solid.box(40, 20, 5), [Cyl(20, 10, 4, 0, 5)])
|
|
91
|
+
text = write_step_planar_solid(solid.base, bores=solid.bores)
|
|
92
|
+
# one analytic cylinder of the right radius on the right axis...
|
|
93
|
+
cyls = re.findall(r"CYLINDRICAL_SURFACE\('',#(\d+),([0-9.]+)\)", text)
|
|
94
|
+
assert len(cyls) == 1 and float(cyls[0][1]) == pytest.approx(4.0)
|
|
95
|
+
# ...two rim circles, same radius
|
|
96
|
+
circles = re.findall(r"CIRCLE\('',#(\d+),([0-9.]+)\)", text)
|
|
97
|
+
assert len(circles) == 2
|
|
98
|
+
assert all(float(r) == pytest.approx(4.0) for _, r in circles)
|
|
99
|
+
# ...and the axis really is at the bore centre
|
|
100
|
+
ent = _entities(text)
|
|
101
|
+
place = ent[int(cyls[0][0])][1]
|
|
102
|
+
origin = ent[_refs(place)[0]][1]
|
|
103
|
+
xs = [float(v) for v in re.findall(r"-?[0-9.]+", origin)]
|
|
104
|
+
assert xs[0] == pytest.approx(20.0) and xs[1] == pytest.approx(10.0)
|
|
105
|
+
# the caps carry the hole as an INNER bound (FACE_BOUND), not an outer one
|
|
106
|
+
assert len(re.findall(r"= FACE_BOUND\(", text)) == 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_planar_export_is_unchanged_by_the_bore_support() -> None:
|
|
110
|
+
box = Solid.box(20, 20, 5)
|
|
111
|
+
assert write_step_planar_solid(box) == write_step_planar_solid(box, bores=[])
|
|
112
|
+
_assert_manifold(write_step_planar_solid(box))
|
|
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
|