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/surfacing.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""K6.0 — surfacing groundwork: Coons patches, exact curvature, G1.
|
|
2
|
+
|
|
3
|
+
The exactness thesis extends into differential geometry:
|
|
4
|
+
|
|
5
|
+
- **Gaussian curvature is exactly rational.** With the unnormalized
|
|
6
|
+
normal ``n = S_u × S_v``, the second-fundamental terms ``L' = S_uu·n``
|
|
7
|
+
etc. carry a factor |n| each, and
|
|
8
|
+
|
|
9
|
+
K = (L'·N' − M'²) / (EG − F²)²
|
|
10
|
+
|
|
11
|
+
— every |n| cancels, so K at a rational parameter of a polynomial
|
|
12
|
+
surface is an exact ``Fraction``. A float kernel evaluates the same
|
|
13
|
+
formula through several rounded square roots.
|
|
14
|
+
- **Mean curvature needs one √** (the (EG−F²)^{3/2}) and comes back as
|
|
15
|
+
a certified interval (ADR-0019).
|
|
16
|
+
- **G1 continuity is certified by polynomial identity.** Two patches
|
|
17
|
+
meet G1 along a shared boundary iff the cross product of their
|
|
18
|
+
transversal tangents with the boundary tangent vanishes identically —
|
|
19
|
+
a polynomial in the boundary parameter. Checking it at more sample
|
|
20
|
+
points than its degree bound is a PROOF (a nonzero polynomial of
|
|
21
|
+
degree d has at most d roots), not a heuristic.
|
|
22
|
+
- **Coons patches** (bilinearly blended) from four Bézier boundaries:
|
|
23
|
+
ruled(u) + ruled(v) − bilinear, assembled exactly via Bézier degree
|
|
24
|
+
elevation and control-net arithmetic.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from fractions import Fraction
|
|
30
|
+
|
|
31
|
+
from forgekernel.interval import CInterval
|
|
32
|
+
from forgekernel.nurbs import BSplineSurface, bezier_surface, surface_partials2
|
|
33
|
+
|
|
34
|
+
F = Fraction
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -- Bézier net algebra --------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _elevate_row(row):
|
|
40
|
+
"""Degree-elevate a Bézier control row by one (exact convex comb.)."""
|
|
41
|
+
p = len(row) - 1
|
|
42
|
+
out = [row[0]]
|
|
43
|
+
for i in range(1, p + 1):
|
|
44
|
+
a = F(i, p + 1)
|
|
45
|
+
out.append(tuple(a * row[i - 1][c] + (1 - a) * row[i][c]
|
|
46
|
+
for c in range(3)))
|
|
47
|
+
out.append(row[-1])
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _elevate_u(net, times):
|
|
52
|
+
for _ in range(times):
|
|
53
|
+
cols = list(map(list, zip(*net)))
|
|
54
|
+
cols = [_elevate_row(c) for c in cols]
|
|
55
|
+
net = list(map(list, zip(*cols)))
|
|
56
|
+
return net
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _elevate_v(net, times):
|
|
60
|
+
for _ in range(times):
|
|
61
|
+
net = [_elevate_row(r) for r in net]
|
|
62
|
+
return net
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def coons_patch(c0, c1, d0, d1) -> BSplineSurface:
|
|
66
|
+
"""Bilinearly blended Coons patch from four Bézier boundary curves
|
|
67
|
+
(given as control-point lists): ``c0``/``c1`` run along u at v=0/1;
|
|
68
|
+
``d0``/``d1`` run along v at u=0/1. Corners must agree exactly.
|
|
69
|
+
|
|
70
|
+
S = Ruled_v(c0,c1) + Ruled_u(d0,d1) − Bilinear(corners), assembled
|
|
71
|
+
as one exact Bézier net via degree elevation."""
|
|
72
|
+
c0 = [tuple(F(x) for x in pt) for pt in c0]
|
|
73
|
+
c1 = [tuple(F(x) for x in pt) for pt in c1]
|
|
74
|
+
d0 = [tuple(F(x) for x in pt) for pt in d0]
|
|
75
|
+
d1 = [tuple(F(x) for x in pt) for pt in d1]
|
|
76
|
+
if len(c0) != len(c1) or len(d0) != len(d1):
|
|
77
|
+
raise ValueError("opposite boundaries must share degree (K6.1)")
|
|
78
|
+
# corner compatibility, exact
|
|
79
|
+
if not (c0[0] == d0[0] and c0[-1] == d1[0]
|
|
80
|
+
and c1[0] == d0[-1] and c1[-1] == d1[-1]):
|
|
81
|
+
raise ValueError("Coons boundaries disagree at a corner")
|
|
82
|
+
p = len(c0) - 1 # u-degree
|
|
83
|
+
q = len(d0) - 1 # v-degree
|
|
84
|
+
# ruled between c0 and c1: net rows along u, 2 columns in v
|
|
85
|
+
ruled_v = [[c0[i], c1[i]] for i in range(p + 1)] # (p+1)×2
|
|
86
|
+
ruled_u = [[d0[j] for j in range(q + 1)],
|
|
87
|
+
[d1[j] for j in range(q + 1)]] # 2×(q+1)
|
|
88
|
+
bilin = [[c0[0], c1[0]], [c0[-1], c1[-1]]] # 2×2
|
|
89
|
+
# elevate all three to degree (p, q)
|
|
90
|
+
A = _elevate_v(ruled_v, q - 1)
|
|
91
|
+
B = _elevate_u(ruled_u, p - 1)
|
|
92
|
+
C = _elevate_u(_elevate_v(bilin, q - 1), p - 1)
|
|
93
|
+
net = [[tuple(A[i][j][c] + B[i][j][c] - C[i][j][c] for c in range(3))
|
|
94
|
+
for j in range(q + 1)] for i in range(p + 1)]
|
|
95
|
+
return bezier_surface(net)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -- curvature -----------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _dot(a, b):
|
|
101
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cross(a, b):
|
|
105
|
+
return (a[1] * b[2] - a[2] * b[1],
|
|
106
|
+
a[2] * b[0] - a[0] * b[2],
|
|
107
|
+
a[0] * b[1] - a[1] * b[0])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def gaussian_curvature(surface: BSplineSurface, u, v) -> Fraction:
|
|
111
|
+
"""K at (u, v) — an EXACT rational for a polynomial surface.
|
|
112
|
+
Every |n| in the classical formula cancels:
|
|
113
|
+
K = ((S_uu·n)(S_vv·n) − (S_uv·n)²) / (EG − F²)² with n = S_u×S_v."""
|
|
114
|
+
_, su, sv, suu, suv, svv = surface_partials2(surface, u, v)
|
|
115
|
+
n = _cross(su, sv)
|
|
116
|
+
E, Ff, G = _dot(su, su), _dot(su, sv), _dot(sv, sv)
|
|
117
|
+
denom = (E * G - Ff * Ff) ** 2
|
|
118
|
+
if denom == 0:
|
|
119
|
+
raise ValueError("degenerate tangent plane (singular parameterization)")
|
|
120
|
+
return (_dot(suu, n) * _dot(svv, n) - _dot(suv, n) ** 2) / denom
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def mean_curvature(surface: BSplineSurface, u, v) -> CInterval:
|
|
124
|
+
"""H at (u, v) — certified (one genuine √ in (EG−F²)^{3/2}).
|
|
125
|
+
H = (E·N' − 2F·M' + G·L') / (2 (EG−F²)^{3/2}) with primes · n̂|n|."""
|
|
126
|
+
_, su, sv, suu, suv, svv = surface_partials2(surface, u, v)
|
|
127
|
+
n = _cross(su, sv)
|
|
128
|
+
E, Ff, G = _dot(su, su), _dot(su, sv), _dot(sv, sv)
|
|
129
|
+
W = E * G - Ff * Ff # = |n|²
|
|
130
|
+
if W == 0:
|
|
131
|
+
raise ValueError("degenerate tangent plane")
|
|
132
|
+
num = E * _dot(svv, n) - 2 * Ff * _dot(suv, n) + G * _dot(suu, n)
|
|
133
|
+
w32 = (CInterval.exact(W) * CInterval.exact(W) * CInterval.exact(W)).sqrt()
|
|
134
|
+
lo, hi = F(num) / w32.hi / 2, F(num) / w32.lo / 2
|
|
135
|
+
return CInterval(min(lo, hi), max(lo, hi))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -- G1 continuity certification -----------------------------------------------
|
|
139
|
+
|
|
140
|
+
def g1_certify(A: BSplineSurface, B: BSplineSurface, *,
|
|
141
|
+
a_edge: str = "u1", b_edge: str = "u0",
|
|
142
|
+
samples: int | None = None) -> bool:
|
|
143
|
+
"""Certify G1 continuity along a shared boundary by polynomial
|
|
144
|
+
identity testing: tangent planes agree along the seam iff
|
|
145
|
+
(t_A × t_B) · anything vanishes — concretely, the normals of A and
|
|
146
|
+
B are parallel at every boundary point, i.e. n_A × n_B ≡ 0, a
|
|
147
|
+
vector polynomial in the seam parameter. Its degree is bounded by
|
|
148
|
+
4·(p+q); checking MORE sample points than the bound and finding
|
|
149
|
+
zero every time is a proof, not a heuristic."""
|
|
150
|
+
def at(surface, edge, t):
|
|
151
|
+
(u0d, u1d), (v0d, v1d) = surface.domain()
|
|
152
|
+
u0d, u1d = F(u0d), F(u1d)
|
|
153
|
+
v0d, v1d = F(v0d), F(v1d)
|
|
154
|
+
if edge == "u1":
|
|
155
|
+
return F(u1d), v0d + t * (v1d - v0d)
|
|
156
|
+
if edge == "u0":
|
|
157
|
+
return F(u0d), v0d + t * (v1d - v0d)
|
|
158
|
+
if edge == "v1":
|
|
159
|
+
return u0d + t * (u1d - u0d), F(v1d)
|
|
160
|
+
return u0d + t * (u1d - u0d), F(v0d)
|
|
161
|
+
|
|
162
|
+
bound = 4 * (A.p + A.q + B.p + B.q) + 1
|
|
163
|
+
n = samples or bound
|
|
164
|
+
for k in range(n + 1):
|
|
165
|
+
t = F(k, n)
|
|
166
|
+
ua, va = at(A, a_edge, t)
|
|
167
|
+
ub, vb = at(B, b_edge, t)
|
|
168
|
+
# positions must coincide exactly (G0 first)
|
|
169
|
+
if A.eval(ua, va) != B.eval(ub, vb):
|
|
170
|
+
return False
|
|
171
|
+
na = A.normal(ua, va)
|
|
172
|
+
nb = B.normal(ub, vb)
|
|
173
|
+
if _cross(na, nb) != (0, 0, 0):
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# -- K6.1: G2 certification + curvature combs ----------------------------------
|
|
179
|
+
|
|
180
|
+
def g2_certify(A: BSplineSurface, B: BSplineSurface, *,
|
|
181
|
+
a_edge: str = "u1", b_edge: str = "u0",
|
|
182
|
+
samples: int | None = None) -> bool:
|
|
183
|
+
"""Certify G2 (curvature continuity) along a shared seam, exactly.
|
|
184
|
+
|
|
185
|
+
Requires G1 first (checked). Then the transversal normal curvature
|
|
186
|
+
must agree at every seam point. With unnormalized normals the
|
|
187
|
+
comparison cross-multiplies into pure rationals:
|
|
188
|
+
|
|
189
|
+
κ_n = (S_tt·n̂)/|S_t'|²-ish terms → compare
|
|
190
|
+
(S_tt^A·n_A)²·W_B == (S_tt^B·n_B)²·W_A and matching sign,
|
|
191
|
+
|
|
192
|
+
where W = |n|² = EG−F² is rational. Zero at more samples than the
|
|
193
|
+
polynomial degree bound ⇒ identically zero: a proof."""
|
|
194
|
+
from forgekernel.nurbs import surface_partials2
|
|
195
|
+
|
|
196
|
+
if not g1_certify(A, B, a_edge=a_edge, b_edge=b_edge, samples=samples):
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def at(surface, edge, t):
|
|
200
|
+
(u0d, u1d), (v0d, v1d) = surface.domain()
|
|
201
|
+
u0d, u1d, v0d, v1d = F(u0d), F(u1d), F(v0d), F(v1d)
|
|
202
|
+
if edge == "u1":
|
|
203
|
+
return F(u1d), v0d + t * (v1d - v0d)
|
|
204
|
+
if edge == "u0":
|
|
205
|
+
return F(u0d), v0d + t * (v1d - v0d)
|
|
206
|
+
if edge == "v1":
|
|
207
|
+
return u0d + t * (u1d - u0d), F(v1d)
|
|
208
|
+
return u0d + t * (u1d - u0d), F(v0d)
|
|
209
|
+
|
|
210
|
+
def transversal_kn(surface, edge, uu, vv):
|
|
211
|
+
"""(S_tt·n, W) for the TRANSVERSAL parameter curve at the seam
|
|
212
|
+
(the u-direction for u-edges, v-direction for v-edges)."""
|
|
213
|
+
_, su, sv, suu, suv, svv = surface_partials2(surface, uu, vv)
|
|
214
|
+
n = _cross(su, sv)
|
|
215
|
+
W = _dot(n, n)
|
|
216
|
+
stt = suu if edge in ("u0", "u1") else svv
|
|
217
|
+
return _dot(stt, n), W, n
|
|
218
|
+
|
|
219
|
+
bound = 8 * (A.p + A.q + B.p + B.q) + 1
|
|
220
|
+
ns = samples or bound
|
|
221
|
+
for k in range(ns + 1):
|
|
222
|
+
t = F(k, ns)
|
|
223
|
+
ua, va = at(A, a_edge, t)
|
|
224
|
+
ub, vb = at(B, b_edge, t)
|
|
225
|
+
Xa, Wa, na = transversal_kn(A, a_edge, ua, va)
|
|
226
|
+
Xb, Wb, nb = transversal_kn(B, b_edge, ub, vb)
|
|
227
|
+
if Wa == 0 or Wb == 0:
|
|
228
|
+
return False
|
|
229
|
+
# sign of n_B relative to n_A (G1 already guarantees parallel)
|
|
230
|
+
s = 1 if _dot(na, nb) > 0 else -1
|
|
231
|
+
# κ_n^A == κ_n^B in A's orientation, cross-multiplied (exact):
|
|
232
|
+
if Xa * Xa * Wb != Xb * Xb * Wa:
|
|
233
|
+
return False
|
|
234
|
+
# signs must agree too (curvature bowing the same way)
|
|
235
|
+
if (Xa > 0) != (s * Xb > 0) and not (Xa == 0 and Xb == 0):
|
|
236
|
+
return False
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def curve_curvature_comb(curve, n: int = 32, scale: float = 1.0):
|
|
241
|
+
"""Curvature comb for a Bézier/B-spline curve — the visual surfacing
|
|
242
|
+
audit tool. Returns [(point, tip), ...] where tip = point + scale·κ·N̂.
|
|
243
|
+
κ = |c'×c''|/|c'|³ has one √ per sample: magnitudes are certified-
|
|
244
|
+
exact ratios evaluated to float only for the viewer."""
|
|
245
|
+
import math
|
|
246
|
+
|
|
247
|
+
from forgekernel.nurbs import _deboor4, _hodo_list
|
|
248
|
+
|
|
249
|
+
p, U = curve.p, curve.U
|
|
250
|
+
pts = [tuple(pt) for pt in curve.cp]
|
|
251
|
+
(t0, t1) = curve.domain()
|
|
252
|
+
out = []
|
|
253
|
+
p1, U1, D1 = _hodo_list(p, U, pts)
|
|
254
|
+
p2, U2, D2 = _hodo_list(p1, U1, D1) if p1 >= 1 else (-1, [], [])
|
|
255
|
+
for k in range(n + 1):
|
|
256
|
+
t = F(t0) + F(k, n) * (F(t1) - F(t0))
|
|
257
|
+
c = curve.eval(t)
|
|
258
|
+
d1 = _deboor4(p1, U1, D1, t) if D1 else (F(0),) * 3
|
|
259
|
+
d2 = _deboor4(p2, U2, D2, t) if p2 >= 0 and D2 else (F(0),) * 3
|
|
260
|
+
cx = _cross(d1, d2)
|
|
261
|
+
num2 = _dot(cx, cx) # |c'×c''|², exact
|
|
262
|
+
den2 = _dot(d1, d1) # |c'|², exact
|
|
263
|
+
if den2 == 0:
|
|
264
|
+
continue
|
|
265
|
+
kappa = math.sqrt(float(num2)) / float(den2) ** 1.5
|
|
266
|
+
# normal direction: component of c'' orthogonal to c'
|
|
267
|
+
proj = _dot(d2, d1)
|
|
268
|
+
nvec = tuple(float(d2[c]) - float(proj / den2) * float(d1[c])
|
|
269
|
+
for c in range(3))
|
|
270
|
+
nlen = math.sqrt(sum(x * x for x in nvec)) or 1.0
|
|
271
|
+
pt = tuple(float(x) for x in c)
|
|
272
|
+
tip = tuple(pt[c] + scale * kappa * nvec[c] / nlen for c in range(3))
|
|
273
|
+
out.append((pt, tip))
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# -- K6.2: G1 blend strip (fill between two patches, proven smooth) -----------
|
|
278
|
+
|
|
279
|
+
def _edge_data(surface: BSplineSurface, edge: str):
|
|
280
|
+
"""(boundary curve net, transversal derivative net) along an edge —
|
|
281
|
+
exact Bézier control rows extracted from the surface net.
|
|
282
|
+
|
|
283
|
+
For a Bézier patch the boundary curve is the edge row/column of the
|
|
284
|
+
control net, and the transversal derivative along that edge is
|
|
285
|
+
p·(row₁ − row₀) (a Bézier curve of the edge parameter)."""
|
|
286
|
+
net = surface.cp
|
|
287
|
+
p, q = surface.p, surface.q
|
|
288
|
+
if any(w != F(1) for row in surface.w for w in row):
|
|
289
|
+
raise ValueError("blend strip: polynomial patches only (K6.3)")
|
|
290
|
+
if edge == "u1": # u = 1 edge, parameter runs along v
|
|
291
|
+
c = net[-1]
|
|
292
|
+
d = [tuple(F(p) * (net[-1][j][k] - net[-2][j][k]) for k in range(3))
|
|
293
|
+
for j in range(q + 1)]
|
|
294
|
+
elif edge == "u0":
|
|
295
|
+
c = net[0]
|
|
296
|
+
d = [tuple(F(p) * (net[1][j][k] - net[0][j][k]) for k in range(3))
|
|
297
|
+
for j in range(q + 1)]
|
|
298
|
+
elif edge == "v1":
|
|
299
|
+
c = [net[i][-1] for i in range(p + 1)]
|
|
300
|
+
d = [tuple(F(q) * (net[i][-1][k] - net[i][-2][k]) for k in range(3))
|
|
301
|
+
for i in range(p + 1)]
|
|
302
|
+
else:
|
|
303
|
+
c = [net[i][0] for i in range(p + 1)]
|
|
304
|
+
d = [tuple(F(q) * (net[i][1][k] - net[i][0][k]) for k in range(3))
|
|
305
|
+
for i in range(p + 1)]
|
|
306
|
+
return [tuple(x) for x in c], d
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def g1_blend_strip(A: BSplineSurface, B: BSplineSurface, *,
|
|
310
|
+
a_edge: str = "u1", b_edge: str = "u0") -> BSplineSurface:
|
|
311
|
+
"""Fill the gap between an edge of ``A`` and an edge of ``B`` with a
|
|
312
|
+
cubic Hermite strip that meets BOTH with G1 continuity — by
|
|
313
|
+
construction, and certifiable by :func:`g1_certify` afterwards.
|
|
314
|
+
|
|
315
|
+
The strip is cubic transversally: Hermite data (position + outward
|
|
316
|
+
transversal derivative) at each end, converted to an exact Bézier
|
|
317
|
+
net via the Hermite→Bézier map [P0, P0+m0/3, P1−m1/3, P1]."""
|
|
318
|
+
ca, da = _edge_data(A, a_edge)
|
|
319
|
+
cb, db = _edge_data(B, b_edge)
|
|
320
|
+
if len(ca) != len(cb):
|
|
321
|
+
raise ValueError("blend strip: edges must share degree (K6.3)")
|
|
322
|
+
# outward direction: A's edge derivative points OUT of A when the
|
|
323
|
+
# edge is u1/v1, INTO A when u0/v0 (flip). Likewise B's must point
|
|
324
|
+
# INTO the strip (i.e. out of B flipped at u1/v1... B's edge faces
|
|
325
|
+
# the strip, so B's outward transversal at u0/v0 is already inward).
|
|
326
|
+
sa = 1 if a_edge in ("u1", "v1") else -1
|
|
327
|
+
sb = 1 if b_edge in ("u1", "v1") else -1
|
|
328
|
+
n = len(ca)
|
|
329
|
+
net = []
|
|
330
|
+
for i in range(n):
|
|
331
|
+
p0 = ca[i]
|
|
332
|
+
p1 = cb[i]
|
|
333
|
+
m0 = tuple(sa * da[i][k] for k in range(3)) # d/dv at strip v=0
|
|
334
|
+
m1 = tuple(-sb * db[i][k] for k in range(3)) # d/dv at strip v=1
|
|
335
|
+
net.append([
|
|
336
|
+
p0,
|
|
337
|
+
tuple(p0[k] + m0[k] / 3 for k in range(3)),
|
|
338
|
+
tuple(p1[k] - m1[k] / 3 for k in range(3)),
|
|
339
|
+
p1,
|
|
340
|
+
])
|
|
341
|
+
return bezier_surface(net)
|
forgekernel/tess.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tessellation of the analytic composites (K2.x) for the viewer.
|
|
2
|
+
|
|
3
|
+
Curved solids are exact objects; a mesh is a bounded-error VIEW of them.
|
|
4
|
+
``deflection`` is the max chord error (mm) at the boundary — the one
|
|
5
|
+
documented place a float enters, and only for display. Segment count is
|
|
6
|
+
derived so the chord error stays under deflection: for radius r,
|
|
7
|
+
N = ceil(pi / arccos(1 - deflection/r)).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _nseg(radius: float, deflection: float) -> int:
|
|
16
|
+
r = max(radius, 1e-9)
|
|
17
|
+
if deflection >= r:
|
|
18
|
+
return 8
|
|
19
|
+
return max(8, math.ceil(math.pi / math.acos(max(-1.0, 1 - deflection / r))))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def lathe(profile_rz: list[tuple], deflection: float = 0.2,
|
|
23
|
+
cx: float = 0.0, cy: float = 0.0) -> dict:
|
|
24
|
+
"""Mesh a surface of revolution from a closed (r, z) profile revolved
|
|
25
|
+
about the z axis through (cx, cy). Deterministic; returns
|
|
26
|
+
{vertices, triangles}."""
|
|
27
|
+
rmax = max(r for r, _ in profile_rz) or 1.0
|
|
28
|
+
n = _nseg(rmax, deflection)
|
|
29
|
+
verts: list[list[float]] = []
|
|
30
|
+
tris: list[list[int]] = []
|
|
31
|
+
ring_start: list[int] = []
|
|
32
|
+
for r, z in profile_rz:
|
|
33
|
+
base = len(verts)
|
|
34
|
+
ring_start.append(base)
|
|
35
|
+
if r == 0:
|
|
36
|
+
verts.append([cx, cy, float(z)])
|
|
37
|
+
else:
|
|
38
|
+
for k in range(n):
|
|
39
|
+
a = 2 * math.pi * k / n
|
|
40
|
+
verts.append([cx + r * math.cos(a), cy + r * math.sin(a),
|
|
41
|
+
float(z)])
|
|
42
|
+
m = len(profile_rz)
|
|
43
|
+
for i in range(m):
|
|
44
|
+
j = (i + 1) % m
|
|
45
|
+
ra = profile_rz[i][0]
|
|
46
|
+
rb = profile_rz[j][0]
|
|
47
|
+
a0, b0 = ring_start[i], ring_start[j]
|
|
48
|
+
if ra == 0 and rb == 0:
|
|
49
|
+
continue
|
|
50
|
+
for k in range(n):
|
|
51
|
+
kn = (k + 1) % n
|
|
52
|
+
if ra == 0:
|
|
53
|
+
tris.append([a0, b0 + k, b0 + kn])
|
|
54
|
+
elif rb == 0:
|
|
55
|
+
tris.append([a0 + k, b0, a0 + kn])
|
|
56
|
+
else:
|
|
57
|
+
tris.append([a0 + k, b0 + k, b0 + kn])
|
|
58
|
+
tris.append([a0 + k, b0 + kn, a0 + kn])
|
|
59
|
+
return {"vertices": verts, "triangles": tris}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def mesh_volume(mesh: dict) -> float:
|
|
63
|
+
"""Signed volume of a triangle mesh (divergence theorem) — the test
|
|
64
|
+
hook proving the mesh approximates the exact solid."""
|
|
65
|
+
v = mesh["vertices"]
|
|
66
|
+
total = 0.0
|
|
67
|
+
for a, b, c in mesh["triangles"]:
|
|
68
|
+
ax, ay, az = v[a]
|
|
69
|
+
bx, by, bz = v[b]
|
|
70
|
+
cx, cy, cz = v[c]
|
|
71
|
+
total += (ax * (by * cz - bz * cy)
|
|
72
|
+
- ay * (bx * cz - bz * cx)
|
|
73
|
+
+ az * (bx * cy - by * cx))
|
|
74
|
+
return abs(total) / 6.0
|
forgekernel/trim.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""K7 — trimmed-patch topology: a surface patch plus parameter-space trim
|
|
2
|
+
loops, with **exact** point-in-region classification.
|
|
3
|
+
|
|
4
|
+
A boolean over freeform solids produces faces that are only *part* of a
|
|
5
|
+
surface — the region bounded by intersection curves. We represent that as
|
|
6
|
+
the untrimmed patch (any surface exposing ``domain()``/``eval()``) plus a
|
|
7
|
+
set of trim loops in the patch's (u, v) parameter domain:
|
|
8
|
+
|
|
9
|
+
loops[0] — the outer boundary
|
|
10
|
+
loops[1:] — holes punched out of it
|
|
11
|
+
|
|
12
|
+
Trim-loop vertices carry exact rational (u, v) coordinates: they come from
|
|
13
|
+
certified SSI points (:func:`forgekernel.ssi.ssi_curves`) and patch-corner
|
|
14
|
+
vertices, both already in ℚ. So the point-in-region test — even-odd ray
|
|
15
|
+
parity — is decided in ℚ with **no tolerance**. That exactness is the whole
|
|
16
|
+
point: which side of a trim boundary a point lies on is a *topological*
|
|
17
|
+
decision, and ADR-0019 forbids a float from making it. A point that lands
|
|
18
|
+
exactly on the boundary is reported as ``"on"`` (also an exact predicate),
|
|
19
|
+
never silently bucketed into "in" or "out".
|
|
20
|
+
|
|
21
|
+
The even-odd rule classifies correctly regardless of each loop's winding
|
|
22
|
+
(a point inside a hole crosses outer+inner = even ⇒ out); loop orientation
|
|
23
|
+
matters only for the signed parameter-domain area, not for containment.
|
|
24
|
+
|
|
25
|
+
Not modeled here (deliberately): the *surface* area/volume of the trimmed
|
|
26
|
+
region. Its integrand is the surface Jacobian and the trim curve is only
|
|
27
|
+
polyline-approximated in parameter space, so that measure is not exact —
|
|
28
|
+
it belongs to the later Green's-over-trim-loops step, not this type.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from fractions import Fraction as F
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_uv(pt):
|
|
37
|
+
return (F(pt[0]), F(pt[1]))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _orient(a, b, c):
|
|
41
|
+
"""Sign of the cross product (b−a)×(c−a): +1 left, −1 right, 0 collinear.
|
|
42
|
+
Exact in ℚ."""
|
|
43
|
+
d = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
44
|
+
return (d > 0) - (d < 0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _segments_properly_cross(a, b, c, d) -> bool:
|
|
48
|
+
"""Do open segments a→b and c→d cross at an interior point? Exact ℚ, and
|
|
49
|
+
strict: shared endpoints or collinear touching do NOT count (those are
|
|
50
|
+
legal hole-touches-outer contacts, not a boundary crossing)."""
|
|
51
|
+
o1, o2 = _orient(a, b, c), _orient(a, b, d)
|
|
52
|
+
o3, o4 = _orient(c, d, a), _orient(c, d, b)
|
|
53
|
+
return o1 != 0 and o2 != 0 and o3 != 0 and o4 != 0 \
|
|
54
|
+
and o1 != o2 and o3 != o4
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TrimmedPatch:
|
|
58
|
+
"""A surface restricted to the region its trim loops enclose.
|
|
59
|
+
|
|
60
|
+
``surface`` is any object with ``domain()`` -> ((u0,u1),(v0,v1)) and
|
|
61
|
+
``eval(u, v)``. ``loops`` is a list of loops, each an ordered list of
|
|
62
|
+
(u, v) vertices (the closing edge back to the first vertex is implicit);
|
|
63
|
+
``loops[0]`` is the outer boundary and the rest are holes."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, surface, loops) -> None:
|
|
66
|
+
self.surface = surface
|
|
67
|
+
self.loops = [[_as_uv(p) for p in loop] for loop in loops]
|
|
68
|
+
if not self.loops:
|
|
69
|
+
raise ValueError("trimmed patch needs at least an outer loop")
|
|
70
|
+
for i, loop in enumerate(self.loops):
|
|
71
|
+
if len(loop) < 3:
|
|
72
|
+
raise ValueError(f"loop {i}: a trim loop needs >= 3 vertices")
|
|
73
|
+
|
|
74
|
+
# -- exact predicates ------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _on_segment(u, v, a, b) -> bool:
|
|
78
|
+
"""Is (u, v) exactly on the closed segment a→b? (exact in ℚ)."""
|
|
79
|
+
cross = (b[0] - a[0]) * (v - a[1]) - (b[1] - a[1]) * (u - a[0])
|
|
80
|
+
if cross != 0:
|
|
81
|
+
return False
|
|
82
|
+
# collinear — inside the segment's bounding box?
|
|
83
|
+
return (min(a[0], b[0]) <= u <= max(a[0], b[0])
|
|
84
|
+
and min(a[1], b[1]) <= v <= max(a[1], b[1]))
|
|
85
|
+
|
|
86
|
+
def classify(self, u, v) -> str:
|
|
87
|
+
"""Exact point-in-region: ``"in"`` (strict interior), ``"on"`` (on a
|
|
88
|
+
trim edge), or ``"out"``. All decided in ℚ — no tolerance."""
|
|
89
|
+
u, v = F(u), F(v)
|
|
90
|
+
# boundary first: an on-edge point is neither in nor out
|
|
91
|
+
for loop in self.loops:
|
|
92
|
+
m = len(loop)
|
|
93
|
+
for k in range(m):
|
|
94
|
+
if self._on_segment(u, v, loop[k], loop[(k + 1) % m]):
|
|
95
|
+
return "on"
|
|
96
|
+
# even-odd parity of a +u ray at height v, over every loop's edges
|
|
97
|
+
inside = False
|
|
98
|
+
for loop in self.loops:
|
|
99
|
+
m = len(loop)
|
|
100
|
+
for k in range(m):
|
|
101
|
+
a, b = loop[k], loop[(k + 1) % m]
|
|
102
|
+
if (a[1] > v) != (b[1] > v):
|
|
103
|
+
# u of the edge's crossing with the horizontal line at v
|
|
104
|
+
x_int = a[0] + (v - a[1]) * (b[0] - a[0]) / (b[1] - a[1])
|
|
105
|
+
if u < x_int:
|
|
106
|
+
inside = not inside
|
|
107
|
+
return "in" if inside else "out"
|
|
108
|
+
|
|
109
|
+
def contains(self, u, v) -> bool:
|
|
110
|
+
"""True iff (u, v) is in the strict interior of the trimmed region."""
|
|
111
|
+
return self.classify(u, v) == "in"
|
|
112
|
+
|
|
113
|
+
# -- exact volume contribution (K7 flux over the trim loops) ---------------
|
|
114
|
+
|
|
115
|
+
def flux(self):
|
|
116
|
+
"""This trimmed face's exact flux contribution
|
|
117
|
+
(1/3)∮∮_D S·(S_u×S_v) du dv to the enclosed solid volume, via Green's
|
|
118
|
+
theorem over the trim loops (:func:`forgekernel.bsolid.trimmed_patch_flux`).
|
|
119
|
+
Requires a polynomial surface. Loops are winding-normalized first so
|
|
120
|
+
the outer/hole orientation is correct. Summing ``flux`` over the
|
|
121
|
+
outward-oriented faces of a closed shell gives the solid volume."""
|
|
122
|
+
from forgekernel.bsolid import trimmed_patch_flux
|
|
123
|
+
n = self.normalized()
|
|
124
|
+
return trimmed_patch_flux(n.surface, n.loops)
|
|
125
|
+
|
|
126
|
+
# -- exact measures --------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _loop_signed_area(loop):
|
|
130
|
+
"""Shoelace signed area (exact ℚ); CCW positive."""
|
|
131
|
+
m = len(loop)
|
|
132
|
+
s = F(0)
|
|
133
|
+
for k in range(m):
|
|
134
|
+
a, b = loop[k], loop[(k + 1) % m]
|
|
135
|
+
s += a[0] * b[1] - b[0] * a[1]
|
|
136
|
+
return s / 2
|
|
137
|
+
|
|
138
|
+
def signed_area(self):
|
|
139
|
+
"""Exact signed parameter-domain area = outer + Σ holes, using each
|
|
140
|
+
loop's given winding (outer CCW +, holes CW −). This is the (u, v)
|
|
141
|
+
measure of the region, NOT the surface area — see the module note."""
|
|
142
|
+
return sum((self._loop_signed_area(loop) for loop in self.loops), F(0))
|
|
143
|
+
|
|
144
|
+
def area(self):
|
|
145
|
+
"""Exact unsigned parameter-domain area: |outer| − Σ|holes|."""
|
|
146
|
+
loops = self.loops
|
|
147
|
+
outer = abs(self._loop_signed_area(loops[0]))
|
|
148
|
+
holes = sum(abs(self._loop_signed_area(h)) for h in loops[1:])
|
|
149
|
+
return outer - holes
|
|
150
|
+
|
|
151
|
+
# -- orientation / validation ---------------------------------------------
|
|
152
|
+
|
|
153
|
+
def is_ccw(self, index: int = 0) -> bool:
|
|
154
|
+
return self._loop_signed_area(self.loops[index]) > 0
|
|
155
|
+
|
|
156
|
+
def normalized(self) -> "TrimmedPatch":
|
|
157
|
+
"""Return a copy with canonical winding: outer CCW, holes CW."""
|
|
158
|
+
out = []
|
|
159
|
+
for i, loop in enumerate(self.loops):
|
|
160
|
+
ccw = self._loop_signed_area(loop) > 0
|
|
161
|
+
want_ccw = (i == 0)
|
|
162
|
+
out.append(list(loop) if ccw == want_ccw else list(reversed(loop)))
|
|
163
|
+
return TrimmedPatch(self.surface, out)
|
|
164
|
+
|
|
165
|
+
def validate(self) -> None:
|
|
166
|
+
"""Structural checks: every vertex inside the surface's parameter
|
|
167
|
+
domain, holes strictly inside the outer boundary."""
|
|
168
|
+
(u0, u1), (v0, v1) = self.surface.domain()
|
|
169
|
+
for i, loop in enumerate(self.loops):
|
|
170
|
+
for (u, v) in loop:
|
|
171
|
+
if not (u0 <= u <= u1 and v0 <= v <= v1):
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"loop {i}: vertex ({u},{v}) outside surface domain")
|
|
174
|
+
outer = TrimmedPatch(self.surface, [self.loops[0]])
|
|
175
|
+
outer_loop = self.loops[0]
|
|
176
|
+
no = len(outer_loop)
|
|
177
|
+
for i, hole in enumerate(self.loops[1:], start=1):
|
|
178
|
+
# WHOLE-polygon containment, not a single sample point: every hole
|
|
179
|
+
# vertex must be in-or-on the outer boundary AND no hole edge may
|
|
180
|
+
# properly cross an outer edge. Testing only the vertex average
|
|
181
|
+
# accepts holes that stick out through the boundary and rejects
|
|
182
|
+
# valid holes whose average lands in a concavity (reviewed 2026-07).
|
|
183
|
+
for (u, v) in hole:
|
|
184
|
+
if outer.classify(u, v) == "out":
|
|
185
|
+
raise ValueError(f"hole {i} is not inside the outer boundary")
|
|
186
|
+
mh = len(hole)
|
|
187
|
+
for a in range(mh):
|
|
188
|
+
h0, h1 = hole[a], hole[(a + 1) % mh]
|
|
189
|
+
for b in range(no):
|
|
190
|
+
o0, o1 = outer_loop[b], outer_loop[(b + 1) % no]
|
|
191
|
+
if _segments_properly_cross(h0, h1, o0, o1):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"hole {i} crosses the outer boundary")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgekernel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Exact-arithmetic B-rep CAD kernel: topology decided in ℚ, never by floating-point epsilon
|
|
5
|
+
Author-email: Dan Willis <danielcwillis@gmail.com>
|
|
6
|
+
Maintainer-email: Dan Willis <danielcwillis@gmail.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Project-URL: Homepage, https://github.com/gitcad-xyz/forge
|
|
9
|
+
Project-URL: Repository, https://github.com/gitcad-xyz/forge
|
|
10
|
+
Project-URL: Issues, https://github.com/gitcad-xyz/forge/issues
|
|
11
|
+
Project-URL: Design — ADR-0018, https://github.com/gitcad-xyz/gitcad/blob/main/docs/adr/0018-native-kernel.md
|
|
12
|
+
Project-URL: Benchmarks vs OCCT, https://github.com/gitcad-xyz/gitcad/blob/main/bench/RUST-vs-OCCT.md
|
|
13
|
+
Keywords: cad,b-rep,brep,geometry,kernel,nurbs,solid-modeling,computational-geometry,exact-arithmetic,rational-arithmetic,step,occt,opencascade
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Manufacturing
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
26
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Provides-Extra: rust
|
|
31
|
+
Requires-Dist: forgekernel_rs>=0.1.0; extra == "rust"
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# forge — the gitcad-native B-rep kernel
|
|
37
|
+
|
|
38
|
+
From-scratch, unencumbered (Apache-2.0), built to pass the bar OCCT
|
|
39
|
+
sets and then raise it — the plan and the decision record live in
|
|
40
|
+
gitcad ([ADR-0018](https://github.com/gitcad-xyz/gitcad/blob/main/docs/adr/0018-native-kernel.md),
|
|
41
|
+
[coverage plan](https://github.com/gitcad-xyz/gitcad/blob/main/docs/research/kernel-coverage-plan.md)).
|
|
42
|
+
|
|
43
|
+
## The three-oracle chain
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
forgekernel.ref Python + exact rational arithmetic — the executable
|
|
47
|
+
specification; topological decisions are EXACT, never
|
|
48
|
+
epsilon-guarded
|
|
49
|
+
⇅ differential
|
|
50
|
+
OCCT the 30-year-hardened independent oracle, driven from
|
|
51
|
+
the gitcad benchmark corpus through the Kernel seam
|
|
52
|
+
⇅ oracle
|
|
53
|
+
forge (Rust) the production port, added operator class by operator
|
|
54
|
+
class once ref has proven the semantics
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How progress is measured
|
|
58
|
+
|
|
59
|
+
Not vibes: the [benchmark trend](https://github.com/gitcad-xyz/gitcad/blob/main/bench/TREND.md)
|
|
60
|
+
in the gitcad repo scores every backend on the shared corpus —
|
|
61
|
+
capability %, torture-case pass rate, correctness deltas, wall time.
|
|
62
|
+
Day-one baseline: OCCT scores 93.8%, failing `swept_channel`
|
|
63
|
+
(sharp-cornered sweep → invalid geometry). Beating that number with
|
|
64
|
+
exact arithmetic is the first milestone.
|
|
65
|
+
|
|
66
|
+
## Roadmap (gates, not dates)
|
|
67
|
+
|
|
68
|
+
- **K1** exact planar core: rational linear algebra, plane-based
|
|
69
|
+
polyhedral B-rep, epsilon-free booleans, divergence-theorem mass
|
|
70
|
+
properties, native lineage. Gate: planar corpus green vs OCCT.
|
|
71
|
+
- **K2** quadrics + torus with closed-form intersectors — most of
|
|
72
|
+
real mech. Gate: ≥80% corpus green.
|
|
73
|
+
- **K3** NURBS + branch-complete surface–surface intersection (the
|
|
74
|
+
crown jewel). **K4** procedural offsets/shell. **K5** G2 blends.
|
|
75
|
+
**K6** the surfacing suite — the SolidWorks-class end goal.
|
|
76
|
+
|
|
77
|
+
## Layout
|
|
78
|
+
|
|
79
|
+
- `src/forgekernel/` — the Python reference kernel (`ref`)
|
|
80
|
+
- `rust/` — the forge port (arrives with K1 stability)
|
|
81
|
+
- gitcad consumes both through its `Kernel` seam; nothing here depends
|
|
82
|
+
on gitcad
|