forgekernel 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- forgekernel/brep.py +624 -0
- forgekernel/bsolid.py +295 -0
- forgekernel/csg.py +158 -0
- forgekernel/curve.py +166 -0
- forgekernel/exact.py +121 -0
- forgekernel/interval.py +141 -0
- forgekernel/io.py +56 -0
- forgekernel/kernel.py +93 -0
- forgekernel/loft.py +185 -0
- forgekernel/nurbs.py +481 -0
- forgekernel/profile2d.py +228 -0
- forgekernel/quadric.py +979 -0
- forgekernel/ssi.py +524 -0
- forgekernel/stepio.py +460 -0
- forgekernel/surd.py +104 -0
- forgekernel/surfacing.py +341 -0
- forgekernel/tess.py +74 -0
- forgekernel/trim.py +193 -0
- forgekernel-0.1.0.dist-info/METADATA +82 -0
- forgekernel-0.1.0.dist-info/RECORD +24 -0
- forgekernel-0.1.0.dist-info/WHEEL +5 -0
- forgekernel-0.1.0.dist-info/licenses/LICENSE +201 -0
- forgekernel-0.1.0.dist-info/top_level.txt +1 -0
forgekernel/bsolid.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""K7 — boundary-represented freeform solids (NURBS patches).
|
|
2
|
+
|
|
3
|
+
The keystone result: the volume of a solid bounded by **polynomial**
|
|
4
|
+
Bézier patches is *exactly rational*. By the divergence theorem
|
|
5
|
+
|
|
6
|
+
V = (1/3) ∮∮_∂Ω S · (S_u × S_v) du dv (summed over patches),
|
|
7
|
+
|
|
8
|
+
and the integrand ``S·(S_u×S_v)`` is a polynomial in (u,v). A polynomial
|
|
9
|
+
integrates exactly over [0,1]², so V ∈ ℚ — no epsilon, not even ℚ[π].
|
|
10
|
+
OCCT can only Gauss-quadrature the same flux to a tolerance.
|
|
11
|
+
|
|
12
|
+
Exact integration uses an interpolatory rational quadrature: n = 3p
|
|
13
|
+
distinct rational nodes give weights (integrals of Lagrange bases) that
|
|
14
|
+
are exact rationals and integrate any degree ≤ 3p−1 polynomial exactly
|
|
15
|
+
— which the flux is, in each variable. Rational (non-polynomial) patches
|
|
16
|
+
give a rational integrand and a **certified interval** volume instead
|
|
17
|
+
(ADR-0019); that is K7.1.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from fractions import Fraction
|
|
23
|
+
|
|
24
|
+
from forgekernel.nurbs import BSplineSurface, bezier_surface, surface_partials2
|
|
25
|
+
|
|
26
|
+
F = Fraction
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _lagrange_weights(nodes):
|
|
30
|
+
"""Interpolatory quadrature weights on ``nodes`` ⊂ [0,1]: w_k =
|
|
31
|
+
∫₀¹ ∏_{m≠k}(x−x_m)/(x_k−x_m) dx — exact rationals. Exact for any
|
|
32
|
+
polynomial of degree ≤ len(nodes)−1."""
|
|
33
|
+
n = len(nodes)
|
|
34
|
+
weights = []
|
|
35
|
+
for k in range(n):
|
|
36
|
+
# Lagrange basis numerator ∏_{m≠k}(x − x_m) as power-basis coeffs
|
|
37
|
+
coeffs = [F(1)] # polynomial "1"
|
|
38
|
+
denom = F(1)
|
|
39
|
+
for m in range(n):
|
|
40
|
+
if m == k:
|
|
41
|
+
continue
|
|
42
|
+
# multiply by (x − x_m)
|
|
43
|
+
new = [F(0)] * (len(coeffs) + 1)
|
|
44
|
+
for i, c in enumerate(coeffs):
|
|
45
|
+
new[i + 1] += c # x·c
|
|
46
|
+
new[i] += -nodes[m] * c # −x_m·c
|
|
47
|
+
coeffs = new
|
|
48
|
+
denom *= (nodes[k] - nodes[m])
|
|
49
|
+
# integrate power series ∫₀¹ Σ c_i x^i = Σ c_i/(i+1)
|
|
50
|
+
integ = sum(c / (i + 1) for i, c in enumerate(coeffs))
|
|
51
|
+
weights.append(integ / denom)
|
|
52
|
+
return weights
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _nodes(n):
|
|
56
|
+
"""n distinct rationals in (0,1): the Chebyshev-like split k+1/(n+1)."""
|
|
57
|
+
return [F(k + 1, n + 1) for k in range(n)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _triple(a, b, c):
|
|
61
|
+
"""Scalar triple product a·(b×c), exact."""
|
|
62
|
+
cx = (b[1] * c[2] - b[2] * c[1],
|
|
63
|
+
b[2] * c[0] - b[0] * c[2],
|
|
64
|
+
b[0] * c[1] - b[1] * c[0])
|
|
65
|
+
return a[0] * cx[0] + a[1] * cx[1] + a[2] * cx[2]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def patch_flux(surface: BSplineSurface) -> Fraction:
|
|
69
|
+
"""(1/3)∮∮ S·(S_u×S_v) du dv over the surface's parameter domain —
|
|
70
|
+
the patch's contribution to the enclosed signed volume. EXACT ℚ for
|
|
71
|
+
polynomial surfaces (raises for rational — K7.1 certified path)."""
|
|
72
|
+
if any(w != F(1) for row in surface.w for w in row):
|
|
73
|
+
raise ValueError("exact flux: polynomial patches only (K7.1)")
|
|
74
|
+
p, q = surface.p, surface.q
|
|
75
|
+
(u0, u1), (v0, v1) = surface.domain()
|
|
76
|
+
u0, u1, v0, v1 = F(u0), F(u1), F(v0), F(v1)
|
|
77
|
+
du, dv = u1 - u0, v1 - v0
|
|
78
|
+
# degree of S·(S_u×S_v) is (3p−1, 3q−1) → need 3p, 3q nodes
|
|
79
|
+
un, vn = _nodes(3 * p), _nodes(3 * q)
|
|
80
|
+
uw, vw = _lagrange_weights(un), _lagrange_weights(vn)
|
|
81
|
+
total = F(0)
|
|
82
|
+
for i, uu in enumerate(un):
|
|
83
|
+
for j, vv in enumerate(vn):
|
|
84
|
+
U, Su, Sv = surface_partials2(surface, u0 + uu * du, v0 + vv * dv)[:3]
|
|
85
|
+
# chain rule: dS/dū = Su·du, dS/dv̄ = Sv·dv (ū,v̄ ∈ [0,1])
|
|
86
|
+
t = _triple(U, tuple(du * s for s in Su), tuple(dv * s for s in Sv))
|
|
87
|
+
total += uw[i] * vw[j] * t
|
|
88
|
+
return total / 3
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def trimmed_patch_flux(surface: BSplineSurface, loops) -> Fraction:
|
|
92
|
+
"""(1/3)∮∮_D S·(S_u×S_v) du dv over the TRIMMED parameter region D of a
|
|
93
|
+
polynomial patch — D bounded by polygonal ``loops`` in the surface's
|
|
94
|
+
(u, v) domain (outer CCW, holes CW; use ``TrimmedPatch.normalized()``).
|
|
95
|
+
|
|
96
|
+
Green's theorem turns the area integral into a contour integral over the
|
|
97
|
+
loop edges: ∫∫_D F du dv = ∮_∂D G dv with G(u,v)=∫_{u0}^{u} F(u',v) du'.
|
|
98
|
+
F = S·(S_u×S_v) is a polynomial (u-degree 3p−1, v-degree 3q−1), so the
|
|
99
|
+
inner u-antiderivative is an exact 3p-node quadrature and the outer
|
|
100
|
+
edge integral (G is degree 3(p+q)−1 along a straight edge) an exact
|
|
101
|
+
3(p+q)-node one — the whole result is exact ℚ.
|
|
102
|
+
|
|
103
|
+
Exactness holds for polynomial patches AND polygonal trim loops. When
|
|
104
|
+
the loops are the polyline sampling of a curved SSI trim boundary, the
|
|
105
|
+
result is exact for THAT polygon — i.e. it carries the boundary's
|
|
106
|
+
discretization error, not a rounding one (the honest K7 caveat)."""
|
|
107
|
+
if any(w != F(1) for row in surface.w for w in row):
|
|
108
|
+
raise ValueError("exact trimmed flux: polynomial patches only (K7.1)")
|
|
109
|
+
p, q = surface.p, surface.q
|
|
110
|
+
(ud0, _), _ = surface.domain()
|
|
111
|
+
ud0 = F(ud0)
|
|
112
|
+
inn = _nodes(3 * p)
|
|
113
|
+
inw = _lagrange_weights(inn)
|
|
114
|
+
otn = _nodes(3 * (p + q))
|
|
115
|
+
otw = _lagrange_weights(otn)
|
|
116
|
+
|
|
117
|
+
def Fpt(u, v):
|
|
118
|
+
S, Su, Sv = surface_partials2(surface, u, v)[:3]
|
|
119
|
+
return _triple(S, Su, Sv)
|
|
120
|
+
|
|
121
|
+
def Gpt(u, v): # ∫_{ud0}^{u} F(u',v) du' via σ∈[0,1] map
|
|
122
|
+
span = u - ud0
|
|
123
|
+
return span * sum(w * Fpt(ud0 + s * span, v) for w, s in zip(inw, inn))
|
|
124
|
+
|
|
125
|
+
total = F(0)
|
|
126
|
+
for loop in loops:
|
|
127
|
+
pts = [(F(a), F(b)) for a, b in loop]
|
|
128
|
+
m = len(pts)
|
|
129
|
+
for k in range(m):
|
|
130
|
+
(ua, va), (ub, vb) = pts[k], pts[(k + 1) % m]
|
|
131
|
+
dvv = vb - va
|
|
132
|
+
if dvv == 0: # a horizontal edge adds nothing to ∮ G dv
|
|
133
|
+
continue
|
|
134
|
+
duu = ub - ua
|
|
135
|
+
edge = F(0)
|
|
136
|
+
for w, tau in zip(otw, otn):
|
|
137
|
+
edge += w * Gpt(ua + tau * duu, va + tau * dvv)
|
|
138
|
+
total += dvv * edge
|
|
139
|
+
return total / 3
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def trimmed_solid_volume(faces) -> Fraction:
|
|
143
|
+
"""Volume of a solid whose closed, outward-oriented boundary is a set of
|
|
144
|
+
TRIMMED polynomial patches — Σ per-face flux, exact ℚ. ``faces`` is a
|
|
145
|
+
list of ``(surface, loops)``. This is the boolean-assembly reduction:
|
|
146
|
+
a boolean re-trims faces and adds intersection-curve loops, but the
|
|
147
|
+
enclosed volume is just the sum of the trimmed-face fluxes."""
|
|
148
|
+
return abs(sum((trimmed_patch_flux(s, loops) for s, loops in faces), F(0)))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PatchSolid:
|
|
152
|
+
"""A closed solid whose boundary is a list of outward-oriented
|
|
153
|
+
polynomial Bézier patches. Volume exact in ℚ via the flux theorem."""
|
|
154
|
+
|
|
155
|
+
provenance = "exact"
|
|
156
|
+
|
|
157
|
+
def __init__(self, patches) -> None:
|
|
158
|
+
self.patches = list(patches)
|
|
159
|
+
if not self.patches:
|
|
160
|
+
raise ValueError("PatchSolid needs at least one boundary patch")
|
|
161
|
+
|
|
162
|
+
def volume(self) -> Fraction:
|
|
163
|
+
v = sum((patch_flux(p) for p in self.patches), F(0))
|
|
164
|
+
return abs(v)
|
|
165
|
+
|
|
166
|
+
def bbox_f(self):
|
|
167
|
+
lo = [float("inf")] * 3
|
|
168
|
+
hi = [float("-inf")] * 3
|
|
169
|
+
for patch in self.patches:
|
|
170
|
+
for row in patch.cp:
|
|
171
|
+
for pt in row:
|
|
172
|
+
for c in range(3):
|
|
173
|
+
lo[c] = min(lo[c], float(pt[c]))
|
|
174
|
+
hi[c] = max(hi[c], float(pt[c]))
|
|
175
|
+
return tuple(lo), tuple(hi)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def box_patches(dx, dy, dz, origin=(0, 0, 0)):
|
|
179
|
+
"""Six outward flat Bézier patches forming a box (degree 1×1) — the
|
|
180
|
+
hand-checkable sanity solid: volume must be exactly dx·dy·dz."""
|
|
181
|
+
ox, oy, oz = (F(v) for v in origin)
|
|
182
|
+
dx, dy, dz = F(dx), F(dy), F(dz)
|
|
183
|
+
x0, y0, z0 = ox, oy, oz
|
|
184
|
+
x1, y1, z1 = ox + dx, oy + dy, oz + dz
|
|
185
|
+
|
|
186
|
+
def patch(p00, p10, p01, p11):
|
|
187
|
+
return bezier_surface([[p00, p01], [p10, p11]])
|
|
188
|
+
# each patch oriented so S_u×S_v points OUTWARD
|
|
189
|
+
return [
|
|
190
|
+
patch((x0, y0, z0), (x0, y1, z0), (x1, y0, z0), (x1, y1, z0)), # z0 (−z out): check sign via abs
|
|
191
|
+
patch((x0, y0, z1), (x1, y0, z1), (x0, y1, z1), (x1, y1, z1)), # z1 (+z)
|
|
192
|
+
patch((x0, y0, z0), (x1, y0, z0), (x0, y0, z1), (x1, y0, z1)), # y0
|
|
193
|
+
patch((x0, y1, z0), (x0, y1, z1), (x1, y1, z0), (x1, y1, z1)), # y1
|
|
194
|
+
patch((x0, y0, z0), (x0, y0, z1), (x0, y1, z0), (x0, y1, z1)), # x0
|
|
195
|
+
patch((x1, y0, z0), (x1, y1, z0), (x1, y0, z1), (x1, y1, z1)), # x1
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# -- K7.0b: exact inertia tensor (same flux trick, one degree higher) ---------
|
|
200
|
+
|
|
201
|
+
def _flux_moment(surface: BSplineSurface, fx, fy, fz):
|
|
202
|
+
"""(1/1) ∮∮ (fx,fy,fz)·(S_u×S_v) du dv where fx,fy,fz are callables
|
|
203
|
+
of the point S — used to lift a volume integral to a surface flux.
|
|
204
|
+
Polynomial integrand ⇒ exact ℚ."""
|
|
205
|
+
if any(w != F(1) for row in surface.w for w in row):
|
|
206
|
+
raise ValueError("exact moments: polynomial patches only (K7.1)")
|
|
207
|
+
p, q = surface.p, surface.q
|
|
208
|
+
(u0, u1), (v0, v1) = surface.domain()
|
|
209
|
+
u0, u1, v0, v1 = F(u0), F(u1), F(v0), F(v1)
|
|
210
|
+
du, dv = u1 - u0, v1 - v0
|
|
211
|
+
# A moment ∮ f(S)·n with f of coordinate-degree m has integrand degree
|
|
212
|
+
# m·p + (2p−1) = (m+2)p−1 in u. The heaviest moment used here is the
|
|
213
|
+
# SECOND moment (m=3 → 5p−1), so 5p nodes are needed — NOT 3p+2, which
|
|
214
|
+
# only coincides at p=1 (the trap that let degree-1 box tests pass while
|
|
215
|
+
# degree≥2 patches returned a wrong, non-exact inertia tensor).
|
|
216
|
+
un, vn = _nodes(5 * p), _nodes(5 * q)
|
|
217
|
+
uw, vw = _lagrange_weights(un), _lagrange_weights(vn)
|
|
218
|
+
total = F(0)
|
|
219
|
+
for i, uu in enumerate(un):
|
|
220
|
+
for j, vv in enumerate(vn):
|
|
221
|
+
S, Su, Sv = surface_partials2(surface, u0 + uu * du, v0 + vv * dv)[:3]
|
|
222
|
+
nx = (du * Su[1]) * (dv * Sv[2]) - (du * Su[2]) * (dv * Sv[1])
|
|
223
|
+
ny = (du * Su[2]) * (dv * Sv[0]) - (du * Su[0]) * (dv * Sv[2])
|
|
224
|
+
nz = (du * Su[0]) * (dv * Sv[1]) - (du * Su[1]) * (dv * Sv[0])
|
|
225
|
+
total += uw[i] * vw[j] * (fx(S) * nx + fy(S) * ny + fz(S) * nz)
|
|
226
|
+
return total
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def mass_properties(solid: "PatchSolid") -> dict:
|
|
230
|
+
"""Exact volume, centroid, and inertia tensor (about the centroid) of
|
|
231
|
+
a Bézier-patch solid — every entry an exact ``Fraction``.
|
|
232
|
+
|
|
233
|
+
Divergence theorem lifts each volume integral to a boundary flux of a
|
|
234
|
+
polynomial: V=∮(x,·,·)·n, ∫x=∮(x²/2,·,·)·n, ∫x²=∮(x³/3,·,·)·n,
|
|
235
|
+
∫xy=∮(x²y/2,·,·)·n, …"""
|
|
236
|
+
zero = lambda S: F(0)
|
|
237
|
+
V = sum((_flux_moment(p, lambda S: S[0], zero, zero)
|
|
238
|
+
for p in solid.patches), F(0))
|
|
239
|
+
sign = 1 if V >= 0 else -1
|
|
240
|
+
V *= sign
|
|
241
|
+
|
|
242
|
+
def moment(fx):
|
|
243
|
+
return sign * sum((_flux_moment(p, fx, zero, zero)
|
|
244
|
+
for p in solid.patches), F(0))
|
|
245
|
+
|
|
246
|
+
mx = moment(lambda S: S[0] * S[0] / 2)
|
|
247
|
+
my = sign * sum((_flux_moment(p, zero, lambda S: S[1] * S[1] / 2, zero)
|
|
248
|
+
for p in solid.patches), F(0))
|
|
249
|
+
mz = sign * sum((_flux_moment(p, zero, zero, lambda S: S[2] * S[2] / 2)
|
|
250
|
+
for p in solid.patches), F(0))
|
|
251
|
+
cx, cy, cz = mx / V, my / V, mz / V
|
|
252
|
+
Ixx_o = moment(lambda S: S[0] ** 3 / 3) # ∫x² dV
|
|
253
|
+
Iyy_o = sign * sum((_flux_moment(p, zero, lambda S: S[1] ** 3 / 3, zero)
|
|
254
|
+
for p in solid.patches), F(0))
|
|
255
|
+
Izz_o = sign * sum((_flux_moment(p, zero, zero, lambda S: S[2] ** 3 / 3)
|
|
256
|
+
for p in solid.patches), F(0))
|
|
257
|
+
Ixy_o = moment(lambda S: S[0] * S[0] * S[1] / 2) # ∫xy dV
|
|
258
|
+
Iyz_o = sign * sum((_flux_moment(p, zero, lambda S: S[1] * S[1] * S[2] / 2,
|
|
259
|
+
zero) for p in solid.patches), F(0))
|
|
260
|
+
Izx_o = sign * sum((_flux_moment(p, zero, zero,
|
|
261
|
+
lambda S: S[2] * S[2] * S[0] / 2)
|
|
262
|
+
for p in solid.patches), F(0))
|
|
263
|
+
# inertia tensor about the CENTROID (parallel-axis, exact)
|
|
264
|
+
Ixx = (Iyy_o + Izz_o) - V * (cy * cy + cz * cz)
|
|
265
|
+
Iyy = (Izz_o + Ixx_o) - V * (cz * cz + cx * cx)
|
|
266
|
+
Izz = (Ixx_o + Iyy_o) - V * (cx * cx + cy * cy)
|
|
267
|
+
Ixy = -(Ixy_o - V * cx * cy)
|
|
268
|
+
Iyz = -(Iyz_o - V * cy * cz)
|
|
269
|
+
Izx = -(Izx_o - V * cz * cx)
|
|
270
|
+
return {"volume": V, "centroid": (cx, cy, cz),
|
|
271
|
+
"inertia": ((Ixx, Ixy, Izx), (Ixy, Iyy, Iyz), (Izx, Iyz, Izz))}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# -- K7.0d: exact mass properties of a planar Solid (reuse the flux) ----------
|
|
275
|
+
|
|
276
|
+
def solid_to_patches(solid):
|
|
277
|
+
"""Convert a planar forge ``Solid`` to flat degenerate Bézier patches
|
|
278
|
+
(one per boundary triangle) so the exact flux machinery applies. A
|
|
279
|
+
triangle (v0,v1,v2) becomes the collapsed bilinear patch
|
|
280
|
+
[[v0,v0],[v1,v2]] — its S_u×S_v is the triangle's outward area
|
|
281
|
+
vector, exactly."""
|
|
282
|
+
patches = []
|
|
283
|
+
for poly in solid.polys:
|
|
284
|
+
vs = [tuple(F(c) for c in v) for v in poly.verts]
|
|
285
|
+
for i in range(1, len(vs) - 1): # fan triangulation
|
|
286
|
+
a, b, c = vs[0], vs[i], vs[i + 1]
|
|
287
|
+
patches.append(bezier_surface([[a, a], [b, c]]))
|
|
288
|
+
return patches
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def polyhedron_mass_properties(solid) -> dict:
|
|
292
|
+
"""Exact volume + centroid + inertia tensor of a planar ``Solid``,
|
|
293
|
+
every entry a Fraction — via the same divergence-theorem flux used
|
|
294
|
+
for freeform solids."""
|
|
295
|
+
return mass_properties(PatchSolid(solid_to_patches(solid)))
|
forgekernel/csg.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Exact CSG booleans on convex-faceted solids (K1).
|
|
2
|
+
|
|
3
|
+
BSP-based clipping in the classic csg.js structure, but with EXACT
|
|
4
|
+
rational classification: a vertex is FRONT/BACK/ON by the sign of an
|
|
5
|
+
exact expression, so coplanar faces, shared edges, and slivers take a
|
|
6
|
+
deterministic branch instead of an epsilon gamble. Splitting a convex
|
|
7
|
+
polygon by a plane yields convex polygons with exactly computed
|
|
8
|
+
intersection points; degenerate fragments vanish by exact area test.
|
|
9
|
+
Lineage rides through every split (Polygon.source is preserved).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from forgekernel.brep import Polygon, Solid
|
|
15
|
+
from forgekernel.exact import Plane, add, dot, smul, sub
|
|
16
|
+
|
|
17
|
+
_COPLANAR, _FRONT, _BACK, _SPANNING = 0, 1, 2, 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split(plane: Plane, poly: Polygon, cof: list, cob: list,
|
|
21
|
+
front: list, back: list) -> None:
|
|
22
|
+
sides = [plane.side(v) for v in poly.verts]
|
|
23
|
+
kind = 0
|
|
24
|
+
for s in sides:
|
|
25
|
+
kind |= _FRONT if s > 0 else (_BACK if s < 0 else 0)
|
|
26
|
+
if kind == _COPLANAR:
|
|
27
|
+
(cof if dot(plane.n, poly.plane.n) > 0 else cob).append(poly)
|
|
28
|
+
elif kind == _FRONT:
|
|
29
|
+
front.append(poly)
|
|
30
|
+
elif kind == _BACK:
|
|
31
|
+
back.append(poly)
|
|
32
|
+
else:
|
|
33
|
+
f: list = []
|
|
34
|
+
b: list = []
|
|
35
|
+
n = len(poly.verts)
|
|
36
|
+
for i in range(n):
|
|
37
|
+
j = (i + 1) % n
|
|
38
|
+
vi, vj = poly.verts[i], poly.verts[j]
|
|
39
|
+
si, sj = sides[i], sides[j]
|
|
40
|
+
if si >= 0:
|
|
41
|
+
f.append(vi)
|
|
42
|
+
if si <= 0:
|
|
43
|
+
b.append(vi)
|
|
44
|
+
if si * sj < 0: # strict crossing: exact t
|
|
45
|
+
t = (plane.d - dot(plane.n, vi)) / dot(plane.n, sub(vj, vi))
|
|
46
|
+
x = add(vi, smul(t, sub(vj, vi)))
|
|
47
|
+
f.append(x)
|
|
48
|
+
b.append(x)
|
|
49
|
+
if len(f) >= 3:
|
|
50
|
+
p = Polygon(f, poly.source, poly.plane)
|
|
51
|
+
if p.area2() != 0:
|
|
52
|
+
front.append(p)
|
|
53
|
+
if len(b) >= 3:
|
|
54
|
+
p = Polygon(b, poly.source, poly.plane)
|
|
55
|
+
if p.area2() != 0:
|
|
56
|
+
back.append(p)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Node:
|
|
60
|
+
__slots__ = ("plane", "front", "back", "polys")
|
|
61
|
+
|
|
62
|
+
def __init__(self, polys: list[Polygon] | None = None) -> None:
|
|
63
|
+
self.plane: Plane | None = None
|
|
64
|
+
self.front: _Node | None = None
|
|
65
|
+
self.back: _Node | None = None
|
|
66
|
+
self.polys: list[Polygon] = []
|
|
67
|
+
if polys:
|
|
68
|
+
self.build(polys)
|
|
69
|
+
|
|
70
|
+
def build(self, polys: list[Polygon]) -> None:
|
|
71
|
+
if not polys:
|
|
72
|
+
return
|
|
73
|
+
if self.plane is None:
|
|
74
|
+
self.plane = polys[0].plane
|
|
75
|
+
front: list[Polygon] = []
|
|
76
|
+
back: list[Polygon] = []
|
|
77
|
+
for p in polys:
|
|
78
|
+
_split(self.plane, p, self.polys, self.polys, front, back)
|
|
79
|
+
if front:
|
|
80
|
+
if self.front is None:
|
|
81
|
+
self.front = _Node()
|
|
82
|
+
self.front.build(front)
|
|
83
|
+
if back:
|
|
84
|
+
if self.back is None:
|
|
85
|
+
self.back = _Node()
|
|
86
|
+
self.back.build(back)
|
|
87
|
+
|
|
88
|
+
def invert(self) -> None:
|
|
89
|
+
self.polys = [p.flipped() for p in self.polys]
|
|
90
|
+
if self.plane is not None:
|
|
91
|
+
self.plane = self.plane.flipped()
|
|
92
|
+
if self.front:
|
|
93
|
+
self.front.invert()
|
|
94
|
+
if self.back:
|
|
95
|
+
self.back.invert()
|
|
96
|
+
self.front, self.back = self.back, self.front
|
|
97
|
+
|
|
98
|
+
def clip_polygons(self, polys: list[Polygon]) -> list[Polygon]:
|
|
99
|
+
if self.plane is None:
|
|
100
|
+
return list(polys)
|
|
101
|
+
front: list[Polygon] = []
|
|
102
|
+
back: list[Polygon] = []
|
|
103
|
+
for p in polys:
|
|
104
|
+
_split(self.plane, p, front, back, front, back)
|
|
105
|
+
front = self.front.clip_polygons(front) if self.front else front
|
|
106
|
+
back = self.back.clip_polygons(back) if self.back else []
|
|
107
|
+
return front + back
|
|
108
|
+
|
|
109
|
+
def clip_to(self, other: "_Node") -> None:
|
|
110
|
+
self.polys = other.clip_polygons(self.polys)
|
|
111
|
+
if self.front:
|
|
112
|
+
self.front.clip_to(other)
|
|
113
|
+
if self.back:
|
|
114
|
+
self.back.clip_to(other)
|
|
115
|
+
|
|
116
|
+
def all_polygons(self) -> list[Polygon]:
|
|
117
|
+
out = list(self.polys)
|
|
118
|
+
if self.front:
|
|
119
|
+
out += self.front.all_polygons()
|
|
120
|
+
if self.back:
|
|
121
|
+
out += self.back.all_polygons()
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def union(a: Solid, b: Solid) -> Solid:
|
|
126
|
+
na, nb = _Node(list(a.polys)), _Node(list(b.polys))
|
|
127
|
+
na.clip_to(nb)
|
|
128
|
+
nb.clip_to(na)
|
|
129
|
+
nb.invert()
|
|
130
|
+
nb.clip_to(na)
|
|
131
|
+
nb.invert()
|
|
132
|
+
na.build(nb.all_polygons())
|
|
133
|
+
return Solid(na.all_polygons())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cut(a: Solid, b: Solid) -> Solid:
|
|
137
|
+
na, nb = _Node(list(a.polys)), _Node(list(b.polys))
|
|
138
|
+
na.invert()
|
|
139
|
+
na.clip_to(nb)
|
|
140
|
+
nb.clip_to(na)
|
|
141
|
+
nb.invert()
|
|
142
|
+
nb.clip_to(na)
|
|
143
|
+
nb.invert()
|
|
144
|
+
na.build(nb.all_polygons())
|
|
145
|
+
na.invert()
|
|
146
|
+
return Solid(na.all_polygons())
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def intersect(a: Solid, b: Solid) -> Solid:
|
|
150
|
+
na, nb = _Node(list(a.polys)), _Node(list(b.polys))
|
|
151
|
+
na.invert()
|
|
152
|
+
nb.clip_to(na)
|
|
153
|
+
nb.invert()
|
|
154
|
+
na.clip_to(nb)
|
|
155
|
+
nb.clip_to(na)
|
|
156
|
+
na.build(nb.all_polygons())
|
|
157
|
+
na.invert()
|
|
158
|
+
return Solid(na.all_polygons())
|
forgekernel/curve.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""K3.0 curves — the helix and its swept round section (ADR-0019).
|
|
2
|
+
|
|
3
|
+
The first transcendental geometry in the kernel. A ``Helix`` point is
|
|
4
|
+
``(R cos 2πt, R sin 2πt, pitch·t)`` — irrational at every rational ``t``
|
|
5
|
+
— so this family cannot live in the exact fields. It is carried instead
|
|
6
|
+
as *certified* geometry (ADR-0019): the one quantity a build actually
|
|
7
|
+
needs, the swept volume, is an exact closed form evaluated as a
|
|
8
|
+
``CInterval`` and reported with a proven half-width.
|
|
9
|
+
|
|
10
|
+
coil spring = round section of radius ρ swept along a helix
|
|
11
|
+
V = π ρ² L, L = turns · √((2πR)² + pitch²)
|
|
12
|
+
|
|
13
|
+
``V = A·L`` is exact for a tube whose section radius stays below the
|
|
14
|
+
spine's radius of curvature (checked), so the only approximation is the
|
|
15
|
+
irrational ``√`` and ``π`` — both bracketed, never guessed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from fractions import Fraction
|
|
22
|
+
|
|
23
|
+
from forgekernel.interval import CInterval, pi_interval
|
|
24
|
+
|
|
25
|
+
F = Fraction
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Helix:
|
|
29
|
+
"""A helical spine about +z, starting at ``(radius, 0, 0)``."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, radius, pitch, turns, ccw: bool = True) -> None:
|
|
32
|
+
self.R = F(radius)
|
|
33
|
+
self.pitch = F(pitch)
|
|
34
|
+
self.turns = F(turns)
|
|
35
|
+
self.ccw = ccw
|
|
36
|
+
if self.R <= 0 or self.pitch <= 0 or self.turns <= 0:
|
|
37
|
+
raise ValueError("helix wants positive radius/pitch/turns")
|
|
38
|
+
|
|
39
|
+
def arc_length(self) -> CInterval:
|
|
40
|
+
"""Certified length: turns·√((2πR)² + pitch²) in ℚ + π + √."""
|
|
41
|
+
pi = pi_interval()
|
|
42
|
+
arg = CInterval.exact(4 * self.R * self.R) * pi * pi \
|
|
43
|
+
+ CInterval.exact(self.pitch * self.pitch)
|
|
44
|
+
return CInterval.exact(self.turns) * arg.sqrt()
|
|
45
|
+
|
|
46
|
+
def curvature(self) -> float:
|
|
47
|
+
"""κ = R/(R²+c²), c = pitch/2π — the spine radius of curvature is 1/κ."""
|
|
48
|
+
c = float(self.pitch) / (2 * math.pi)
|
|
49
|
+
R = float(self.R)
|
|
50
|
+
return R / (R * R + c * c)
|
|
51
|
+
|
|
52
|
+
def point_f(self, t: float) -> tuple[float, float, float]:
|
|
53
|
+
"""Float sample at parameter ``t`` turns (for tessellation only)."""
|
|
54
|
+
ang = 2 * math.pi * t * (1.0 if self.ccw else -1.0)
|
|
55
|
+
return (float(self.R) * math.cos(ang),
|
|
56
|
+
float(self.R) * math.sin(ang),
|
|
57
|
+
float(self.pitch) * t)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TubeSolid:
|
|
61
|
+
"""A round section of radius ``wire_radius`` swept along a ``Helix``.
|
|
62
|
+
|
|
63
|
+
Provenance is ``certified``: the volume is a ``CInterval``. The tube
|
|
64
|
+
is watertight by construction (closed lateral surface + two end
|
|
65
|
+
caps) as long as the section does not self-overlap, which holds when
|
|
66
|
+
``wire_radius < 1/κ`` (the spine radius of curvature) and adjacent
|
|
67
|
+
coils clear (``2·wire_radius < pitch``); both are checked."""
|
|
68
|
+
|
|
69
|
+
provenance = "certified"
|
|
70
|
+
|
|
71
|
+
def __init__(self, helix: Helix, wire_radius, translate=(0, 0, 0)) -> None:
|
|
72
|
+
self.helix = helix
|
|
73
|
+
self.rho = F(wire_radius)
|
|
74
|
+
self.t = tuple(F(v) for v in translate)
|
|
75
|
+
if self.rho <= 0:
|
|
76
|
+
raise ValueError("tube wants positive wire radius")
|
|
77
|
+
if 2 * self.rho >= helix.pitch:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"tube self-overlaps: 2·wire_radius >= pitch (coils merge)")
|
|
80
|
+
if float(self.rho) >= 1.0 / helix.curvature():
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"tube self-overlaps: wire_radius >= spine radius of curvature")
|
|
83
|
+
|
|
84
|
+
# -- certified metrics ----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def volume(self) -> CInterval:
|
|
87
|
+
"""π ρ² L — exact tube volume as a certified interval."""
|
|
88
|
+
pi = pi_interval()
|
|
89
|
+
return pi * CInterval.exact(self.rho * self.rho) \
|
|
90
|
+
* self.helix.arc_length()
|
|
91
|
+
|
|
92
|
+
def centroid_f(self) -> tuple[float, float, float]:
|
|
93
|
+
"""On the axis at mid-height, exact by the sweep's 180° symmetry."""
|
|
94
|
+
tx, ty, tz = (float(v) for v in self.t)
|
|
95
|
+
return (tx, ty, tz + float(self.helix.pitch * self.helix.turns) / 2)
|
|
96
|
+
|
|
97
|
+
def bbox_f(self):
|
|
98
|
+
"""Analytic (tight) bounds. Horizontal reach R+ρ from the axis; the
|
|
99
|
+
section tilt adds at most ρ in z beyond the raw helix span."""
|
|
100
|
+
R, rho = float(self.helix.R), float(self.rho)
|
|
101
|
+
reach = R + rho
|
|
102
|
+
zspan = float(self.helix.pitch * self.helix.turns)
|
|
103
|
+
tx, ty, tz = (float(v) for v in self.t)
|
|
104
|
+
return ((tx - reach, ty - reach, tz - rho),
|
|
105
|
+
(tx + reach, ty + reach, tz + zspan + rho))
|
|
106
|
+
|
|
107
|
+
# -- transforms -----------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def translated(self, dx, dy, dz) -> "TubeSolid":
|
|
110
|
+
t = (self.t[0] + F(dx), self.t[1] + F(dy), self.t[2] + F(dz))
|
|
111
|
+
return TubeSolid(self.helix, self.rho, t)
|
|
112
|
+
|
|
113
|
+
# -- tessellation (bounded-error render artifact) -------------------------
|
|
114
|
+
|
|
115
|
+
def tessellate(self, deflection: float = 0.2) -> dict:
|
|
116
|
+
"""Float mesh of the tube (viewer only). Uses a rotation-minimizing
|
|
117
|
+
frame stepped along the helix; returns {vertices, triangles}."""
|
|
118
|
+
along, around = 24, 12
|
|
119
|
+
h = self.helix
|
|
120
|
+
tx, ty, tz = (float(v) for v in self.t)
|
|
121
|
+
rho = float(self.rho)
|
|
122
|
+
n = max(4, int(along * float(h.turns)))
|
|
123
|
+
# rotation-minimizing frame via finite differences
|
|
124
|
+
ring_centers = []
|
|
125
|
+
tangents = []
|
|
126
|
+
for i in range(n + 1):
|
|
127
|
+
t = float(h.turns) * i / n
|
|
128
|
+
p = h.point_f(t)
|
|
129
|
+
eps = 1e-6
|
|
130
|
+
p1 = h.point_f(t + eps)
|
|
131
|
+
tang = (p1[0] - p[0], p1[1] - p[1], p1[2] - p[2])
|
|
132
|
+
m = math.dist((0, 0, 0), tang) or 1.0
|
|
133
|
+
tangents.append((tang[0] / m, tang[1] / m, tang[2] / m))
|
|
134
|
+
ring_centers.append((p[0] + tx, p[1] + ty, p[2] + tz))
|
|
135
|
+
# initial normal not parallel to first tangent
|
|
136
|
+
ref = (0.0, 0.0, 1.0)
|
|
137
|
+
if abs(tangents[0][2]) > 0.9:
|
|
138
|
+
ref = (1.0, 0.0, 0.0)
|
|
139
|
+
verts, tris = [], []
|
|
140
|
+
prev_n = None
|
|
141
|
+
for i, (c, tg) in enumerate(zip(ring_centers, tangents)):
|
|
142
|
+
base = ref if prev_n is None else prev_n
|
|
143
|
+
# normal = base minus its tangent component, renormalized
|
|
144
|
+
d = base[0] * tg[0] + base[1] * tg[1] + base[2] * tg[2]
|
|
145
|
+
nrm = (base[0] - d * tg[0], base[1] - d * tg[1], base[2] - d * tg[2])
|
|
146
|
+
m = math.dist((0, 0, 0), nrm) or 1.0
|
|
147
|
+
nrm = (nrm[0] / m, nrm[1] / m, nrm[2] / m)
|
|
148
|
+
prev_n = nrm
|
|
149
|
+
binm = (tg[1] * nrm[2] - tg[2] * nrm[1],
|
|
150
|
+
tg[2] * nrm[0] - tg[0] * nrm[2],
|
|
151
|
+
tg[0] * nrm[1] - tg[1] * nrm[0])
|
|
152
|
+
for j in range(around):
|
|
153
|
+
a = 2 * math.pi * j / around
|
|
154
|
+
ca, sa = math.cos(a) * rho, math.sin(a) * rho
|
|
155
|
+
verts.append((c[0] + ca * nrm[0] + sa * binm[0],
|
|
156
|
+
c[1] + ca * nrm[1] + sa * binm[1],
|
|
157
|
+
c[2] + ca * nrm[2] + sa * binm[2]))
|
|
158
|
+
for i in range(n):
|
|
159
|
+
for j in range(around):
|
|
160
|
+
a = i * around + j
|
|
161
|
+
b = i * around + (j + 1) % around
|
|
162
|
+
c = (i + 1) * around + j
|
|
163
|
+
d = (i + 1) * around + (j + 1) % around
|
|
164
|
+
tris.append([a, b, c])
|
|
165
|
+
tris.append([b, d, c])
|
|
166
|
+
return {"vertices": [list(v) for v in verts], "triangles": tris}
|