forgekernel 0.9.1__tar.gz → 0.9.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {forgekernel-0.9.1/src/forgekernel.egg-info → forgekernel-0.9.4}/PKG-INFO +1 -1
  2. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/brep.py +17 -29
  4. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/exact.py +9 -16
  5. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/kernel.py +63 -0
  6. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/quadric.py +206 -14
  7. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/surd.py +54 -0
  8. {forgekernel-0.9.1 → forgekernel-0.9.4/src/forgekernel.egg-info}/PKG-INFO +1 -1
  9. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/SOURCES.txt +2 -0
  10. forgekernel-0.9.4/tests/test_boss.py +134 -0
  11. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k1_exact.py +18 -3
  12. forgekernel-0.9.4/tests/test_rotate.py +87 -0
  13. {forgekernel-0.9.1 → forgekernel-0.9.4}/LICENSE +0 -0
  14. {forgekernel-0.9.1 → forgekernel-0.9.4}/README.md +0 -0
  15. {forgekernel-0.9.1 → forgekernel-0.9.4}/pyproject.toml +0 -0
  16. {forgekernel-0.9.1 → forgekernel-0.9.4}/setup.cfg +0 -0
  17. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/bsolid.py +0 -0
  18. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/csg.py +0 -0
  19. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/curve.py +0 -0
  20. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/hlr.py +0 -0
  21. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/interval.py +0 -0
  22. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/io.py +0 -0
  23. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/loft.py +0 -0
  24. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/mesh2d.py +0 -0
  25. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/nurbs.py +0 -0
  26. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/profile2d.py +0 -0
  27. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/ssi.py +0 -0
  28. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/stepio.py +0 -0
  29. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/surfacing.py +0 -0
  30. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/tess.py +0 -0
  31. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel/trim.py +0 -0
  32. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  33. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/requires.txt +0 -0
  34. {forgekernel-0.9.1 → forgekernel-0.9.4}/src/forgekernel.egg-info/top_level.txt +0 -0
  35. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_hlr.py +0 -0
  36. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k37_loft.py +0 -0
  37. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k38_profile.py +0 -0
  38. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_curves.py +0 -0
  39. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_ssi.py +0 -0
  40. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k3_step.py +0 -0
  41. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k6_surfacing.py +0 -0
  42. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_bsolid.py +0 -0
  43. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_stepexport.py +0 -0
  44. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_trim.py +0 -0
  45. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_k7_trimflux.py +0 -0
  46. {forgekernel-0.9.1 → forgekernel-0.9.4}/tests/test_rust_oracle.py +0 -0
  47. {forgekernel-0.9.1 → 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.1
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.1"
7
+ __version__ = "0.9.4"
@@ -188,25 +188,21 @@ class Solid:
188
188
  moment) and closure requires the SIGNED interval coverage on every
