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.
- {forgekernel-0.9.0/src/forgekernel.egg-info → forgekernel-0.9.2}/PKG-INFO +1 -1
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/__init__.py +1 -1
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/brep.py +17 -29
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/exact.py +9 -16
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/kernel.py +63 -0
- forgekernel-0.9.2/src/forgekernel/mesh2d.py +365 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/quadric.py +222 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/surd.py +54 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/tess.py +15 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/SOURCES.txt +2 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_hlr.py +88 -0
- forgekernel-0.9.2/tests/test_rotate.py +87 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/LICENSE +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/README.md +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/pyproject.toml +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/setup.cfg +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/hlr.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/io.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k1_exact.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k3_step.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.9.0 → forgekernel-0.9.2}/tests/test_scaffold.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
207
|
-
ints = [-w for w in ints]
|
|
201
|
+
lead = v
|
|
208
202
|
break
|
|
209
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
316
|
-
ints = [-w for w in ints]
|
|
302
|
+
lead = v
|
|
317
303
|
break
|
|
318
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
-
ints = [-w for w in ints]
|
|
98
|
+
lead = v
|
|
106
99
|
break
|
|
107
|
-
return tuple(
|
|
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."""
|
|
@@ -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
|
|
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
|