forgekernel 0.9.0__tar.gz → 0.9.2__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 (46) hide show
  1. {forgekernel-0.9.0/src/forgekernel.egg-info → forgekernel-0.9.2}/PKG-INFO +1 -1
  2. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/brep.py +17 -29
  4. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/exact.py +9 -16
  5. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/kernel.py +63 -0
  6. forgekernel-0.9.2/src/forgekernel/mesh2d.py +365 -0
  7. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/quadric.py +222 -0
  8. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/surd.py +54 -0
  9. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/tess.py +15 -0
  10. {forgekernel-0.9.0 → forgekernel-0.9.2/src/forgekernel.egg-info}/PKG-INFO +1 -1
  11. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/SOURCES.txt +2 -0
  12. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_hlr.py +88 -0
  13. forgekernel-0.9.2/tests/test_rotate.py +87 -0
  14. {forgekernel-0.9.0 → forgekernel-0.9.2}/LICENSE +0 -0
  15. {forgekernel-0.9.0 → forgekernel-0.9.2}/README.md +0 -0
  16. {forgekernel-0.9.0 → forgekernel-0.9.2}/pyproject.toml +0 -0
  17. {forgekernel-0.9.0 → forgekernel-0.9.2}/setup.cfg +0 -0
  18. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/bsolid.py +0 -0
  19. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/csg.py +0 -0
  20. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/curve.py +0 -0
  21. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/hlr.py +0 -0
  22. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/interval.py +0 -0
  23. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/io.py +0 -0
  24. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/loft.py +0 -0
  25. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/nurbs.py +0 -0
  26. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/profile2d.py +0 -0
  27. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/ssi.py +0 -0
  28. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/stepio.py +0 -0
  29. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/surfacing.py +0 -0
  30. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/trim.py +0 -0
  31. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  32. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/requires.txt +0 -0
  33. {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/top_level.txt +0 -0
  34. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k1_exact.py +0 -0
  35. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k37_loft.py +0 -0
  36. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k38_profile.py +0 -0
  37. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_curves.py +0 -0
  38. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_ssi.py +0 -0
  39. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_step.py +0 -0
  40. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k6_surfacing.py +0 -0
  41. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_bsolid.py +0 -0
  42. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_stepexport.py +0 -0
  43. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_trim.py +0 -0
  44. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_trimflux.py +0 -0
  45. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_rust_oracle.py +0 -0
  46. {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.0
3
+ Version: 0.9.2
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.0"
7
+ __version__ = "0.9.2"
@@ -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)."""
@@ -0,0 +1,365 @@
1
+ """2D polygon triangulation with holes, for display meshes.
2
+
3
+ Tessellation is a bounded-error *view* of the exact solid (ADR-0019: meshing is
4
+ a display property, floats are legal). ``triangulate(outer, holes)`` returns a
5
+ shared vertex list plus triangle indices, used to cap a drilled solid's top and
6
+ bottom faces around its bores.
7
+
8
+ This is a faithful port of the mapbox/earcut ear-clipping algorithm (ISC) — a
9
+ robust linked-list ear clipper with hole elimination. A hand-rolled version
10
+ tore on multi-hole inputs (bolt patterns) because two holes bridging to the same
11
+ outer vertex need distinct linked-list nodes and angular ordering; earcut gets
12
+ that right. The O(n²) ear scan (no z-order hash) is ample for a face's facet
13
+ count.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ Pt = tuple[float, float]
19
+
20
+
21
+ class _Node:
22
+ __slots__ = ("i", "x", "y", "prev", "next", "steiner")
23
+
24
+ def __init__(self, i: int, x: float, y: float) -> None:
25
+ self.i = i
26
+ self.x = x
27
+ self.y = y
28
+ self.prev = None
29
+ self.next = None
30
+ self.steiner = False
31
+
32
+
33
+ def triangulate(outer: list[Pt], holes: list[list[Pt]] = ()) -> tuple[list[Pt], list[tuple[int, int, int]]]:
34
+ """Triangulate ``outer`` minus ``holes``; returns ``(points, triangles)``
35
+ with triangle indices into points."""
36
+ pts: list[Pt] = list(outer)
37
+ hole_indices = []
38
+ for h in holes:
39
+ hole_indices.append(len(pts))
40
+ pts.extend(h)
41
+
42
+ flat = [c for p in pts for c in p]
43
+ tris: list[int] = []
44
+ outer_node = _linked_list(flat, 0, len(outer) * 2, 2, True)
45
+ if outer_node is None or outer_node.next is outer_node.prev:
46
+ return pts, []
47
+ if hole_indices:
48
+ outer_node = _eliminate_holes(flat, hole_indices, outer_node, 2)
49
+ _earcut_linked(outer_node, tris, 2)
50
+ return pts, [(tris[i], tris[i + 1], tris[i + 2]) for i in range(0, len(tris), 3)]
51
+
52
+
53
+ def _linked_list(data, start, end, dim, clockwise):
54
+ last = None
55
+ if clockwise == (_signed_area(data, start, end, dim) > 0):
56
+ for i in range(start, end, dim):
57
+ last = _insert_node(i // dim, data[i], data[i + 1], last)
58
+ else:
59
+ for i in range(end - dim, start - 1, -dim):
60
+ last = _insert_node(i // dim, data[i], data[i + 1], last)
61
+ if last is not None and _equals(last, last.next):
62
+ _remove_node(last)
63
+ last = last.next
64
+ return last
65
+
66
+
67
+ def _filter_points(start, end=None):
68
+ if start is None:
69
+ return start
70
+ if end is None:
71
+ end = start
72
+ p = start
73
+ again = True
74
+ while again or p is not end:
75
+ again = False
76
+ if not p.steiner and (_equals(p, p.next) or _area(p.prev, p, p.next) == 0):
77
+ _remove_node(p)
78
+ p = end = p.prev
79
+ if p is p.next:
80
+ break
81
+ again = True
82
+ else:
83
+ p = p.next
84
+ return end
85
+
86
+
87
+ def _earcut_linked(ear, triangles, dim, pass_=0):
88
+ if ear is None:
89
+ return
90
+ ear = _filter_points(ear)
91
+ stop = ear
92
+ while ear.prev is not ear.next:
93
+ prev, nxt = ear.prev, ear.next
94
+ if _is_ear(ear):
95
+ triangles.append(prev.i)
96
+ triangles.append(ear.i)
97
+ triangles.append(nxt.i)
98
+ _remove_node(ear)
99
+ ear = nxt.next
100
+ stop = nxt.next
101
+ continue
102
+ ear = nxt
103
+ if ear is stop:
104
+ if pass_ == 0:
105
+ _earcut_linked(_filter_points(ear), triangles, dim, 1)
106
+ elif pass_ == 1:
107
+ ear = _cure_local_intersections(_filter_points(ear), triangles)
108
+ _earcut_linked(ear, triangles, dim, 2)
109
+ elif pass_ == 2:
110
+ _split_earcut(ear, triangles, dim)
111
+ return
112
+
113
+
114
+ def _is_ear(ear):
115
+ a, b, c = ear.prev, ear, ear.next
116
+ if _area(a, b, c) >= 0:
117
+ return False # reflex
118
+ p = c.next
119
+ while p is not a:
120
+ if (_point_in_triangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y)
121
+ and _area(p.prev, p, p.next) >= 0):
122
+ return False
123
+ p = p.next
124
+ return True
125
+
126
+
127
+ def _cure_local_intersections(start, triangles):
128
+ p = start
129
+ while True:
130
+ a, b = p.prev, p.next.next
131
+ if (not _equals(a, b) and _intersects(a, p, p.next, b)
132
+ and _locally_inside(a, b) and _locally_inside(b, a)):
133
+ triangles.append(a.i)
134
+ triangles.append(p.i)
135
+ triangles.append(b.i)
136
+ _remove_node(p)
137
+ _remove_node(p.next)
138
+ p = start = b
139
+ p = p.next
140
+ if p is start:
141
+ break
142
+ return _filter_points(p)
143
+
144
+
145
+ def _split_earcut(start, triangles, dim):
146
+ a = start
147
+ while True:
148
+ b = a.next.next
149
+ while b is not a.prev:
150
+ if a.i != b.i and _is_valid_diagonal(a, b):
151
+ c = _split_polygon(a, b)
152
+ a = _filter_points(a, a.next)
153
+ c = _filter_points(c, c.next)
154
+ _earcut_linked(a, triangles, dim, 0)
155
+ _earcut_linked(c, triangles, dim, 0)
156
+ return
157
+ b = b.next
158
+ a = a.next
159
+ if a is start:
160
+ break
161
+
162
+
163
+ def _eliminate_holes(data, hole_indices, outer_node, dim):
164
+ queue = []
165
+ for i, start in enumerate(hole_indices):
166
+ end = hole_indices[i + 1] * dim if i + 1 < len(hole_indices) else len(data)
167
+ lst = _linked_list(data, start * dim, end, dim, False)
168
+ if lst is lst.next:
169
+ lst.steiner = True
170
+ queue.append(_left_most(lst))
171
+ queue.sort(key=lambda n: n.x)
172
+ for hole in queue:
173
+ outer_node = _eliminate_hole(hole, outer_node)
174
+ return outer_node
175
+
176
+
177
+ def _eliminate_hole(hole, outer_node):
178
+ bridge = _find_hole_bridge(hole, outer_node)
179
+ if bridge is None:
180
+ return outer_node
181
+ bridge_reverse = _split_polygon(bridge, hole)
182
+ _filter_points(bridge_reverse, bridge_reverse.next)
183
+ return _filter_points(bridge, bridge.next)
184
+
185
+
186
+ def _find_hole_bridge(hole, outer_node):
187
+ p = outer_node
188
+ hx, hy = hole.x, hole.y
189
+ qx = -float("inf")
190
+ m = None
191
+ # find the outer edge to the left of the hole point that the +x ray hits
192
+ while True:
193
+ if hy <= p.y and hy >= p.next.y and p.next.y != p.y:
194
+ x = p.x + (hy - p.y) / (p.next.y - p.y) * (p.next.x - p.x)
195
+ if hx >= x > qx:
196
+ qx = x
197
+ m = p if p.x < p.next.x else p.next
198
+ if x == hx:
199
+ return m
200
+ p = p.next
201
+ if p is outer_node:
202
+ break
203
+ if m is None:
204
+ return None
205
+ # the bridge must not cross the polygon: pick the reflex vertex of minimum
206
+ # angle (or minimum x) inside the triangle (hole, (qx,hy), m)
207
+ stop = m
208
+ mx, my = m.x, m.y
209
+ tan_min = float("inf")
210
+ p = m
211
+ while True:
212
+ cond = hx > mx if hy < my else hx < mx
213
+ if (min(hx, qx) <= p.x <= max(hx, qx) and hx != p.x
214
+ and _point_in_triangle(hx if hy < my else qx, hy,
215
+ mx, my,
216
+ qx if hy < my else hx, hy,
217
+ p.x, p.y)):
218
+ tan = abs(hy - p.y) / (hx - p.x) if hx != p.x else float("inf")
219
+ if _locally_inside(p, hole) and (tan < tan_min or (
220
+ tan == tan_min and (p.x > m.x or (p.x == m.x and _sector_contains(m, p))))):
221
+ m = p
222
+ tan_min = tan
223
+ p = p.next
224
+ if p is stop:
225
+ break
226
+ return m
227
+
228
+
229
+ def _sector_contains(m, p):
230
+ return _area(m.prev, m, p.prev) < 0 and _area(p.next, m, m.next) < 0
231
+
232
+
233
+ def _split_polygon(a, b):
234
+ a2 = _Node(a.i, a.x, a.y)
235
+ b2 = _Node(b.i, b.x, b.y)
236
+ an, bp = a.next, b.prev
237
+ a.next = b
238
+ b.prev = a
239
+ a2.next = an
240
+ an.prev = a2
241
+ b2.next = a2
242
+ a2.prev = b2
243
+ bp.next = b2
244
+ b2.prev = bp
245
+ return b2
246
+
247
+
248
+ def _insert_node(i, x, y, last):
249
+ p = _Node(i, x, y)
250
+ if last is None:
251
+ p.prev = p
252
+ p.next = p
253
+ else:
254
+ p.next = last.next
255
+ p.prev = last
256
+ last.next.prev = p
257
+ last.next = p
258
+ return p
259
+
260
+
261
+ def _remove_node(p):
262
+ p.next.prev = p.prev
263
+ p.prev.next = p.next
264
+
265
+
266
+ def _left_most(start):
267
+ p = start
268
+ leftmost = start
269
+ p = p.next
270
+ while p is not start:
271
+ if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y):
272
+ leftmost = p
273
+ p = p.next
274
+ return leftmost
275
+
276
+
277
+ def _signed_area(data, start, end, dim):
278
+ s = 0.0
279
+ j = end - dim
280
+ for i in range(start, end, dim):
281
+ s += (data[j] - data[i]) * (data[i + 1] + data[j + 1])
282
+ j = i
283
+ return s
284
+
285
+
286
+ def _area(p, q, r):
287
+ return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
288
+
289
+
290
+ def _equals(p1, p2):
291
+ return p1.x == p2.x and p1.y == p2.y
292
+
293
+
294
+ def _sign(n):
295
+ return (n > 0) - (n < 0)
296
+
297
+
298
+ def _on_segment(p, q, r):
299
+ return (min(p.x, r.x) <= q.x <= max(p.x, r.x)
300
+ and min(p.y, r.y) <= q.y <= max(p.y, r.y))
301
+
302
+
303
+ def _intersects(p1, q1, p2, q2):
304
+ o1 = _sign(_area(p1, q1, p2))
305
+ o2 = _sign(_area(p1, q1, q2))
306
+ o3 = _sign(_area(p2, q2, p1))
307
+ o4 = _sign(_area(p2, q2, q1))
308
+ if o1 != o2 and o3 != o4:
309
+ return True
310
+ if o1 == 0 and _on_segment(p1, p2, q1):
311
+ return True
312
+ if o2 == 0 and _on_segment(p1, q2, q1):
313
+ return True
314
+ if o3 == 0 and _on_segment(p2, p1, q2):
315
+ return True
316
+ if o4 == 0 and _on_segment(p2, q1, q2):
317
+ return True
318
+ return False
319
+
320
+
321
+ def _intersects_polygon(a, b):
322
+ p = a
323
+ while True:
324
+ if (p.i != a.i and p.next.i != a.i and p.i != b.i and p.next.i != b.i
325
+ and _intersects(p, p.next, a, b)):
326
+ return True
327
+ p = p.next
328
+ if p is a:
329
+ break
330
+ return False
331
+
332
+
333
+ def _locally_inside(a, b):
334
+ if _area(a.prev, a, a.next) < 0:
335
+ return _area(a, b, a.next) >= 0 and _area(a, a.prev, b) >= 0
336
+ return _area(a, b, a.prev) < 0 or _area(a, a.next, b) < 0
337
+
338
+
339
+ def _middle_inside(a, b):
340
+ p = a
341
+ inside = False
342
+ px, py = (a.x + b.x) / 2, (a.y + b.y) / 2
343
+ while True:
344
+ if ((p.y > py) != (p.next.y > py) and p.next.y != p.y
345
+ and px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x):
346
+ inside = not inside
347
+ p = p.next
348
+ if p is a:
349
+ break
350
+ return inside
351
+
352
+
353
+ def _is_valid_diagonal(a, b):
354
+ return (a.next.i != b.i and a.prev.i != b.i
355
+ and not _intersects_polygon(a, b)
356
+ and ((_locally_inside(a, b) and _locally_inside(b, a) and _middle_inside(a, b)
357
+ and (_area(a.prev, a, b.prev) != 0 or _area(a, b.prev, b) != 0))
358
+ or (_equals(a, b) and _area(a.prev, a, a.next) > 0
359
+ and _area(b.prev, b, b.next) > 0)))
360
+
361
+
362
+ def _point_in_triangle(ax, ay, bx, by, cx, cy, px, py):
363
+ return ((cx - px) * (ay - py) - (ax - px) * (cy - py) >= 0
364
+ and (ax - px) * (by - py) - (bx - px) * (ay - py) >= 0
365
+ and (bx - px) * (cy - py) - (cx - px) * (by - py) >= 0)
@@ -83,6 +83,15 @@ class Cyl:
83
83
  return ((self.cx - self.r, self.cy - self.r, self.z0),
84
84
  (self.cx + self.r, self.cy + self.r, self.z1))
85
85
 
86
+ def tessellate(self, deflection: float = 0.2) -> dict:
87
+ """Display mesh (walls + both caps) via the surface-of-revolution
88
+ lathe — floats are legal for a bounded-error view (ADR-0019)."""
89
+ from forgekernel.tess import lathe
90
+
91
+ r, z0, z1 = float(self.r), float(self.z0), float(self.z1)
92
+ profile = [(0.0, z0), (r, z0), (r, z1), (0.0, z1)]
93
+ return lathe(profile, deflection, float(self.cx), float(self.cy))
94
+
86
95
 
87
96
  def _dist2_point_seg(px, py, ax, ay, bx, by) -> Fraction:
88
97
  """Exact squared distance from point to segment (all rational)."""
@@ -197,6 +206,176 @@ class DrilledSolid:
197
206
  "axis_origin": [float(c.cx), float(c.cy), float(c.z0)]}