189
189
  line to cancel exactly. Zero everywhere == closed surface."""
190
190
  from collections import defaultdict
191
- from math import gcd as _gcd
192
191
 
193
192
  def canon_dir(d: Vec) -> Vec | None:
194
- den = 1
193
+ # Canonical (scale- and sign-invariant) representative of a
194
+ # direction: divide through by the first nonzero component so any
195
+ # scalar multiple ±λ·d maps to the same tuple. Works whether the
196
+ # components are rational (ℚ) or quadratic surds (ℚ[√d]) — an
197
+ # exactly-rotated solid carries √d edge directions.
198
+ lead = None
195
199
  for v in d:
196
- den = den * v.denominator // _gcd(den, v.denominator)
197
- ints = [int(v * den) for v in d]
198
- g = 0
199
- for v in ints:
200
- g = _gcd(g, abs(v))
201
- if g == 0:
202
- return None
203
- ints = [v // g for v in ints]
204
- for v in ints:
205
200
  if v != 0:
206
- if v < 0:
207
- ints = [-w for w in ints]
201
+ lead = v
208
202
  break
209
- return (F(ints[0]), F(ints[1]), F(ints[2]))
203
+ if lead is None:
204
+ return None
205
+ return tuple(v / lead for v in d)
210
206
 
211
207
  lines: dict = defaultdict(list)
212
208
  for p in self.polys:
@@ -298,24 +294,16 @@ def _ear_clip(loop: list[tuple]) -> list[tuple]:
298
294
 
299
295
 
300
296
  def _canon_dir(d: Vec):
301
- from math import gcd as _g
302
-
303
- den = 1
297
+ # Scale- and sign-invariant direction key: divide by the first nonzero
298
+ # component. Rational (ℚ) or quadratic-surd (ℚ[√d], from exact rotation).
299
+ lead = None
304
300
  for v in d:
305
- den = den * v.denominator // _g(den, v.denominator)
306
- ints = [int(v * den) for v in d]
307
- g = 0
308
- for v in ints:
309
- g = _g(g, abs(v))
310
- if g == 0:
311
- return None
312
- ints = [v // g for v in ints]
313
- for v in ints:
314
301
  if v != 0:
315
- if v < 0:
316
- ints = [-w for w in ints]
302
+ lead = v
317
303
  break
318
- return (F(ints[0]), F(ints[1]), F(ints[2]))
304
+ if lead is None:
305
+ return None
306
+ return tuple(v / lead for v in d)
319
307
 
320
308
 
321
309
  def logical_edges(solid: Solid) -> list[dict]:
@@ -87,24 +87,17 @@ class Plane:
87
87
  return Plane(neg(self.n), -self.d)
88
88
 
89
89
  def canonical(self) -> tuple:
90
- """Hashable canonical form: normal scaled to coprime integers with a
91
- sign convention coplanarity comparisons become tuple equality."""
92
- nums = [self.n[0], self.n[1], self.n[2], self.d]
93
- den = 1
94
- for v in nums:
95
- den = den * v.denominator // gcd(den, v.denominator)
96
- ints = [int(v * den) for v in nums]
97
- g = 0
98
- for v in ints:
99
- g = gcd(g, abs(v))
100
- if g:
101
- ints = [v // g for v in ints]
102
- for v in ints[:3]:
90
+ """Hashable canonical form: (normal, d) divided through by the normal's
91
+ first nonzero component, so any scalar multiple ±λ·(n, d) maps to the
92
+ same tuple coplanarity becomes tuple equality. Works over ℚ and over
93
+ ℚ[√d] (an exactly-rotated solid carries surd face normals)."""
94
+ nums = (self.n[0], self.n[1], self.n[2], self.d)
95
+ lead = None
96
+ for v in nums[:3]:
103
97
  if v != 0:
104
- if v < 0:
105
- ints = [-w for w in ints]
98
+ lead = v
106
99
  break
107
- return tuple(ints)
100
+ return tuple(v / lead for v in nums)
108
101
 
109
102
  def coplanar_key(self) -> tuple:
110
103
  """Canonical form ignoring orientation (for adjacency grouping)."""
@@ -51,6 +51,69 @@ def rotate_quarter(s: Solid, axis: str, quarters: int) -> Solid:
51
51
  return s.rotated_quarter(axis, quarters)
52
52
 
53
53
 
54
+ def _cos_sin_deg(deg):
55
+ """Exact (cos, sin) of an angle in degrees, in ℚ[√d] — or None if the angle
56
+ needs a larger field. Covers the multiples of 30° and 45° (so circular
57
+ patterns of 3/4/6/8/12 and the diagonal sketch-plane rotations are exact)."""
58
+ from forgekernel.surd import SurdVal
59
+
60
+ from fractions import Fraction
61
+
62
+ d = int(deg) % 360
63
+ if deg != int(deg):
64
+ return None
65
+ if d % 90 == 0:
66
+ return [(F(1), F(0)), (F(0), F(1)), (F(-1), F(0)), (F(0), F(-1))][d // 90]
67
+ h = Fraction(1, 2)
68
+ r3, r2 = SurdVal(0, h, 3), SurdVal(0, h, 2) # √3/2, √2/2
69
+ table = {30: (r3, h), 60: (h, r3), 120: (-h, r3), 150: (-r3, h),
70
+ 210: (-r3, -h), 240: (-h, -r3), 300: (h, -r3), 330: (r3, -h),
71
+ 45: (r2, r2), 135: (-r2, r2), 225: (-r2, -r2), 315: (r2, -r2)}
72
+ return table.get(d)
73
+
74
+
75
+ def _rotation_matrix(axis, deg):
76
+ """Exact Rodrigues rotation matrix (list of 3 rows) about ``axis`` by
77
+ ``deg``, over ℚ[√d]. Raises ValueError if the angle is not representable."""
78
+ from forgekernel.surd import SurdVal, sqrt_rational
79
+
80
+ cs = _cos_sin_deg(deg)
81
+ if cs is None:
82
+ raise ValueError(
83
+ f"rotation by {deg}° is not exactly representable in ℚ[√d] "
84
+ "(only multiples of 30° and 45°)")
85
+ c, s = cs
86
+ ax, ay, az = F(axis[0]), F(axis[1]), F(axis[2])
87
+ n2 = ax * ax + ay * ay + az * az # |axis|², rational
88
+ if n2 == 0:
89
+ raise ValueError("zero rotation axis")
90
+ one = SurdVal(1, 0, 1)
91
+ s_over_n = (s if isinstance(s, SurdVal) else SurdVal(s)) / sqrt_rational(n2)
92
+ k2c = (one - (c if isinstance(c, SurdVal) else SurdVal(c))) / n2
93
+ k = [[F(0), -az, ay], [az, F(0), -ax], [-ay, ax, F(0)]] # [axis]×
94
+ k2 = [[sum(k[i][t] * k[t][j] for t in range(3)) for j in range(3)]
95
+ for i in range(3)]
96
+ return [[(F(1) if i == j else F(0)) + s_over_n * k[i][j] + k2c * k2[i][j]
97
+ for j in range(3)] for i in range(3)]
98
+
99
+
100
+ def rotate(s: Solid, axis, deg) -> Solid:
101
+ """Exact rotation of a planar solid about an arbitrary axis by an angle in
102
+ ℚ[√d] (multiples of 30°/45°). Rigid motion — volume is preserved exactly and
103
+ the result is a Solid with exact ℚ[√d] coordinates. Raises ValueError for
104
+ angles a larger field would be needed for (the seam turns that into an
105
+ honest refusal)."""
106
+ r = _rotation_matrix(axis, deg)
107
+
108
+ def fn(v):
109
+ x, y, z = v
110
+ return (r[0][0] * x + r[0][1] * y + r[0][2] * z,
111
+ r[1][0] * x + r[1][1] * y + r[1][2] * z,
112
+ r[2][0] * x + r[2][1] * y + r[2][2] * z)
113
+
114
+ return s.mapped(fn)
115
+
116
+
54
117
  def chamfer(s: Solid, distance) -> Solid:
55
118
  """Exact chamfer on all convex rational-normal edges, with industrial
