forgekernel 0.9.0__tar.gz → 0.9.1__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 (45) hide show
  1. {forgekernel-0.9.0/src/forgekernel.egg-info → forgekernel-0.9.1}/PKG-INFO +1 -1
  2. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/__init__.py +1 -1
  3. forgekernel-0.9.1/src/forgekernel/mesh2d.py +365 -0
  4. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/quadric.py +222 -0
  5. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/tess.py +15 -0
  6. {forgekernel-0.9.0 → forgekernel-0.9.1/src/forgekernel.egg-info}/PKG-INFO +1 -1
  7. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/SOURCES.txt +1 -0
  8. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_hlr.py +88 -0
  9. {forgekernel-0.9.0 → forgekernel-0.9.1}/LICENSE +0 -0
  10. {forgekernel-0.9.0 → forgekernel-0.9.1}/README.md +0 -0
  11. {forgekernel-0.9.0 → forgekernel-0.9.1}/pyproject.toml +0 -0
  12. {forgekernel-0.9.0 → forgekernel-0.9.1}/setup.cfg +0 -0
  13. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/brep.py +0 -0
  14. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/bsolid.py +0 -0
  15. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/csg.py +0 -0
  16. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/curve.py +0 -0
  17. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/exact.py +0 -0
  18. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/hlr.py +0 -0
  19. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/interval.py +0 -0
  20. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/io.py +0 -0
  21. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/kernel.py +0 -0
  22. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/loft.py +0 -0
  23. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/nurbs.py +0 -0
  24. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/profile2d.py +0 -0
  25. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/ssi.py +0 -0
  26. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/stepio.py +0 -0
  27. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/surd.py +0 -0
  28. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/surfacing.py +0 -0
  29. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/trim.py +0 -0
  30. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  31. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/requires.txt +0 -0
  32. {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/top_level.txt +0 -0
  33. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k1_exact.py +0 -0
  34. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k37_loft.py +0 -0
  35. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k38_profile.py +0 -0
  36. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_curves.py +0 -0
  37. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_ssi.py +0 -0
  38. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_step.py +0 -0
  39. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k6_surfacing.py +0 -0
  40. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_bsolid.py +0 -0
  41. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_stepexport.py +0 -0
  42. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_trim.py +0 -0
  43. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_trimflux.py +0 -0
  44. {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_rust_oracle.py +0 -0
  45. {forgekernel-0.9.0 → forgekernel-0.9.1}/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.1
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.1"
@@ -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
@@ -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.1
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
@@ -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
File without changes
File without changes
File without changes
File without changes