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,228 @@
1
+ """K3.8 — spline sketch profiles with EXACT area (Green's theorem).
2
+
3
+ A closed 2D profile of line and polynomial-Bézier segments encloses an
4
+ area that is *exactly rational*:
5
+
6
+ A = ½ ∮ (x dy − y dx)
7
+
8
+ Over a line segment this is ½(x0 y1 − x1 y0); over a Bézier segment
9
+ x(t), y(t) are polynomials, so ½(x y' − y x') is a polynomial and its
10
+ integral over [0,1] is exact. Extruding such a profile therefore has an
11
+ exactly rational volume A·h — a curved-boundary solid OCCT can only
12
+ Gauss-quadrature.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from fractions import Fraction
18
+
19
+ from forgekernel.bsolid import _lagrange_weights, _nodes
20
+
21
+ F = Fraction
22
+
23
+
24
+ def _bezier2(pts, t):
25
+ """De Casteljau on 2D control points at parameter t (exact)."""
26
+ p = [(F(a), F(b)) for a, b in pts]
27
+ n = len(p)
28
+ for r in range(1, n):
29
+ p = [((1 - t) * p[i][0] + t * p[i + 1][0],
30
+ (1 - t) * p[i][1] + t * p[i + 1][1]) for i in range(n - r)]
31
+ return p[0]
32
+
33
+
34
+ def _bezier2_d(pts):
35
+ """Control points of the derivative curve (degree p-1), exact."""
36
+ p = len(pts) - 1
37
+ return [(F(p) * (F(pts[i + 1][0]) - F(pts[i][0])),
38
+ F(p) * (F(pts[i + 1][1]) - F(pts[i][1]))) for i in range(p)]
39
+
40
+
41
+ def segments_to_beziers(start, segments):
42
+ """Normalize a profile into a list of Bézier control-point lists.
43
+ Lines → degree-1 Béziers; arcs are rejected here (they belong to the
44
+ ℚ[π] path). ``spline`` segments carry explicit control points."""
45
+ beziers = []
46
+ cur = (F(start[0]), F(start[1]))
47
+ for seg in segments:
48
+ kind = seg["kind"]
49
+ to = (F(seg["to"][0]), F(seg["to"][1]))
50
+ if kind == "line":
51
+ beziers.append([cur, to])
52
+ elif kind == "spline":
53
+ # Bézier control points between cur and to (exclusive endpoints
54
+ # given in "ctrl"); a cubic if two ctrl points, etc.
55
+ ctrl = [(F(a), F(b)) for a, b in seg.get("ctrl", [])]
56
+ beziers.append([cur, *ctrl, to])
57
+ else:
58
+ raise ValueError(f"profile2d: segment kind {kind!r} not exact "
59
+ f"(arcs → ℚ[π] path)")
60
+ cur = to
61
+ # close the loop back to the start if the last point doesn't already —
62
+ # Green's theorem needs a CLOSED contour or the area is meaningless
63
+ # (the line-extrude path auto-closes; the spline path must too).
64
+ s0 = (F(start[0]), F(start[1]))
65
+ if cur != s0:
66
+ beziers.append([cur, s0])
67
+ return beziers
68
+
69
+
70
+ def exact_signed_area(start, segments) -> Fraction:
71
+ """Signed area of the closed profile via Green's theorem — exact ℚ.
72
+ CCW positive. Keep the sign when pairing with the area moments so the
73
+ orientation cancels in the centroid ratio."""
74
+ beziers = segments_to_beziers(start, segments)
75
+ total = F(0)
76
+ for bez in beziers:
77
+ if len(bez) == 2: # line: ½(x0 y1 − x1 y0)
78
+ (x0, y0), (x1, y1) = bez
79
+ total += (x0 * y1 - x1 * y0) / 2
80
+ else: # Bézier: ∫ ½(x y' − y x') dt
81
+ dctrl = _bezier2_d(bez)
82
+ p = len(bez) - 1
83
+ # integrand degree ≤ 2p-1 → 2p nodes exact
84
+ nn = _nodes(2 * p)
85
+ ww = _lagrange_weights(nn)
86
+ seg_int = F(0)
87
+ for w, t in zip(ww, nn):
88
+ x, y = _bezier2(bez, t)
89
+ dx, dy = _bezier2(dctrl, t)
90
+ seg_int += w * (x * dy - y * dx) / 2
91
+ total += seg_int
92
+ return total
93
+
94
+
95
+ def exact_area(start, segments) -> Fraction:
96
+ """Absolute enclosed area (a positive Fraction), exact ℚ."""
97
+ return abs(exact_signed_area(start, segments))
98
+
99
+
100
+ def exact_moments(start, segments):
101
+ """First area moments (Qx = ∫∫ x dA, Qy = ∫∫ y dA) of the closed profile
102
+ via Green's theorem — exact ℚ. Qx = ∮ ½x² dy and Qy = −∮ ½y² dx; the
103
+ integrand ``x²·y'`` has degree ≤ 3p−1 over a degree-p Bézier, so 3p
104
+ interpolatory nodes integrate it exactly. Signs follow the loop
105
+ orientation (pair with :func:`exact_signed_area`)."""
106
+ beziers = segments_to_beziers(start, segments)
107
+ qx = qy = F(0)
108
+ for bez in beziers:
109
+ dctrl = _bezier2_d(bez)
110
+ p = len(bez) - 1
111
+ nn = _nodes(3 * p)
112
+ ww = _lagrange_weights(nn)
113
+ for w, t in zip(ww, nn):
114
+ x, y = _bezier2(bez, t)
115
+ dx, dy = _bezier2(dctrl, t)
116
+ qx += w * (x * x) / 2 * dy
117
+ qy += w * (-(y * y)) / 2 * dx
118
+ return qx, qy
119
+
120
+
121
+ def _boundary_is_simple(start, segments, samples: int = 16) -> bool:
122
+ """Flatten the line/Bézier boundary to a polyline and test for a proper
123
+ self-intersection of non-adjacent edges (a simple-loop guard)."""
124
+ ring = []
125
+ for bez in segments_to_beziers(start, segments):
126
+ steps = 1 if len(bez) == 2 else samples
127
+ for k in range(steps):
128
+ t = F(k, steps)
129
+ ring.append(_bezier2(bez, t))
130
+ n = len(ring)
131
+ if n < 3:
132
+ return False
133
+
134
+ def seg_cross(a, b, c, d):
135
+ def orient(p, q, r):
136
+ return ((q[0] - p[0]) * (r[1] - p[1])
137
+ - (q[1] - p[1]) * (r[0] - p[0]))
138
+ o1, o2 = orient(a, b, c), orient(a, b, d)
139
+ o3, o4 = orient(c, d, a), orient(c, d, b)
140
+ return (o1 > 0) != (o2 > 0) and (o3 > 0) != (o4 > 0)
141
+
142
+ for i in range(n):
143
+ a, b = ring[i], ring[(i + 1) % n]
144
+ for j in range(i + 1, n):
145
+ if j == i or (j + 1) % n == i or j == (i + 1) % n:
146
+ continue # skip shared-vertex neighbours
147
+ c, d = ring[j], ring[(j + 1) % n]
148
+ if seg_cross(a, b, c, d):
149
+ return False
150
+ return True
151
+
152
+
153
+ class SplinePrism:
154
+ """Extrusion of a closed line/Bézier profile — exact rational volume
155
+ A·h (Green's-theorem area). Curved boundary; planar top/bottom caps."""
156
+
157
+ provenance = "exact"
158
+
159
+ def __init__(self, start, segments, height, base_z=0) -> None:
160
+ self.start = (F(start[0]), F(start[1]))
161
+ self.segments = segments
162
+ self.h = F(height)
163
+ self.z0 = F(base_z)
164
+ if self.h == 0:
165
+ raise ValueError("spline prism has zero height")
166
+ # a self-intersecting boundary makes the signed Green's area
167
+ # meaningless (opposite lobes cancel) — catch it explicitly rather
168
+ # than mis-diagnosing it as "zero area".
169
+ if not _boundary_is_simple(start, segments):
170
+ raise ValueError("profile boundary self-intersects (not a simple loop)")
171
+ self._area = exact_area(start, segments)
172
+ if self._area == 0:
173
+ raise ValueError("spline prism has zero enclosed area")
174
+
175
+ def volume(self) -> Fraction:
176
+ return self._area * abs(self.h)
177
+
178
+ def area(self) -> Fraction:
179
+ return self._area
180
+
181
+ def bbox_f(self):
182
+ beziers = segments_to_beziers(self.start, self.segments)
183
+ xs = [float(c[0]) for b in beziers for c in b]
184
+ ys = [float(c[1]) for b in beziers for c in b]
185
+ # control-net hull bounds the curve; sample for a tight-ish box
186
+ for b in beziers:
187
+ if len(b) > 2:
188
+ for k in range(9):
189
+ x, y = _bezier2(b, F(k, 8))
190
+ xs.append(float(x)); ys.append(float(y))
191
+ z0, z1 = float(self.z0), float(self.z0 + self.h)
192
+ return ((min(xs), min(ys), min(z0, z1)),
193
+ (max(xs), max(ys), max(z0, z1)))
194
+
195
+ def centroid(self):
196
+ """Exact centroid in ℚ³. The profile's area centroid (Green's-theorem
197
+ first moments ÷ signed area) in x, y; the extrusion mid-height in z.
198
+ Orientation cancels because moments and area share the signed factor."""
199
+ a = exact_signed_area(self.start, self.segments)
200
+ qx, qy = exact_moments(self.start, self.segments)
201
+ return (qx / a, qy / a, self.z0 + self.h / 2)
202
+
203
+ def centroid_f(self):
204
+ """Float centroid derived from the exact :meth:`centroid` — the
205
+ profile's true area centroid, not the bbox centre (which is wrong
206
+ for an asymmetric profile)."""
207
+ return tuple(float(c) for c in self.centroid())
208
+
209
+ def tessellate(self, deflection: float = 0.2) -> dict:
210
+ beziers = segments_to_beziers(self.start, self.segments)
211
+ ring = []
212
+ for b in beziers:
213
+ steps = 1 if len(b) == 2 else 12
214
+ for k in range(steps):
215
+ x, y = _bezier2(b, F(k, steps))
216
+ ring.append((float(x), float(y)))
217
+ z0, z1 = float(self.z0), float(self.z0 + self.h)
218
+ n = len(ring)
219
+ verts = [[x, y, z0] for x, y in ring] + [[x, y, z1] for x, y in ring]
220
+ tris = []
221
+ for i in range(n):
222
+ j = (i + 1) % n
223
+ tris += [[i, j, n + i], [j, n + j, n + i]]
224
+ # fan caps (approximate; render only)
225
+ for i in range(1, n - 1):
226
+ tris.append([0, i, i + 1])
227
+ tris.append([n, n + i + 1, n + i])
228
+ return {"vertices": verts, "triangles": tris}