56
119
  corner-triangle vertex truncation (oracle-matched semantics)."""
@@ -93,6 +93,35 @@ class Cyl:
93
93
  return lathe(profile, deflection, float(self.cx), float(self.cy))
94
94
 
95
95
 
96
+ def _xy_inside_footprint(solid: Solid, px, py) -> bool:
97
+ """Exact: is (px, py) inside the solid's xy footprint?
98
+
99
+ The footprint boundary is the xy projection of the lateral (non-horizontal)
100
+ faces. Ray parity along +x with the half-open upward-crossing rule — all
101
+ rational, no tolerance. Needed because "the bore does not CROSS a lateral
102
+ wall" does not distinguish a bore strictly inside from one entirely
103
+ outside: neither crosses anything."""
104
+ segs = set()
105
+ for p in solid.polys:
106
+ n = p.plane.n
107
+ if n[0] == 0 and n[1] == 0:
108
+ continue # horizontal face: no footprint edge
109
+ m = len(p.verts)
110
+ for i in range(m):
111
+ a, b = p.verts[i], p.verts[(i + 1) % m]
112
+ if a[0] == b[0] and a[1] == b[1]:
113
+ continue # vertical edge: projects to a point
114
+ key = ((a[0], a[1]), (b[0], b[1]))
115
+ segs.add(key if key[0] <= key[1] else (key[1], key[0]))
116
+ inside = False
117
+ for (x1, y1), (x2, y2) in segs:
118
+ if (y1 > py) != (y2 > py):
119
+ xc = x1 + (py - y1) * (x2 - x1) / (y2 - y1)
120
+ if px < xc:
121
+ inside = not inside
122
+ return inside
123
+
124
+
96
125
  def _dist2_point_seg(px, py, ax, ay, bx, by) -> Fraction:
97
126
  """Exact squared distance from point to segment (all rational)."""
98
127
  dx, dy = bx - ax, by - ay
@@ -140,6 +169,12 @@ class DrilledSolid:
140
169
  raise ValueError(
141
170
  "bore crosses a lateral wall — general quadric "
142
171
  "booleans arrive at K2.1")
172
+ # The wall check above proves the bore straddles no wall; it does NOT
173
+ # say which side it is on. Without this, a bore placed entirely outside
174
+ # the footprint is recorded as a phantom hole and its volume silently
175
+ # subtracted from a solid it never touches.
176
+ if not _xy_inside_footprint(self.base, c.cx, c.cy):
177
+ raise ValueError("bore misses the solid in xy (nothing to drill)")
143
178
  for o in self.bores:
144
179
  if o.cx == c.cx and o.cy == c.cy:
145
180
  continue # coaxial stack (counterbore)
@@ -616,17 +651,23 @@ class AxisStack:
616
651
 
617
652
  class RevolveSolid:
618
653
  """A closed line-segment profile in the (r, z) half-plane revolved
