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.
Files changed (48) hide show
  1. {forgekernel-0.9.2/src/forgekernel.egg-info → forgekernel-0.9.5}/PKG-INFO +1 -1
  2. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/quadric.py +206 -14
  4. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/stepio.py +109 -9
  5. {forgekernel-0.9.2 → forgekernel-0.9.5/src/forgekernel.egg-info}/PKG-INFO +1 -1
  6. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/SOURCES.txt +3 -1
  7. forgekernel-0.9.5/tests/test_boss.py +134 -0
  8. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k1_exact.py +18 -3
  9. forgekernel-0.9.5/tests/test_step_drilled.py +112 -0
  10. {forgekernel-0.9.2 → forgekernel-0.9.5}/LICENSE +0 -0
  11. {forgekernel-0.9.2 → forgekernel-0.9.5}/README.md +0 -0
  12. {forgekernel-0.9.2 → forgekernel-0.9.5}/pyproject.toml +0 -0
  13. {forgekernel-0.9.2 → forgekernel-0.9.5}/setup.cfg +0 -0
  14. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/brep.py +0 -0
  15. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/bsolid.py +0 -0
  16. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/csg.py +0 -0
  17. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/curve.py +0 -0
  18. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/exact.py +0 -0
  19. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/hlr.py +0 -0
  20. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/interval.py +0 -0
  21. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/io.py +0 -0
  22. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/kernel.py +0 -0
  23. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/loft.py +0 -0
  24. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/mesh2d.py +0 -0
  25. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/nurbs.py +0 -0
  26. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/profile2d.py +0 -0
  27. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/ssi.py +0 -0
  28. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/surd.py +0 -0
  29. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/surfacing.py +0 -0
  30. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/tess.py +0 -0
  31. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel/trim.py +0 -0
  32. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  33. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/requires.txt +0 -0
  34. {forgekernel-0.9.2 → forgekernel-0.9.5}/src/forgekernel.egg-info/top_level.txt +0 -0
  35. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_hlr.py +0 -0
  36. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k37_loft.py +0 -0
  37. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k38_profile.py +0 -0
  38. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_curves.py +0 -0
  39. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_ssi.py +0 -0
  40. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k3_step.py +0 -0
  41. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k6_surfacing.py +0 -0
  42. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_bsolid.py +0 -0
  43. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_stepexport.py +0 -0
  44. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_trim.py +0 -0
  45. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_k7_trimflux.py +0 -0
  46. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_rotate.py +0 -0
  47. {forgekernel-0.9.2 → forgekernel-0.9.5}/tests/test_rust_oracle.py +0 -0
  48. {forgekernel-0.9.2 → forgekernel-0.9.5}/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.5
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.5"
@@ -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
@@ -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 write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
363
- """Emit a planar-faced forge Solid as a valid AP214 STEP file
364
- (full product structure + MANIFOLD_SOLID_BREP with shared straight
365
- edges). Round-trips through OCCT and :func:`read_step_planar_solid`."""
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(emit(f"ORIENTED_EDGE('',*,*,#{ec},"
436
- f"{'.T.' if fwd else '.F.'})"))
437
- loop = emit(f"EDGE_LOOP('',({','.join('#' + str(x) for x in oe)}))")
438
- bound = emit(f"FACE_OUTER_BOUND('',#{loop},.T.)")
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
- face_ids.append(emit(f"ADVANCED_FACE('',(#{bound}),#{plane},.T.)"))
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})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.2
3
+ Version: 0.9.5
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
@@ -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 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
 
@@ -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