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.
@@ -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