619
- 360 degrees about 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
@@ -76,6 +76,9 @@ class SurdVal:
76
76
  o = self._co(o)
77
77
  return SurdVal(self.a - o.a, self.b - o.b, self._radical(o))
78
78
 
79
+ def __rsub__(self, o) -> "SurdVal": # o − self, for rational/int on the left
80
+ return self._co(o) - self
81
+
79
82
  def __mul__(self, o) -> "SurdVal":
80
83
  o = self._co(o)
81
84
  if self.b == 0 or o.b == 0:
@@ -89,12 +92,63 @@ class SurdVal:
89
92
 
90
93
  __rmul__ = __mul__
91
94
 
95
+ def __neg__(self) -> "SurdVal":
96
+ return SurdVal(-self.a, -self.b, self.d)
97
+
98
+ def __truediv__(self, o) -> "SurdVal":
99
+ if isinstance(o, (int, Fraction)):
100
+ r = F(o)
101
+ return SurdVal(self.a / r, self.b / r, self.d)
102
+ o = self._co(o)
103
+ if o.b == 0: # divide by a rational
104
+ return SurdVal(self.a / o.a, self.b / o.a, self.d)
105
+ # divide by (c+e√d) via the conjugate: ·(c−e√d)/(c²−e²d)
106
+ denom = o.a * o.a - o.b * o.b * o.d # rational, nonzero
107
+ num = self * SurdVal(o.a, -o.b, o.d)
108
+ return SurdVal(num.a / denom, num.b / denom, num.d)
109
+
110
+ def __rtruediv__(self, o) -> "SurdVal":
111
+ return self._co(o) / self
112
+
113
+ def _sign(self) -> int:
114
+ """Exact sign of a + b√d (d ≥ 1, √d > 0) — decides comparisons."""
115
+ if self.b == 0:
116
+ return (self.a > 0) - (self.a < 0)
117
+ if self.a == 0:
118
+ return (self.b > 0) - (self.b < 0)
119
+ sa = (self.a > 0) - (self.a < 0)
120
+ sb = (self.b > 0) - (self.b < 0)
121
+ if sa == sb: # both terms same sign
122
+ return sa
123
+ # opposite signs: compare magnitudes a² vs b²·d (squaring is monotone)
124
+ da, db = self.a * self.a, self.b * self.b * self.d
125
+ if da == db:
126
+ return 0
127
+ return sa if da > db else sb
128
+
129
+ def __lt__(self, o) -> bool:
130
+ return (self - self._co(o))._sign() < 0
131
+
132
+ def __le__(self, o) -> bool:
133
+ return (self - self._co(o))._sign() <= 0
134
+
135
+ def __gt__(self, o) -> bool:
136
+ return (self - self._co(o))._sign() > 0
137
+
138
+ def __ge__(self, o) -> bool:
139
+ return (self - self._co(o))._sign() >= 0
140
+
92
141
  def __eq__(self, o: object) -> bool:
93
142
  o = self._co(o)
94
143
  if self.b == 0 and o.b == 0:
95
144
  return self.a == o.a
96
145
  return self.a == o.a and self.b == o.b and self.d == o.d
97
146
 
147
+ def __hash__(self) -> int:
148
+ if self.b == 0: # a pure rational hashes as itself
149
+ return hash(self.a)
150
+ return hash((self.a, self.b, self.d))
151
+
98
152
  def __float__(self) -> float:
99
153
  return float(self.a) + float(self.b) * math.sqrt(self.d)
100
154
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.1
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
@@ -39,5 +40,6 @@ tests/test_k7_bsolid.py
39
40
  tests/test_k7_stepexport.py
40
41
  tests/test_k7_trim.py
41
42
  tests/test_k7_trimflux.py