198
207
  for c in self.bores]
199
208
 
209
+ def tessellate(self, deflection: float = 0.2) -> dict:
210
+ """A watertight display mesh: the base's faces (top/bottom capped
211
+ around the bores), the bore walls (stepped for coaxial counterbores),
212
+ the counterbore shoulder rings, and any blind-hole end caps. Floats are
213
+ legal here — this approximates the exact solid to ``deflection`` chord
214
+ error (ADR-0019: meshing is a display property)."""
215
+ import math
216
+
217
+ from forgekernel.mesh2d import triangulate
218
+
219
+ verts: list[list[float]] = []
220
+ tris: list[list[int]] = []
221
+ index: dict = {}
222
+
223
+ def V(p) -> int:
224
+ k = (round(p[0], 9), round(p[1], 9), round(p[2], 9))
225
+ if k not in index:
226
+ index[k] = len(verts)
227
+ verts.append([float(p[0]), float(p[1]), float(p[2])])
228
+ return index[k]
229
+
230
+ def tri(a, b, c, outward) -> None:
231
+ ia, ib, ic = V(a), V(b), V(c)
232
+ if ia == ib or ib == ic or ia == ic:
233
+ return
234
+ n = ((b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]),
235
+ (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]),
236
+ (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]))
237
+ if n[0] * outward[0] + n[1] * outward[1] + n[2] * outward[2] < 0:
238
+ ib, ic = ic, ib
239
+ tris.append([ia, ib, ic])
240
+
241
+ def _segs(r):
242
+ return (max(24, int(math.ceil(math.pi / math.acos(max(-1.0, 1.0 - deflection / r)))))
243
+ if r > deflection else 24)
244
+
245
+ def circle(cx, cy, r, n):
246
+ return [(cx + r * math.cos(2 * math.pi * k / n),
247
+ cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]
248
+
249
+ (_, _, bz0), (_, _, bz1) = self.base.bbox()
250
+ zmin, zmax = float(bz0), float(bz1)
251
+
252
+ # coaxial bore groups -> z-bands with the outermost radius per band
253
+ from collections import defaultdict
254
+ groups: dict = defaultdict(list)
255
+ for c in self.bores:
256
+ groups[(float(c.cx), float(c.cy))].append(c)
257
+ axis_bands = {}
258
+ for axis, cyls in groups.items():
259
+ zs = sorted({z for c in cyls for z in (float(c.z0), float(c.z1))})
260
+ bands = []
261
+ for za, zb in zip(zs, zs[1:]):
262
+ zmid = (za + zb) / 2
263
+ rs = [float(c.r) for c in cyls
264
+ if float(c.z0) - 1e-9 <= zmid <= float(c.z1) + 1e-9]
265
+ if rs:
266
+ bands.append((za, zb, max(rs)))
267
+ if bands:
268
+ axis_bands[axis] = bands
269
+ # ONE segment count per coaxial axis (from its widest radius) so every
270
+ # ring on that axis — cap hole, wall, shoulder, blind cap — shares
271
+ # vertices and the seams stay watertight (no T-junctions across bands).
272
+ axis_segs = {axis: _segs(max(r for _, _, r in bands))
273
+ for axis, bands in axis_bands.items()}
274
+
275
+ def _in_loop(pt, loop) -> bool:
276
+ x, y = pt
277
+ inside = False
278
+ n = len(loop)
279
+ for i in range(n):
280
+ (x1, y1), (x2, y2) = loop[i], loop[(i + 1) % n]
281
+ if (y1 > y) != (y2 > y):
282
+ xc = x1 + (y - y1) / (y2 - y1) * (x2 - x1)
283
+ if x < xc:
284
+ inside = not inside
285
+ return inside
286
+
287
+ # -- base faces: z-caps get holes, lateral faces are hole-free ---------
288
+ zcaps: dict = defaultdict(list)
289
+ for p in self.base.polys:
290
+ nrm = p.plane.n
291
+ nx, ny, nz = float(nrm[0]), float(nrm[1]), float(nrm[2])
292
+ if nx == 0 and ny == 0:
293
+ z = float(p.verts[0][2])
294
+ zcaps[(round(z, 9), 1 if nz > 0 else -1)].append(
295
+ [(float(v[0]), float(v[1])) for v in p.verts])
296
+ else:
297
+ vs = [(float(v[0]), float(v[1]), float(v[2])) for v in p.verts]
298
+ for i in range(1, len(vs) - 1):
299
+ tri(vs[0], vs[i], vs[i + 1], (nx, ny, nz))
300
+
301
+ def _cap_loops(polys_xy):
302
+ def key(p):
303
+ return (round(p[0], 9), round(p[1], 9))
304
+ present, coords = set(), {}
305
+ for poly in polys_xy:
306
+ m = len(poly)
307
+ for i in range(m):
308
+ a, b = poly[i], poly[(i + 1) % m]
309
+ coords[key(a)] = a
310
+ coords[key(b)] = b
311
+ present.add((key(a), key(b)))
312
+ nxt = {a: b for (a, b) in present if (b, a) not in present}
313
+ loops, used = [], set()
314
+ for start in list(nxt):
315
+ if start in used:
316
+ continue
317
+ loop, cur = [], start
318
+ while cur in nxt and cur not in used:
319
+ used.add(cur)
320
+ loop.append(coords[cur])
321
+ cur = nxt[cur]
322
+ if len(loop) >= 3:
323
+ loops.append(loop)
324
+ return loops
325
+
326
+ for (z, sign), polys in zcaps.items():
327
+ holes = []
328
+ for axis, bands in axis_bands.items():
329
+ r = 0.0
330
+ if abs(bands[-1][1] - z) < 1e-9:
331
+ r = bands[-1][2] # axis reaches this (top) cap
332
+ elif abs(bands[0][0] - z) < 1e-9:
333
+ r = bands[0][2] # ... or this (bottom) cap
334
+ if r > 0:
335
+ holes.append((axis, circle(axis[0], axis[1], r, axis_segs[axis])))
336
+ for loop in _cap_loops(polys):
337
+ hs = [h for (ax, h) in holes if _in_loop(ax, loop)]
338
+ pts2, t2 = triangulate(loop, hs)
339
+ out = (0.0, 0.0, float(sign))
340
+ for a, b, c in t2:
341
+ tri((pts2[a][0], pts2[a][1], z), (pts2[b][0], pts2[b][1], z),
342
+ (pts2[c][0], pts2[c][1], z), out)
343
+
344
+ # -- bore walls, counterbore shoulders, blind end caps -----------------
345
+ for (cx, cy), bands in axis_bands.items():
346
+ n = axis_segs[(cx, cy)] # one resolution for the whole stack
347
+ for za, zb, r in bands:
348
+ ring = circle(cx, cy, r, n)
349
+ for i in range(n):
350
+ a, b = ring[i], ring[(i + 1) % n]
351
+ outw = (cx - (a[0] + b[0]) / 2, cy - (a[1] + b[1]) / 2, 0.0)
352
+ tri((a[0], a[1], za), (b[0], b[1], za), (b[0], b[1], zb), outw)
353
+ tri((a[0], a[1], za), (b[0], b[1], zb), (a[0], a[1], zb), outw)
354
+ for (za, zb, r0), (zb2, zc, r1) in zip(bands, bands[1:]):
355
+ if abs(r0 - r1) < 1e-12:
356
+ continue
357
+ rin, rout = min(r0, r1), max(r0, r1)
358
+ out = (0.0, 0.0, 1.0 if r1 > r0 else -1.0)
359
+ ci, co = circle(cx, cy, rin, n), circle(cx, cy, rout, n)
360
+ for i in range(n): # same n -> rings share θ, seams seal
361
+ ai, bi = ci[i], ci[(i + 1) % n]
362
+ ao, bo = co[i], co[(i + 1) % n]
363
+ tri((ai[0], ai[1], zb), (ao[0], ao[1], zb), (bo[0], bo[1], zb), out)
364
+ tri((ai[0], ai[1], zb), (bo[0], bo[1], zb), (bi[0], bi[1], zb), out)
365
+ zlo, zhi = bands[0][0], bands[-1][1]
366
+ if zlo > zmin + 1e-9: # blind at the bottom -> end cap
367
+ ring = circle(cx, cy, bands[0][2], n)
368
+ for i in range(n):
369
+ a, b = ring[i], ring[(i + 1) % n]
370
+ tri((cx, cy, zlo), (a[0], a[1], zlo), (b[0], b[1], zlo), (0, 0, 1))
371
+ if zhi < zmax - 1e-9: # blind at the top -> end cap
372
+ ring = circle(cx, cy, bands[-1][2], n)
373
+ for i in range(n):
374
+ a, b = ring[i], ring[(i + 1) % n]
375
+ tri((cx, cy, zhi), (a[0], a[1], zhi), (b[0], b[1], zhi), (0, 0, -1))
376
+
377
+ return {"vertices": verts, "triangles": tris}
378
+
200
379
 
201
380
  @dataclass(frozen=True)
202
381
  class Sphere:
@@ -216,6 +395,40 @@ class Sphere:
216
395
  def translated(self, x, y, z) -> "Sphere":
217
396
  return Sphere(self.cx + F(x), self.cy + F(y), self.cz + F(z), self.r)
218
397
 
398
+ def tessellate(self, deflection: float = 0.2) -> dict:
399
+ """A UV-sphere display mesh with collapsed poles (floats legal)."""
400
+ import math
401
+
402
+ r, cx, cy, cz = float(self.r), float(self.cx), float(self.cy), float(self.cz)
403
+ seg = (max(8, int(math.ceil(math.pi / math.acos(max(-1.0, 1.0 - deflection / r)))))
404
+ if r > deflection else 8)
405
+ nlat, nlon = max(4, seg), max(8, 2 * seg)
406
+ verts, tris = [], []
407
+ top = len(verts)
408
+ verts.append([cx, cy, cz + r])
409
+ rings = []
410
+ for i in range(1, nlat):
411
+ theta = math.pi * i / nlat
412
+ rings.append(len(verts))
413
+ for j in range(nlon):
414
+ phi = 2 * math.pi * j / nlon
415
+ verts.append([cx + r * math.sin(theta) * math.cos(phi),
416
+ cy + r * math.sin(theta) * math.sin(phi),
417
+ cz + r * math.cos(theta)])
418
+ bot = len(verts)
419
+ verts.append([cx, cy, cz - r])
420
+ for j in range(nlon): # top fan (outward, CCW from outside)
421
+ tris.append([top, rings[0] + j, rings[0] + (j + 1) % nlon])
422
+ for k in range(len(rings) - 1): # middle quads
423
+ a, b = rings[k], rings[k + 1]
424
+ for j in range(nlon):
425
+ j2 = (j + 1) % nlon
426
+ tris.append([a + j, b + j, b + j2])
427
+ tris.append([a + j, b + j2, a + j2])
428
+ for j in range(nlon): # bottom fan
429
+ tris.append([bot, rings[-1] + (j + 1) % nlon, rings[-1] + j])
430
+ return {"vertices": verts, "triangles": tris}
431
+
219
432
 
220
433
  @dataclass(frozen=True)
221
434
  class Cone:
@@ -238,6 +451,15 @@ class Cone:
238
451
  return Cone(self.cx + F(x), self.cy + F(y), self.r1, self.r2,
239
452
  self.z0 + F(z), self.z1 + F(z))
240
453
 
454
+ def tessellate(self, deflection: float = 0.2) -> dict:
455
+ """Display mesh (frustum wall + caps) via the lathe (floats legal)."""
456
+ from forgekernel.tess import lathe
457
+
458
+ r1, r2 = float(self.r1), float(self.r2)
459
+ z0, z1 = float(self.z0), float(self.z1)
460
+ profile = [(0.0, z0), (r1, z0), (r2, z1), (0.0, z1)]
461
+ return lathe(profile, deflection, float(self.cx), float(self.cy))
462
+
241
463
 
242
464
  class _Quad:
243
465
  """Exact quadratic q(z) = a z^2 + b z + c — the r^2 profile of every
@@ -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
 
@@ -56,9 +56,24 @@ def lathe(profile_rz: list[tuple], deflection: float = 0.2,
56
56
  else:
57
57
  tris.append([a0 + k, b0 + k, b0 + kn])
58
58
  tris.append([a0 + k, b0 + kn, a0 + kn])
59
+ # orient outward: the revolved profile's winding depends on its direction,
60
+ # so flip the whole mesh if the signed volume came out negative (inward).
61
+ if _signed_volume(verts, tris) < 0:
62
+ tris = [[t[0], t[2], t[1]] for t in tris]
59
63
  return {"vertices": verts, "triangles": tris}
60
64
 
61
65
 
66
+ def _signed_volume(verts: list, tris: list) -> float:
67
+ total = 0.0
68
+ for a, b, c in tris:
69
+ ax, ay, az = verts[a]
70
+ bx, by, bz = verts[b]
71
+ cx, cy, cz = verts[c]
72
+ total += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx)
73
+ + az * (bx * cy - by * cx))
74
+ return total
75
+
76
+
62
77
  def mesh_volume(mesh: dict) -> float:
63
78
  """Signed volume of a triangle mesh (divergence theorem) — the test
64
79
  hook proving the mesh approximates the exact solid."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.0
3
+ Version: 0.9.2
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>
@@ -12,6 +12,7 @@ src/forgekernel/interval.py
12
12
  src/forgekernel/io.py
13
13
  src/forgekernel/kernel.py
14
14
  src/forgekernel/loft.py
15
+ src/forgekernel/mesh2d.py
15
16
  src/forgekernel/nurbs.py
16
17
  src/forgekernel/profile2d.py
17
18
  src/forgekernel/quadric.py
@@ -38,5 +39,6 @@ tests/test_k7_bsolid.py
38
39
  tests/test_k7_stepexport.py
39
40
  tests/test_k7_trim.py
40
41
  tests/test_k7_trimflux.py
42
+ tests/test_rotate.py
41
43
  tests/test_rust_oracle.py
42
44
  tests/test_scaffold.py
@@ -109,6 +109,94 @@ def test_section_through_drilled_hole_splits_into_two_loops() -> None:
109
109
  assert spans == [(0, 3), (7, 10)] # slot clears Y∈(3,7)
110
110
 
111
111
 
112
+ def _nonmanifold(mesh):
113
+ from collections import defaultdict
114
+ ec = defaultdict(int)
115
+ for a, b, c in mesh["triangles"]:
116
+ for e in ((a, b), (b, c), (c, a)):
117
+ ec[tuple(sorted(e))] += 1
118
+ return sum(1 for n in ec.values() if n != 2)
119
+
120
+
121
+ def _signed_volume(mesh):
122
+ v = mesh["vertices"]
123
+ t = 0.0
124
+ for a, b, c in mesh["triangles"]:
125
+ ax, ay, az = v[a]
126
+ bx, by, bz = v[b]
127
+ cx, cy, cz = v[c]
128
+ t += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)
129
+ return t / 6.0
130
+
131
+
132
+ def test_drilled_solid_tessellates_watertight() -> None:
133
+ # bolt patterns (multiple holes) and counterbores with mismatched facet
134
+ # buckets are the layouts that broke the first cut — pin them.
135
+ bolt_col = DrilledSolid(Solid.box(100, 60, 20), []).cut(
136
+ Cyl(50, 20, 8, 0, 20)).cut(Cyl(50, 45, 8, 0, 20))
137
+ grid = (DrilledSolid(Solid.box(100, 60, 20), [])
138
+ .cut(Cyl(35, 20, 6, 0, 20)).cut(Cyl(65, 20, 6, 0, 20))
139
+ .cut(Cyl(35, 40, 6, 0, 20)).cut(Cyl(65, 40, 6, 0, 20)))
140
+ cbore_buckets = DrilledSolid(Solid.box(30, 30, 10), []).cut(
141
+ Cyl(15, 15, 3, 0, 10)).cut(Cyl(15, 15, 12, 7, 10))
142
+ cases = [
143
+ (DrilledSolid(Solid.box(40, 20, 5), [Cyl(20, 10, 4, 0, 5)]),
144
+ 40 * 20 * 5 - math.pi * 16 * 5), # through hole
145
+ (DrilledSolid(Solid.box(20, 20, 10), [Cyl(10, 10, 3, 4, 10)]),
146
+ 20 * 20 * 10 - math.pi * 9 * 6), # blind hole
147
+ (bolt_col, 100 * 60 * 20 - 2 * math.pi * 64 * 20), # 2-hole column
148
+ (grid, 100 * 60 * 20 - 4 * math.pi * 36 * 20), # 2x2 bolt grid
149
+ (cbore_buckets,
150
+ 30 * 30 * 10 - (math.pi * 9 * 7 + math.pi * 144 * 3)), # counterbore
151
+ ]
152
+ for solid, vexp in cases:
153
+ mesh = solid.tessellate(0.05)
154
+ assert _nonmanifold(mesh) == 0 # watertight
155
+ sv = _signed_volume(mesh)
156
+ assert sv > 0 # outward normals
157
+ assert abs(sv - vexp) / vexp < 0.02
158
+
159
+
160
+ def test_bare_primitives_tessellate_watertight() -> None:
161
+ from forgekernel.quadric import Cone, Sphere
162
+
163
+ cases = [
164
+ (Cyl(0, 0, 4, 0, 10), math.pi * 16 * 10),
165
+ (Sphere(0, 0, 0, 5), 4 / 3 * math.pi * 125),
166
+ (Cone(0, 0, 3, 1, 0, 8), math.pi * 8 / 3 * (9 + 3 + 1)),
167
+ (Cone(0, 0, 3, 0, 0, 8), math.pi * 9 * 8 / 3), # cone to a point
168
+ ]
169
+ for solid, vexp in cases:
170
+ mesh = solid.tessellate(0.02)
171
+ assert _nonmanifold(mesh) == 0
172
+ sv = _signed_volume(mesh)
173
+ assert sv > 0 # outward normals
174
+ assert abs(sv - vexp) / vexp < 0.02
175
+
176
+
177
+ def test_earcut_handles_multi_hole_bolt_patterns() -> None:
178
+ from forgekernel.mesh2d import triangulate
179
+
180
+ def circle(cx, cy, r, n=32):
181
+ return [(cx + r * math.cos(2 * math.pi * k / n),
182
+ cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]
183
+
184
+ def orient(a, b, c):
185
+ return (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])
186
+
187
+ rect = [(0, 0), (100, 0), (100, 60), (0, 60)]
188
+ for holes, exp in [
189
+ ([circle(50, 20, 8), circle(50, 45, 8)], 6000 - 2 * math.pi * 64), # column
190
+ ([circle(35, 20, 6), circle(65, 20, 6),
191
+ circle(35, 40, 6), circle(65, 40, 6)], 6000 - 4 * math.pi * 36), # grid
192
+ ]:
193
+ pts, tris = triangulate(rect, holes)
194
+ area = sum(abs(orient(pts[a], pts[b], pts[c])) / 2 for a, b, c in tris)
195
+ flips = sum(1 for a, b, c in tris if orient(pts[a], pts[b], pts[c]) < -1e-6)
196
+ assert flips == 0 # no inverted triangles
197
+ assert abs(area - exp) / exp < 0.01 # no missing/overlap area
198
+
199
+
112
200
  def test_counterbore_sections_as_one_stepped_profile() -> None:
113
201
  # coaxial bores (r2 through + r4 counterbore over z7..10) must section as a
114
202
  # single stepped profile: two closed loops with the shoulder step, NOT the
@@ -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