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.
- {forgekernel-0.9.0/src/forgekernel.egg-info → forgekernel-0.9.1}/PKG-INFO +1 -1
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/__init__.py +1 -1
- forgekernel-0.9.1/src/forgekernel/mesh2d.py +365 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/quadric.py +222 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/tess.py +15 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/SOURCES.txt +1 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_hlr.py +88 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/LICENSE +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/README.md +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/pyproject.toml +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/setup.cfg +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/brep.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/exact.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/hlr.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/io.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/kernel.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/surd.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k1_exact.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k3_step.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.1}/tests/test_scaffold.py +0 -0
|
@@ -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."""
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|