43
+ tests/test_rotate.py
42
44
  tests/test_rust_oracle.py
43
45
  tests/test_scaffold.py
@@ -0,0 +1,134 @@
1
+ """Bosses and bored cylinders — a z-cylinder standing on a planar face
2
+ (tangent, measure-zero) and coaxial bores through solids of revolution.
3
+
4
+ A cylinder minus a coaxial bore is still a solid of revolution, so the result
5
+ stays an exact ℚ[π] object rather than degrading to a mesh.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections import defaultdict
12
+
13
+ import pytest
14
+
15
+ from forgekernel.brep import Solid
16
+ from forgekernel.quadric import Cyl, DisjointUnion, RevolveSolid, bore_cyl
17
+
18
+
19
+ def _nonmanifold(mesh):
20
+ ec = defaultdict(int)
21
+ for a, b, c in mesh["triangles"]:
22
+ for e in ((a, b), (b, c), (c, a)):
23
+ ec[tuple(sorted(e))] += 1
24
+ return sum(1 for n in ec.values() if n != 2)
25
+
26
+
27
+ def test_boss_standing_on_a_plate_is_a_tangent_union() -> None:
28
+ # the cylinder's base sits EXACTLY on the plate's top face: contact is
29
+ # measure-zero, so the union volume is exactly the sum.
30
+ plate = Solid.box(30, 30, 3)
31
+ boss = Cyl(15, 15, 4, 3, 9)
32
+ u = DisjointUnion([plate, boss])
33
+ assert float(u.volume()) == pytest.approx(30 * 30 * 3 + math.pi * 16 * 6)
34
+
35
+
36
+ def test_boss_sunk_into_the_plate_is_refused() -> None:
37
+ plate = Solid.box(30, 30, 3)
38
+ with pytest.raises(ValueError, match="overlaps the solid"):
39
+ DisjointUnion([plate, Cyl(15, 15, 4, 2, 9)]) # base below the top face
40
+
41
+
42
+ def test_cylinder_beside_the_plate_is_separated_by_a_side_face() -> None:
43
+ plate = Solid.box(10, 10, 10)
44
+ DisjointUnion([plate, Cyl(20, 5, 3, 0, 10)]) # clear in x: no raise
45
+
46
+
47
+ def test_non_convex_solids_refuse_rather_than_double_count() -> None:
48
+ """SOUNDNESS: the separating-plane test only holds for a convex solid.
49
+
50
+ In an L-prism, a bore buried in one arm lies outside the plane of the
51
+ OTHER arm's face — so a plane-only test would call it disjoint and silently
52
+ add its volume twice. Both predicates must refuse instead."""
53
+ from forgekernel import kernel as fk
54
+ from forgekernel.quadric import Sphere
55
+
56
+ ell = fk.prism([(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)], 5)
57
+ for buried in (Cyl(2, 7, 1, 1, 4), Sphere(2, 7, 2, 1)):
58
+ with pytest.raises(ValueError, match="non-convex"):
59
+ DisjointUnion([ell, buried])
60
+
61
+
62
+ def test_a_bore_outside_the_footprint_is_not_a_phantom_hole() -> None:
63
+ """SOUNDNESS: "the bore crosses no lateral wall" does not mean "the bore is
64
+ inside" — a bore entirely OUTSIDE the footprint crosses nothing either. It
65
+ used to be recorded as a hole, silently deleting volume from a solid the
66
+ tool never touched (a Ø4 tool 40 mm away removed 125.66 mm³ from a box)."""
67
+ from forgekernel.quadric import DrilledSolid
68
+
69
+ box = Solid.box(10, 10, 10)
70
+ with pytest.raises(ValueError, match="misses the solid in xy"):
71
+ DrilledSolid(box, []).cut(Cyl(50, 50, 2, 0, 10))
72
+ inside = DrilledSolid(box, []).cut(Cyl(5, 5, 2, 0, 10)) # a real hole
73
+ assert float(inside.volume()) == pytest.approx(1000 - math.pi * 4 * 10)
74
+
75
+
76
+ def test_cut_leaves_members_the_tool_misses_untouched() -> None:
77
+ # a post standing 50 mm clear of a plate; boring the POST must not shrink
78
+ # the plate (the tool never enters it in xy).
79
+ plate, post = Solid.box(100, 100, 10), Cyl(150, 50, 8, 0, 20)
80
+ out = DisjointUnion([plate, post]).cut(Cyl(150, 50, 4, 0, 20))
81
+ assert float(out.volume()) == pytest.approx(100000 + (64 - 16) * 20 * math.pi)
82
+
83
+
84
+ def test_coaxial_bore_of_a_cylinder_is_an_exact_revolve() -> None:
85
+ cyl = Cyl(0, 0, 10, 0, 2)
86
+ washer = bore_cyl(cyl, Cyl(0, 0, 4, 0, 2)) # through bore: annulus
87
+ assert isinstance(washer, RevolveSolid)
88
+ assert float(washer.volume()) == pytest.approx(math.pi * (100 - 16) * 2)
89
+ mesh = washer.tessellate(0.05)
90
+ assert _nonmanifold(mesh) == 0 # watertight tube
91
+
92
+ blind = bore_cyl(Cyl(0, 0, 4, 3, 9), Cyl(0, 0, 1, 4, 9)) # blind from top
93
+ assert float(blind.volume()) == pytest.approx(math.pi * 16 * 6 - math.pi * 1 * 5)
94
+
95
+ up = bore_cyl(Cyl(0, 0, 4, 0, 6), Cyl(0, 0, 1, -5, 2)) # blind from below
96
+ assert float(up.volume()) == pytest.approx(math.pi * 16 * 6 - math.pi * 1 * 2)
97
+
98
+
99
+ def test_bore_that_misses_in_z_leaves_the_cylinder_unchanged() -> None:
100
+ cyl = Cyl(0, 0, 4, 0, 6)
101
+ assert bore_cyl(cyl, Cyl(0, 0, 1, 10, 12)) is cyl
102
+
103
+
104
+ def test_non_representable_bores_refuse() -> None:
105
+ cyl = Cyl(0, 0, 4, 0, 6)
106
+ with pytest.raises(ValueError, match="not coaxial"):
107
+ bore_cyl(cyl, Cyl(1, 0, 1, 0, 6))
108
+ with pytest.raises(ValueError, match="not narrower"):
109
+ bore_cyl(cyl, Cyl(0, 0, 4, 0, 6))
110
+ with pytest.raises(ValueError, match="closed cavity"):
111
+ bore_cyl(cyl, Cyl(0, 0, 1, 2, 4)) # floats strictly inside
112
+
113
+
114
+ def test_cut_distributes_over_a_disjoint_union() -> None:
115
+ # the boss + pilot: (plate ∪ boss) ∖ pilot, pilot blind in the boss only
116
+ plate = Solid.box(30, 30, 3)
117
+ u = DisjointUnion([plate, Cyl(15, 15, 4, 3, 9)])
118
+ out = u.cut(Cyl(15, 15, 1, 4, 9))
119
+ expect = 30 * 30 * 3 + math.pi * 16 * 6 - math.pi * 1 * 5
120
+ assert float(out.volume()) == pytest.approx(expect)
121
+ # the plate member is untouched (the tool misses it in z)
122
+ assert any(isinstance(m, Solid) for m in out.members)
123
+ assert _nonmanifold(out.tessellate(0.05)) == 0
124
+
125
+
126
+ def test_revolve_solid_can_sit_off_axis() -> None:
127
+ loop = [(0, 0), (2, 0), (2, 5), (0, 5)] # a plain cylinder r=2
128
+ at_origin, moved = RevolveSolid(loop), RevolveSolid(loop, 7, -3)
129
+ assert float(at_origin.volume()) == float(moved.volume())
130
+ assert moved.centroid_f()[0] == pytest.approx(7.0)
131
+ assert moved.centroid_f()[1] == pytest.approx(-3.0)
132
+ lo, hi = moved.bbox()
133
+ assert (lo[0], hi[0]) == pytest.approx((5.0, 9.0))
134
+ assert (lo[1], hi[1]) == pytest.approx((-5.0, -1.0))
@@ -270,11 +270,26 @@ def test_disjoint_in_z_passes_despite_close_axes() -> None:
270
270
  assert u.volume() == PiVal(0, 1000)
271
271
 
272
272
 
273
- def 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,87 @@
1
+ """Exact rotation over ℚ[√d] — the K2.2 circular-pattern / diagonal-sketch-plane
2
+ enabler. Rotation is a rigid motion, so volume is preserved EXACTLY (rational),
3
+ and representable angles (multiples of 30°/45°) keep coordinates exact."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+
9
+ import pytest
10
+
11
+ from forgekernel import kernel as fk
12
+ from forgekernel.surd import SurdVal, sqrt_rational
13
+
14
+
15
+ def test_rotation_preserves_volume_exactly() -> None:
16
+ b = fk.box(10, 4, 3)
17
+ for axis, deg in [((0, 0, 1), 60), ((0, 0, 1), 45), ((1, 0, 0), 30),
18
+ ((1, 1, 1), 120)]:
19
+ r = fk.rotate(b, axis, deg)
20
+ assert r.volume() == 120 # exact rational, not a float
21
+
22
+
23
+ def test_six_sixty_degree_rotations_are_the_identity() -> None:
24
+ b = fk.box(7, 5, 2)
25
+ acc = b
26
+ for _ in range(6):
27
+ acc = fk.rotate(acc, (0, 0, 1), 60)
28
+ orig = {tuple(float(c) for c in v) for p in b.polys for v in p.verts}
29
+ back = {tuple(float(c) for c in v) for p in acc.polys for v in p.verts}
30
+ assert orig == back # 6·60° = 360° back to start, exactly
31
+
32
+
33
+ def test_diagonal_120_is_a_rational_axis_permutation() -> None:
34
+ p = fk.rotate(fk.box(3, 5, 7), (1, 1, 1), 120)
35
+ # 120° about the body diagonal cyclically permutes the axes — the surds
36
+ # cancel, leaving purely rational coordinates.
37
+ assert all(not isinstance(c, SurdVal) or c.b == 0
38
+ for poly in p.polys for v in poly.verts for c in v)
39
+ assert p.volume() == 105
40
+
41
+
42
+ def test_unrepresentable_angle_is_refused() -> None:
43
+ with pytest.raises(ValueError):
44
+ fk.rotate(fk.box(2, 2, 2), (0, 0, 1), 37)
45
+
46
+
47
+ def test_boolean_union_of_rotated_copies_is_watertight_and_exact() -> None:
48
+ # the circular-pattern path: union disjoint rotated copies (surd coords)
49
+ seed = fk.translate(fk.box(5, 2, 3), 10, -1, 0)
50
+ out = seed
51
+ for i in range(1, 6):
52
+ out = fk.boolean("union", out, fk.rotate(seed, (0, 0, 1), 60 * i))
53
+ assert out.volume() == 6 * 5 * 2 * 3 # disjoint -> exact sum, no leaks
54
+ assert out.watertight_violations() == []
55
+
56
+
57
+ def test_overlapping_boolean_mixes_rational_and_surd_coords() -> None:
58
+ # the case disjoint patterns miss: an axis-aligned (ℚ) body booleaned with
59
+ # an OVERLAPPING rotated (ℚ[√3]) body — BSP clipping makes mixed-type
60
+ # polygons, so Fraction−SurdVal must resolve (needs SurdVal.__rsub__).
61
+ from fractions import Fraction as F
62
+
63
+ a = fk.box(2, 2, 2)
64
+ b = fk.rotate(fk.translate(fk.box(2, 2, 2), F(1, 2), F(1, 2), 0), (0, 0, 1), 30)
65
+ vu = float(fk.boolean("union", a, b).volume())
66
+ vi = float(fk.boolean("intersect", a, b).volume())
67
+ vc = float(fk.boolean("cut", a, b).volume())
68
+ assert vi > 0 # they genuinely overlap
69
+ assert abs(vu + vi - 16) < 1e-9 # |A∪B| + |A∩B| = |A| + |B|
70
+ assert abs(vc - (8 - vi)) < 1e-9 # |A∖B| = |A| − |A∩B|
71
+ for op in ("union", "cut", "intersect"):
72
+ assert fk.boolean(op, a, b).watertight_violations() == []
73
+
74
+
75
+ def test_surd_is_an_ordered_field() -> None:
76
+ import random
77
+ random.seed(0)
78
+ for _ in range(2000):
79
+ d = random.choice([2, 3, 5, 6])
80
+ from fractions import Fraction as F
81
+ x = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
82
+ F(random.randint(-9, 9), random.randint(1, 4)), d)
83
+ y = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
84
+ F(random.randint(-9, 9), random.randint(1, 4)), d)
85
+ if abs(float(x) - float(y)) > 1e-9:
86
+ assert (x < y) == (float(x) < float(y))
87
+ assert (x / y) * y == x if float(y) != 0 else True
File without changes
File without changes
File without changes
File without changes