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.
Files changed (47) hide show
  1. {forgekernel-0.9.2/src/forgekernel.egg-info → forgekernel-0.9.4}/PKG-INFO +1 -1
  2. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/quadric.py +206 -14
  4. {forgekernel-0.9.2 → forgekernel-0.9.4/src/forgekernel.egg-info}/PKG-INFO +1 -1
  5. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/SOURCES.txt +1 -0
  6. forgekernel-0.9.4/tests/test_boss.py +134 -0
  7. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k1_exact.py +18 -3
  8. {forgekernel-0.9.2 → forgekernel-0.9.4}/LICENSE +0 -0
  9. {forgekernel-0.9.2 → forgekernel-0.9.4}/README.md +0 -0
  10. {forgekernel-0.9.2 → forgekernel-0.9.4}/pyproject.toml +0 -0
  11. {forgekernel-0.9.2 → forgekernel-0.9.4}/setup.cfg +0 -0
  12. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/brep.py +0 -0
  13. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/bsolid.py +0 -0
  14. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/csg.py +0 -0
  15. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/curve.py +0 -0
  16. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/exact.py +0 -0
  17. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/hlr.py +0 -0
  18. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/interval.py +0 -0
  19. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/io.py +0 -0
  20. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/kernel.py +0 -0
  21. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/loft.py +0 -0
  22. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/mesh2d.py +0 -0
  23. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/nurbs.py +0 -0
  24. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/profile2d.py +0 -0
  25. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/ssi.py +0 -0
  26. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/stepio.py +0 -0
  27. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/surd.py +0 -0
  28. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/surfacing.py +0 -0
  29. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/tess.py +0 -0
  30. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel/trim.py +0 -0
  31. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  32. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/requires.txt +0 -0
  33. {forgekernel-0.9.2 → forgekernel-0.9.4}/src/forgekernel.egg-info/top_level.txt +0 -0
  34. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_hlr.py +0 -0
  35. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k37_loft.py +0 -0
  36. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k38_profile.py +0 -0
  37. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_curves.py +0 -0
  38. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_ssi.py +0 -0
  39. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k3_step.py +0 -0
  40. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k6_surfacing.py +0 -0
  41. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_bsolid.py +0 -0
  42. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_stepexport.py +0 -0
  43. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_trim.py +0 -0
  44. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_k7_trimflux.py +0 -0
  45. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_rotate.py +0 -0
  46. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_rust_oracle.py +0 -0
  47. {forgekernel-0.9.2 → forgekernel-0.9.4}/tests/test_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Exact-arithmetic B-rep CAD kernel
5
5
  Author-email: Dan Willis <danielcwillis@gmail.com>
6
6
  Maintainer-email: Dan Willis <danielcwillis@gmail.com>
@@ -4,4 +4,4 @@ Exact rational arithmetic for every topological decision; approximation
4
4
  only with tracked error bounds. See README for the three-oracle chain.
5
5
  """
6
6
 
7
- __version__ = "0.9.2"
7
+ __version__ = "0.9.4"
@@ -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 the z axis. Green gives exact metrics:
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 (0.0, 0.0, float(num / v3 * 3) if v3 else 0.0)
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
- return ((-float(rmax), -float(rmax), float(min(zs))),
657
- (float(rmax), float(rmax), float(max(zs))))
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
- outer = (a.r + b.r) ** 2
752
- inner = (a.r - b.r) ** 2
753
- if d2 >= outer or d2 <= inner:
754
- return # externally/internally clear
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 or 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Exact-arithmetic B-rep CAD kernel
5
5
  Author-email: Dan Willis <danielcwillis@gmail.com>
6
6
  Maintainer-email: Dan Willis <danielcwillis@gmail.com>
@@ -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 test_internally_tangent_cylinders_exact() -> None:
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
- # r=10 and r=4, centers 6 apart: d^2 = 36 = (10-4)^2 internally tangent
277
- u = DisjointUnion([Cyl.make(10, 8), Cyl.make(4, 8).translated(6, 0, 0)])
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