partforge 0.81.0 → 0.83.0
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.
- package/bin/cli.js +28 -0
- package/docs/AUTHORING-PARTS.md +116 -20
- package/docs/ERROR-PATTERNS.md +16 -1
- package/docs/KERNEL-CONTRACT.md +24 -8
- package/package.json +1 -1
- package/src/app-lofted-bottle.js +22 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/loft-rings.js +377 -0
- package/src/framework/geometry/loft.js +138 -61
- package/src/framework/geometry/manifold-backend.js +48 -6
- package/src/framework/geometry/mesh-build.js +16 -0
- package/src/framework/geometry/occt-backend.js +10 -5
- package/src/framework/geometry/probe.js +21 -8
- package/src/framework/geometry/profile.js +26 -18
- package/src/framework/geometry/shading-policy.js +14 -8
- package/src/framework/geometry/sweep.js +1 -1
- package/src/framework/lint/rules-build.js +12 -2
- package/src/framework/lint/rules-shape.js +19 -0
- package/src/framework/oracle/measure.js +87 -0
- package/src/framework/oracle/verify.js +4 -1
- package/src/lofted-bottle-worker.js +3 -0
- package/src/parts/import-demo.js +22 -1
- package/src/parts/lofted-bottle.js +61 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Shared Shape2D/curve-aware loft ring resolution — the pure-JS leaf both backends
|
|
2
|
+
// call before touching a kernel. Lifts every accepted ring form to the curve-contour
|
|
3
|
+
// IR, bakes each ring's scale-then-rotate(Z) transform ONCE, and classifies the loft
|
|
4
|
+
// into one of three modes (see docs/superpowers/plans/2026-08-23-shape2d-loft-design.md):
|
|
5
|
+
// poly-exact — all-line identical signatures: today's path, bit-for-bit;
|
|
6
|
+
// curve — identical signatures with arcs/cubics: matched per-segment sampling
|
|
7
|
+
// (Manifold) + original curve wires (OCCT, STEP-exact);
|
|
8
|
+
// resample — structurally different rings: shared arc-length resample, both
|
|
9
|
+
// backends loft the IDENTICAL rings (parity by construction).
|
|
10
|
+
import { regularPolygon } from "./polygon.js";
|
|
11
|
+
import { pointsToContour, reverseContour, closeContourGap, arcGeometry, sampleBezier, tessellateContour } from "./profile.js";
|
|
12
|
+
import { rotateProfile, scaleProfile, contourIsCCW, cubicAt, profileCorners } from "./contour-ops.js";
|
|
13
|
+
import { SMOOTH_SIDES_MIN } from "./shading-policy.js";
|
|
14
|
+
|
|
15
|
+
export const LOFT_SEGS = 64; // fixed pure-JS LOD for curve rings (hull.js precedent)
|
|
16
|
+
|
|
17
|
+
const isPointList = (x) => Array.isArray(x) && Array.isArray(x[0]);
|
|
18
|
+
const isContour = (x) => x && !Array.isArray(x) && Array.isArray(x.segments);
|
|
19
|
+
|
|
20
|
+
// Legacy transform bake for point rings — EXACTLY resolveRings' math, kept verbatim so
|
|
21
|
+
// every existing part's loft stays bit-identical (mesh-fillet tools, rim-bevel, roundedBox).
|
|
22
|
+
const bakePts = (pts, r) => {
|
|
23
|
+
const s = r.scale ?? 1;
|
|
24
|
+
const [sx, sy] = Array.isArray(s) ? s : [s, s];
|
|
25
|
+
const rot = ((r.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
|
|
26
|
+
return pts.map(([x, y]) => {
|
|
27
|
+
const X = x * sx, Y = y * sy;
|
|
28
|
+
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Contour bake: scale about origin then rotate about origin — the same composite map,
|
|
33
|
+
// applied through contour-ops so arcs survive similarity maps exactly and become
|
|
34
|
+
// cubics under non-uniform scale (transformContour's rule).
|
|
35
|
+
const bakeContour = (contour, r) => {
|
|
36
|
+
let c = closeContourGap(contour); // Ensure explicit closing so signature is consistent
|
|
37
|
+
const s = r.scale ?? 1;
|
|
38
|
+
if (!(s === 1 || (Array.isArray(s) && s[0] === 1 && s[1] === 1))) c = scaleProfile(c, s, [0, 0]);
|
|
39
|
+
if ((r.rotate ?? 0) !== 0) c = rotateProfile(c, r.rotate, [0, 0]);
|
|
40
|
+
return contourIsCCW(c) ? c : reverseContour(c);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function liftLoftRings(rings) {
|
|
44
|
+
if (!Array.isArray(rings) || rings.length < 2)
|
|
45
|
+
throw new Error("loft: rings must be an array of at least 2 rings");
|
|
46
|
+
return rings.map((r, i) => {
|
|
47
|
+
if (!r || typeof r !== "object") throw new Error(`loft: ring ${i} must be an object { polygon|sides+radius, z }`);
|
|
48
|
+
if (!Number.isFinite(r.z)) throw new Error(`loft: ring ${i} needs a finite z`);
|
|
49
|
+
let poly = r.polygon;
|
|
50
|
+
if (poly && poly._shape2d) {
|
|
51
|
+
const regions = poly._regions;
|
|
52
|
+
if (regions.length === 0) throw new Error(`loft: ring ${i} is an empty Shape2D — nothing to loft`);
|
|
53
|
+
if (regions.length > 1) throw new Error(
|
|
54
|
+
`loft: ring ${i} is a Shape2D with ${regions.length} regions — a loft ring must be a single closed outline (union the regions into one, or loft each separately)`);
|
|
55
|
+
if (regions[0].holes.length > 0) throw new Error(
|
|
56
|
+
`loft: ring ${i} has holes — loft rings must be hole-free outlines (cut the holes from the lofted solid instead)`);
|
|
57
|
+
poly = JSON.parse(JSON.stringify(regions[0].outer));
|
|
58
|
+
}
|
|
59
|
+
if (!poly && Number.isFinite(r.sides) && Number.isFinite(r.radius)) poly = regularPolygon(r.sides, r.radius);
|
|
60
|
+
if (isContour(poly)) {
|
|
61
|
+
const contour = bakeContour(poly, r);
|
|
62
|
+
const allLines = contour.segments.every((s) => !s.via && !s.c1);
|
|
63
|
+
if (allLines && contour.segments.length < 3)
|
|
64
|
+
throw new Error(`loft: ring ${i}'s contour has only ${contour.segments.length} line segment(s) — an all-line contour needs at least 3 to close a polygon (a curved contour, e.g. a circle, may legitimately have fewer)`);
|
|
65
|
+
return { raw: r, contour, pts: null, z: r.z };
|
|
66
|
+
}
|
|
67
|
+
if (!isPointList(poly) || poly.length < 3)
|
|
68
|
+
throw new Error(`loft: ring ${i} needs polygon:[[x,y],…] (≥3 points), a curve contour, a Shape2D, or sides+radius shorthand`);
|
|
69
|
+
const pts = bakePts(poly, r);
|
|
70
|
+
return { raw: r, contour: bakeContour(pointsToContour(poly), r), pts, z: r.z };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const signatureOf = (contour) => contour.segments.map((s) => (s.c1 ? "C" : s.via ? "A" : "L")).join("");
|
|
75
|
+
|
|
76
|
+
export function classifyLoftRings(lifted) {
|
|
77
|
+
const sigs = lifted.map((r) => signatureOf(r.contour));
|
|
78
|
+
const hasCurve = sigs.some((s) => /[AC]/.test(s));
|
|
79
|
+
const identical = sigs.every((s) => s === sigs[0]);
|
|
80
|
+
// Identical all-line signatures imply equal vertex counts (an N-point ring lifts to
|
|
81
|
+
// exactly N line segments), so this IS today's equal-N legacy case, bit-for-bit.
|
|
82
|
+
if (identical && !hasCurve) return { mode: "poly-exact", hasCurve: false };
|
|
83
|
+
if (identical) return { mode: "curve", hasCurve };
|
|
84
|
+
return { mode: "resample", hasCurve };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cache-key form of a ring list: replace live Shape2D values with their content hash
|
|
88
|
+
// so h()'s canonical serializer never walks a shape's methods.
|
|
89
|
+
export function loftRingsKey(rings) {
|
|
90
|
+
if (!Array.isArray(rings)) return rings;
|
|
91
|
+
return rings.map((r) => (r && typeof r === "object"
|
|
92
|
+
? { ...r, polygon: r.polygon && r.polygon._shape2d ? "s2d:" + r.polygon._hash : r.polygon }
|
|
93
|
+
: r));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Natural facet count a segment would get at LOFT_SEGS — the per-segment budget the
|
|
97
|
+
// matched sampler levels up to across rings.
|
|
98
|
+
const segNaturalCount = (prev, seg) => {
|
|
99
|
+
if (seg.c1) return Math.max(1, sampleBezier(prev, seg.c1, seg.c2, seg.to, LOFT_SEGS).length);
|
|
100
|
+
if (seg.via) {
|
|
101
|
+
const g = arcGeometry(prev, seg.via, seg.to);
|
|
102
|
+
return g ? Math.max(2, Math.ceil((LOFT_SEGS * Math.abs(g.dA)) / (2 * Math.PI))) : 1;
|
|
103
|
+
}
|
|
104
|
+
return 1;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Sample one segment with EXACTLY n points (uniform in angle/parameter), last point
|
|
108
|
+
// pinned to seg.to. Fixed counts are what keep corresponding vertices aligned across
|
|
109
|
+
// rings — the adaptive samplers must not be used here.
|
|
110
|
+
const sampleSegN = (prev, seg, n) => {
|
|
111
|
+
const out = [];
|
|
112
|
+
if (seg.c1) {
|
|
113
|
+
for (let s = 1; s <= n; s++) out.push(cubicAt(prev, seg.c1, seg.c2, seg.to, s / n));
|
|
114
|
+
} else if (seg.via) {
|
|
115
|
+
const g = arcGeometry(prev, seg.via, seg.to);
|
|
116
|
+
if (!g) { for (let s = 1; s <= n; s++) out.push([prev[0] + (seg.to[0] - prev[0]) * (s / n), prev[1] + (seg.to[1] - prev[1]) * (s / n)]); }
|
|
117
|
+
else for (let s = 1; s <= n; s++) {
|
|
118
|
+
const ang = g.a0 + g.dA * (s / n);
|
|
119
|
+
out.push([g.cx + g.r * Math.cos(ang), g.cy + g.r * Math.sin(ang)]);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
for (let s = 1; s <= n; s++) out.push([prev[0] + (seg.to[0] - prev[0]) * (s / n), prev[1] + (seg.to[1] - prev[1]) * (s / n)]);
|
|
123
|
+
}
|
|
124
|
+
out[out.length - 1] = [seg.to[0], seg.to[1]];
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Curve mode: identical signatures guaranteed by classifyLoftRings. Per segment index,
|
|
129
|
+
// every ring samples with the same count (the max natural count), so vertex i lies at
|
|
130
|
+
// the same curve parameter on every ring; the seam is each contour's start.
|
|
131
|
+
export function matchedTessellation(lifted) {
|
|
132
|
+
return matchedTessellationDetail(lifted).rings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Detail form: also reports the shared per-segment sample counts, which the shading
|
|
136
|
+
// provenance below needs to map contour joints onto sample indices.
|
|
137
|
+
function matchedTessellationDetail(lifted) {
|
|
138
|
+
const segCount = lifted[0].contour.segments.length;
|
|
139
|
+
const counts = [];
|
|
140
|
+
for (let j = 0; j < segCount; j++) {
|
|
141
|
+
let n = 1, prevs = lifted.map((r) => (j === 0 ? r.contour.start : r.contour.segments[j - 1].to));
|
|
142
|
+
lifted.forEach((r, k) => { n = Math.max(n, segNaturalCount(prevs[k], r.contour.segments[j])); });
|
|
143
|
+
counts.push(n);
|
|
144
|
+
}
|
|
145
|
+
const rings = lifted.map((r) => {
|
|
146
|
+
const ring = [[r.contour.start[0], r.contour.start[1]]];
|
|
147
|
+
let prev = r.contour.start;
|
|
148
|
+
r.contour.segments.forEach((seg, j) => { for (const p of sampleSegN(prev, seg, counts[j])) ring.push(p); prev = seg.to; });
|
|
149
|
+
// stored contours close explicitly (last segment lands on start) — drop the closure
|
|
150
|
+
ring.pop();
|
|
151
|
+
return ring;
|
|
152
|
+
});
|
|
153
|
+
return { rings, counts };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shoelace = (ring) => ring.reduce((a, [x, y], i) => {
|
|
157
|
+
const [nx, ny] = ring[(i + 1) % ring.length];
|
|
158
|
+
return a + x * ny - nx * y;
|
|
159
|
+
}, 0) / 2;
|
|
160
|
+
|
|
161
|
+
// Deterministic seam: the outermost crossing of the +X ray from the ring's centroid.
|
|
162
|
+
// Returns { edge, t } — a parametric position on the ring's edge list. Falls back to
|
|
163
|
+
// all crossings of the full horizontal line, then to vertex 0, so it is total.
|
|
164
|
+
const seamOf = (ring) => {
|
|
165
|
+
let cx = 0, cy = 0;
|
|
166
|
+
for (const [x, y] of ring) { cx += x; cy += y; }
|
|
167
|
+
cx /= ring.length; cy /= ring.length;
|
|
168
|
+
let best = null;
|
|
169
|
+
for (let pass = 0; pass < 2 && !best; pass++) {
|
|
170
|
+
for (let i = 0; i < ring.length; i++) {
|
|
171
|
+
const [px, py] = ring[i], [qx, qy] = ring[(i + 1) % ring.length];
|
|
172
|
+
if ((py <= cy) === (qy <= cy)) continue; // half-open: each crossing once
|
|
173
|
+
const t = (cy - py) / (qy - py);
|
|
174
|
+
const x = px + t * (qx - px);
|
|
175
|
+
if (pass === 0 && x <= cx) continue; // pass 0: +X ray only
|
|
176
|
+
if (!best || x > best.x) best = { edge: i, t, x };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return best ?? { edge: 0, t: 0, x: ring[0][0] };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Arc-length resample one CCW ring to N points starting at its seam, then snap each
|
|
183
|
+
// sharp corner onto its nearest sample (closest corner wins a contested sample).
|
|
184
|
+
const resampleRing = (ring, N, corners) => {
|
|
185
|
+
const seam = seamOf(ring);
|
|
186
|
+
const pts = [];
|
|
187
|
+
// unroll the ring into an open polyline starting exactly at the seam point
|
|
188
|
+
const start = [ring[seam.edge][0] + seam.t * (ring[(seam.edge + 1) % ring.length][0] - ring[seam.edge][0]),
|
|
189
|
+
ring[seam.edge][1] + seam.t * (ring[(seam.edge + 1) % ring.length][1] - ring[seam.edge][1])];
|
|
190
|
+
pts.push(start);
|
|
191
|
+
for (let k = 1; k <= ring.length; k++) {
|
|
192
|
+
const i = (seam.edge + k) % ring.length;
|
|
193
|
+
pts.push([ring[i][0], ring[i][1]]);
|
|
194
|
+
}
|
|
195
|
+
// pts is now start → all vertices → start's edge-begin; close it back to start
|
|
196
|
+
pts.push([start[0], start[1]]);
|
|
197
|
+
const cum = [0];
|
|
198
|
+
for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
|
|
199
|
+
const L = cum[cum.length - 1];
|
|
200
|
+
const out = [];
|
|
201
|
+
let seg = 0;
|
|
202
|
+
for (let k = 0; k < N; k++) {
|
|
203
|
+
const target = (k * L) / N;
|
|
204
|
+
while (seg < cum.length - 2 && cum[seg + 1] < target) seg++;
|
|
205
|
+
const span = cum[seg + 1] - cum[seg] || 1;
|
|
206
|
+
const t = (target - cum[seg]) / span;
|
|
207
|
+
out.push([pts[seg][0] + t * (pts[seg + 1][0] - pts[seg][0]), pts[seg][1] + t * (pts[seg + 1][1] - pts[seg][1])]);
|
|
208
|
+
}
|
|
209
|
+
// corner snapping: a sharp corner within one sample-spacing of a sample replaces it
|
|
210
|
+
// Snapshot the original sample positions before snapping, so distance calculations
|
|
211
|
+
// measure against original positions, not mutated ones (contest rule: closer corner wins)
|
|
212
|
+
const spacing = L / N;
|
|
213
|
+
const orig = out.map((p) => [p[0], p[1]]);
|
|
214
|
+
const owner = new Map(); // sample index -> snap distance
|
|
215
|
+
const ownerOf = new Map(); // corner list index -> sample index it won
|
|
216
|
+
corners.forEach((c, ci) => {
|
|
217
|
+
let bi = -1, bd = Infinity;
|
|
218
|
+
for (let i = 0; i < orig.length; i++) {
|
|
219
|
+
const d = Math.hypot(orig[i][0] - c[0], orig[i][1] - c[1]);
|
|
220
|
+
if (d < bd) { bd = d; bi = i; }
|
|
221
|
+
}
|
|
222
|
+
if (bd < spacing && (!owner.has(bi) || bd < owner.get(bi))) {
|
|
223
|
+
out[bi] = [c[0], c[1]];
|
|
224
|
+
owner.set(bi, bd);
|
|
225
|
+
for (const [k, v] of ownerOf) if (v === bi) ownerOf.delete(k); // evicted corner loses the sample
|
|
226
|
+
ownerOf.set(ci, bi);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return { out, ownerOf };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Resample mode: every ring tessellated at the fixed LOD, resampled to a common N.
|
|
233
|
+
export function resampleTessellation(lifted) {
|
|
234
|
+
return resampleTessellationDetail(lifted).rings;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Detail form: also reports each ring's snapped-corner sample indices, which the
|
|
238
|
+
// shading provenance below uses as sector boundaries.
|
|
239
|
+
function resampleTessellationDetail(lifted) {
|
|
240
|
+
const source = lifted.map((r) => {
|
|
241
|
+
let ring = r.pts ?? tessellateContour(r.contour, LOFT_SEGS);
|
|
242
|
+
// tessellateContour of a contour returns an explicitly closed ring — drop the closure
|
|
243
|
+
if (ring.length > 1 && ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1])
|
|
244
|
+
ring = ring.slice(0, -1);
|
|
245
|
+
if (shoelace(ring) < 0) ring = [...ring].reverse();
|
|
246
|
+
return ring;
|
|
247
|
+
});
|
|
248
|
+
const N = Math.max(...source.map((r) => r.length));
|
|
249
|
+
const rings = [], snapped = [];
|
|
250
|
+
lifted.forEach((r, i) => {
|
|
251
|
+
const corners = profileCorners(r.contour);
|
|
252
|
+
const res = resampleRing(source[i], N, corners.map((c) => c.point));
|
|
253
|
+
rings.push(res.out);
|
|
254
|
+
// Only SHARP corners become sector boundaries (see sharpTurn below): a
|
|
255
|
+
// polygonized circle's 7.5°-per-vertex "corners" all snap, but must not
|
|
256
|
+
// shatter the ring into per-facet sectors.
|
|
257
|
+
const sharpSet = new Set();
|
|
258
|
+
corners.forEach((c, ci) => { if (sharpTurn(c) && res.ownerOf.has(ci)) sharpSet.add(res.ownerOf.get(ci)); });
|
|
259
|
+
snapped.push(sharpSet);
|
|
260
|
+
});
|
|
261
|
+
return { rings, snapped };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Shading provenance ──────────────────────────────────────────────────────
|
|
265
|
+
// Column j is the wall strip between samples j and j+1 (wrapping). A sector is a
|
|
266
|
+
// maximal run of columns between sharp contour features — sharp joints in curve
|
|
267
|
+
// mode, snapped corners in resample mode — so the mesh builder (loft.js) can give
|
|
268
|
+
// each sector its own shading surface. sectorSmooth[k] says whether sector k's
|
|
269
|
+
// facets approximate a smooth curve (its shading policy creases gently and draws
|
|
270
|
+
// no facet wireframe) or are flat/faceted geometry.
|
|
271
|
+
|
|
272
|
+
// A joint is SHARP (a sector boundary) only when it turns more than a smooth
|
|
273
|
+
// tessellation ever produces per facet — the same 360/SMOOTH_SIDES_MIN bar as the
|
|
274
|
+
// legacy "≥32 sides reads smooth" rule. profileCorners' own 1° bar is author-intent
|
|
275
|
+
// (corner ops); reusing it here would shatter a polygonized circle into sectors.
|
|
276
|
+
const SHARP_TURN_DEG = 360 / SMOOTH_SIDES_MIN;
|
|
277
|
+
const sharpTurn = (corner) => Math.abs(180 - corner.interiorAngleDeg) > SHARP_TURN_DEG;
|
|
278
|
+
|
|
279
|
+
const sectorsFromBoundaries = (N, boundarySet) => {
|
|
280
|
+
const B = [...boundarySet].sort((a, b) => a - b);
|
|
281
|
+
const sectorOf = new Array(N).fill(0);
|
|
282
|
+
// Known limitation: a SINGLE sharp joint on an otherwise smooth ring (a teardrop
|
|
283
|
+
// cusp) cannot be expressed — one boundary yields one sector, so both sides of
|
|
284
|
+
// the cusp share a run and it shades per the sector policy (smooth if any curve).
|
|
285
|
+
// Splitting the ring artificially would draw a fake dividing line at the split
|
|
286
|
+
// (smooth facets bend > the 5° cross-run bar at LOFT_SEGS). Add a second corner,
|
|
287
|
+
// or force `shading: "faceted"`, to make a lone cusp read.
|
|
288
|
+
if (B.length > 1) {
|
|
289
|
+
for (let k = 0; k < B.length; k++) {
|
|
290
|
+
const from = B[k], to = B[(k + 1) % B.length];
|
|
291
|
+
for (let j = from; j !== to; j = (j + 1) % N) sectorOf[j] = k;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return sectorOf;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Curve mode: sector boundaries at sharp joints (profileCorners, unioned across all
|
|
298
|
+
// rings — structurally identical contours can still disagree on which joints bend).
|
|
299
|
+
// A sector is smooth when any column in it samples a curved (arc/cubic) segment.
|
|
300
|
+
const curveShading = (lifted, counts) => {
|
|
301
|
+
const N = counts.reduce((a, b) => a + b, 0);
|
|
302
|
+
const jointSample = [0]; // joint j (start of segment j) lands at this sample index
|
|
303
|
+
for (let j = 1; j < counts.length; j++) jointSample.push(jointSample[j - 1] + counts[j - 1]);
|
|
304
|
+
const sharp = new Set();
|
|
305
|
+
for (const r of lifted) for (const c of profileCorners(r.contour)) if (sharpTurn(c)) sharp.add(jointSample[c.index]);
|
|
306
|
+
const sectorOf = sectorsFromBoundaries(N, sharp);
|
|
307
|
+
const segSmooth = lifted[0].contour.segments.map((s) => !!(s.via || s.c1));
|
|
308
|
+
const K = Math.max(...sectorOf) + 1;
|
|
309
|
+
const sectorSmooth = new Array(K).fill(false);
|
|
310
|
+
let seg = 0;
|
|
311
|
+
for (let j = 0; j < N; j++) {
|
|
312
|
+
while (seg < counts.length - 1 && j >= jointSample[seg + 1]) seg++;
|
|
313
|
+
if (segSmooth[seg]) sectorSmooth[sectorOf[j]] = true;
|
|
314
|
+
}
|
|
315
|
+
return { sectorOf, sectorSmooth };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Resample mode: sector boundaries at the union of every ring's snapped corners. A
|
|
319
|
+
// sector is smooth when every interior sample on every ring turns gently — the same
|
|
320
|
+
// bar as the legacy "≥ SMOOTH_SIDES_MIN sides reads smooth" rule (360/32 per facet).
|
|
321
|
+
const resampleShading = (rings, snappedSets) => {
|
|
322
|
+
const N = rings[0].length;
|
|
323
|
+
const boundaries = new Set();
|
|
324
|
+
for (const s of snappedSets) for (const i of s) boundaries.add(i);
|
|
325
|
+
const sectorOf = sectorsFromBoundaries(N, boundaries);
|
|
326
|
+
const K = Math.max(...sectorOf) + 1;
|
|
327
|
+
const limit = (2 * Math.PI) / SMOOTH_SIDES_MIN;
|
|
328
|
+
const sectorSmooth = new Array(K).fill(true);
|
|
329
|
+
for (const ring of rings)
|
|
330
|
+
for (let i = 0; i < N; i++) {
|
|
331
|
+
if (boundaries.has(i)) continue; // corners split sectors; they are not interior turns
|
|
332
|
+
const p = ring[(i - 1 + N) % N], q = ring[i], r2 = ring[(i + 1) % N];
|
|
333
|
+
const ux = q[0] - p[0], uy = q[1] - p[1], vx = r2[0] - q[0], vy = r2[1] - q[1];
|
|
334
|
+
const turn = Math.abs(Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy));
|
|
335
|
+
if (turn > limit) { // the kink sits AT sample i — both adjacent columns' sectors go faceted
|
|
336
|
+
sectorSmooth[sectorOf[i]] = false;
|
|
337
|
+
sectorSmooth[sectorOf[(i - 1 + N) % N]] = false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { sectorOf, sectorSmooth };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Orchestrates lift -> classify -> tessellate into the single shape both backends consume:
|
|
344
|
+
// resolved[i] = { pts2d, z, contour } — pts2d for Manifold's hand-mesh, contour (curve mode
|
|
345
|
+
// only) for OCCT's native wire loft. See docs/superpowers/plans/2026-08-23-shape2d-loft-design.md.
|
|
346
|
+
export function resolveLoftRings(rings) {
|
|
347
|
+
const lifted = liftLoftRings(rings);
|
|
348
|
+
const { mode, hasCurve } = classifyLoftRings(lifted);
|
|
349
|
+
let ptRings, shading = null;
|
|
350
|
+
if (mode === "poly-exact") {
|
|
351
|
+
// Point-list rings keep their legacy un-normalized winding for bit-exactness — UNLESS
|
|
352
|
+
// the ring set also mixes in a contour/Shape2D-sourced ring (r.pts === null), whose
|
|
353
|
+
// winding bakeContour already forced CCW. Left alone, a CW point ring paired with a
|
|
354
|
+
// CCW contour ring cancels the side walls (mixed winding) into an empty solid instead
|
|
355
|
+
// of erroring or self-correcting (loftMesh's volume<0 latch only catches a FULLY
|
|
356
|
+
// inverted mesh, not this partial cancellation) — so only in the mixed case do we
|
|
357
|
+
// also normalize each point ring to CCW here. All-point-list ring sets are untouched.
|
|
358
|
+
const mixed = lifted.some((r) => r.pts) && lifted.some((r) => !r.pts);
|
|
359
|
+
ptRings = lifted.map((r) => {
|
|
360
|
+
if (!r.pts) return matchedTessellation([r, r])[0];
|
|
361
|
+
return mixed && shoelace(r.pts) < 0 ? [...r.pts].reverse() : r.pts;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
else if (mode === "curve") {
|
|
365
|
+
const d = matchedTessellationDetail(lifted);
|
|
366
|
+
ptRings = d.rings;
|
|
367
|
+
shading = curveShading(lifted, d.counts);
|
|
368
|
+
} else {
|
|
369
|
+
const d = resampleTessellationDetail(lifted);
|
|
370
|
+
ptRings = d.rings;
|
|
371
|
+
shading = resampleShading(d.rings, d.snapped);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
mode, hasCurve, shading,
|
|
375
|
+
resolved: lifted.map((r, i) => ({ pts2d: ptRings[i], z: r.z, contour: mode === "curve" ? r.contour : null })),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
@@ -1,76 +1,153 @@
|
|
|
1
|
-
// Backend-shared loft support
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Backend-shared loft support: resolveLoftRings (loft-rings.js) validates and
|
|
2
|
+
// tessellates the declarative ring specs; loftMesh() is the Manifold path — stacked
|
|
3
|
+
// rings stitched with side quads and closed with TRIANGULATED caps (wasm.triangulate),
|
|
4
|
+
// so non-convex Shape2D rings cap correctly (the old centroid fan was star-convex-only).
|
|
5
|
+
//
|
|
6
|
+
// Curve/resample lofts additionally partition their triangles into RUNS carrying
|
|
7
|
+
// reserved original IDs (mesh-fillet's blend-band mechanism): one run per contour
|
|
8
|
+
// sector per band group, plus each cap. Sector boundaries (sharp joints, snapped
|
|
9
|
+
// corners) and band-group boundaries (silhouette kinks bending more than
|
|
10
|
+
// TANGENT_ANGLE — the same bar the B-rep backend draws real edges at) then shade
|
|
11
|
+
// hard and draw dividing lines through creased-normals' existing cross-surface
|
|
12
|
+
// rules, while each smooth sector keeps gentle crease behavior inside. Provenance
|
|
13
|
+
// decides the shading; angle inference alone could not (a 10° belly kink sits far
|
|
14
|
+
// below SMOOTH's 35° crease). Point-ring (poly-exact) lofts and hinted lofts keep
|
|
15
|
+
// the legacy single-surface path bit-for-bit.
|
|
16
|
+
import { resolveLoftRings } from "./loft-rings.js";
|
|
17
|
+
import { sideQuads, manifoldFromMesh, manifoldFromMeshRuns, reverseWinding } from "./mesh-build.js";
|
|
18
|
+
import { TANGENT_ANGLE, LOFT_SECTOR_SMOOTH, LOFT_SECTOR_FACETED, cosDeg } from "./shading-policy.js";
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const pts2d = pts.map(([x, y]) => {
|
|
30
|
-
const X = x * sx, Y = y * sy; // scale in-plane, then rotate about Z
|
|
31
|
-
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
32
|
-
});
|
|
33
|
-
return { pts2d, z: r.z };
|
|
34
|
-
});
|
|
35
|
-
const N = out[0].pts2d.length;
|
|
36
|
-
for (const r of out) if (r.pts2d.length !== N)
|
|
37
|
-
throw new Error("loft: every ring must have the same number of points (straight quad stitching, no re-sampling)");
|
|
38
|
-
return out;
|
|
20
|
+
const shoelace = (ring) => ring.reduce((a, [x, y], i) => {
|
|
21
|
+
const [nx, ny] = ring[(i + 1) % ring.length];
|
|
22
|
+
return a + x * ny - nx * y;
|
|
23
|
+
}, 0) / 2;
|
|
24
|
+
|
|
25
|
+
// Triangulated end cap. Winding must stay CONSISTENT with the side walls: a CCW ring's
|
|
26
|
+
// top cap faces +Z and its bottom cap −Z; a CW ring (legacy point lists — walls come
|
|
27
|
+
// out inverted and the whole-mesh volume check below flips everything at once) gets
|
|
28
|
+
// both caps inverted too, so the mesh is orientable either way.
|
|
29
|
+
function triCap(wasm, Tr, ringStart, pts2d, bottom) {
|
|
30
|
+
const ccw = shoelace(pts2d) >= 0;
|
|
31
|
+
const ring = ccw ? pts2d : [...pts2d].reverse();
|
|
32
|
+
const tris = wasm.triangulate([ring], 1e-9);
|
|
33
|
+
const remap = (i) => ringStart + (ccw ? i : pts2d.length - 1 - i);
|
|
34
|
+
const flip = bottom !== !ccw; // XOR: see winding table in the test file
|
|
35
|
+
for (const t of tris) {
|
|
36
|
+
const a = remap(t[0]), b = remap(t[1]), c = remap(t[2]);
|
|
37
|
+
if (flip) Tr.push(a, c, b); else Tr.push(a, b, c);
|
|
38
|
+
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
// Interior rings where the wall direction bends more than TANGENT_ANGLE at any
|
|
42
|
+
// vertex column — author-placed silhouette features, not tessellation of a smooth
|
|
43
|
+
// sweep. Matches the bar at which the B-rep backend's real loft edges draw lines,
|
|
44
|
+
// so the Manifold preview and an OCCT export agree on which rings read as features.
|
|
45
|
+
function kinkRingsOf(resolved, closed = false) {
|
|
46
|
+
const R = resolved.length, N = resolved[0].pts2d.length;
|
|
47
|
+
const cosKink = cosDeg(TANGENT_ANGLE);
|
|
48
|
+
const kinks = new Set();
|
|
49
|
+
// open lofts bend only at interior rings; a closed loop also bends across the
|
|
50
|
+
// wrap, so rings 0 and R-1 are checked there too (their neighbors wrap modulo R)
|
|
51
|
+
for (let k = closed ? 0 : 1; k < (closed ? R : R - 1); k++) {
|
|
52
|
+
const A = resolved[(k - 1 + R) % R], B = resolved[k], C = resolved[(k + 1) % R];
|
|
53
|
+
for (let i = 0; i < N; i++) {
|
|
54
|
+
const ux = B.pts2d[i][0] - A.pts2d[i][0], uy = B.pts2d[i][1] - A.pts2d[i][1], uz = B.z - A.z;
|
|
55
|
+
const vx = C.pts2d[i][0] - B.pts2d[i][0], vy = C.pts2d[i][1] - B.pts2d[i][1], vz = C.z - B.z;
|
|
56
|
+
const lu = Math.hypot(ux, uy, uz), lv = Math.hypot(vx, vy, vz);
|
|
57
|
+
// a zero-length band (duplicated ring) has no direction — it cannot bend;
|
|
58
|
+
// without this skip the 0-dot always reads as a >5° kink and splits runs
|
|
59
|
+
if (lu < 1e-9 || lv < 1e-9) continue;
|
|
60
|
+
if ((ux * vx + uy * vy + uz * vz) / (lu * lv) < cosKink) { kinks.add(k); break; }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return kinks;
|
|
64
|
+
}
|
|
46
65
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
66
|
+
// Run-partitioned mesh: wall triangles grouped by (band group × sector), caps as
|
|
67
|
+
// their own runs, every run stamped with a freshly reserved original ID and its
|
|
68
|
+
// policy recorded in `runPolicies` for the backend to register.
|
|
69
|
+
function sectoredMesh(wasm, resolvedLoft, kinks, { closed = false } = {}, runPolicies) {
|
|
70
|
+
const { resolved, shading } = resolvedLoft;
|
|
71
|
+
const N = resolved[0].pts2d.length, R = resolved.length;
|
|
72
|
+
const V = [];
|
|
73
|
+
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
74
|
+
|
|
75
|
+
const bands = closed ? R : R - 1;
|
|
76
|
+
const groupOf = new Array(bands);
|
|
77
|
+
let g = 0;
|
|
78
|
+
for (let b = 0; b < bands; b++) { if (b > 0 && kinks.has(b)) g++; groupOf[b] = g; }
|
|
79
|
+
// closed loop: unless ring 0 itself kinks, the last group continues into the first
|
|
80
|
+
if (closed && g > 0 && !kinks.has(0)) for (let b = bands - 1; b >= 0 && groupOf[b] === g; b--) groupOf[b] = 0;
|
|
81
|
+
|
|
82
|
+
const K = Math.max(...shading.sectorOf) + 1;
|
|
83
|
+
const runTris = new Map(); // (group * K + sector) -> flat tri indices
|
|
84
|
+
for (let b = 0; b < bands; b++) {
|
|
85
|
+
const i0 = b * N, i1 = ((b + 1) % R) * N;
|
|
86
|
+
for (let j = 0; j < N; j++) {
|
|
87
|
+
const key = groupOf[b] * K + shading.sectorOf[j];
|
|
88
|
+
let arr = runTris.get(key);
|
|
89
|
+
if (!arr) runTris.set(key, (arr = []));
|
|
90
|
+
const a = i0 + j, b2 = i0 + (j + 1) % N, cc = i1 + j, dd = i1 + (j + 1) % N;
|
|
91
|
+
arr.push(a, dd, cc, a, b2, dd); // same winding as sideQuads
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const capBottom = [], capTop = [];
|
|
95
|
+
if (!closed) {
|
|
96
|
+
triCap(wasm, capBottom, 0, resolved[0].pts2d, true);
|
|
97
|
+
triCap(wasm, capTop, (R - 1) * N, resolved[R - 1].pts2d, false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const wallKeys = [...runTris.keys()].sort((a, b) => a - b);
|
|
101
|
+
const totalRuns = wallKeys.length + (closed ? 0 : 2);
|
|
102
|
+
const base = wasm.Manifold.reserveIDs(totalRuns);
|
|
103
|
+
const Tr = [], runIndex = [0], runOriginalID = [];
|
|
104
|
+
let next = base;
|
|
105
|
+
for (const key of wallKeys) {
|
|
106
|
+
for (const t of runTris.get(key)) Tr.push(t);
|
|
107
|
+
runIndex.push(Tr.length);
|
|
108
|
+
runOriginalID.push(next);
|
|
109
|
+
runPolicies?.set(next, shading.sectorSmooth[key % K] ? LOFT_SECTOR_SMOOTH : LOFT_SECTOR_FACETED);
|
|
110
|
+
next++;
|
|
111
|
+
}
|
|
112
|
+
for (const cap of closed ? [] : [capBottom, capTop]) {
|
|
113
|
+
for (const t of cap) Tr.push(t);
|
|
114
|
+
runIndex.push(Tr.length);
|
|
115
|
+
runOriginalID.push(next);
|
|
116
|
+
runPolicies?.set(next, LOFT_SECTOR_FACETED); // caps are planar
|
|
117
|
+
next++;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let out = manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID);
|
|
121
|
+
if (out.volume() < 0) { // descending-z stacks: rebuild outward, runs unchanged
|
|
122
|
+
out.delete?.();
|
|
123
|
+
reverseWinding(Tr);
|
|
124
|
+
out = manifoldFromMeshRuns(wasm, V, Tr, runIndex, runOriginalID);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function loftMesh(wasm, rings, opts = {}, runPolicies = null) {
|
|
130
|
+
const rl = Array.isArray(rings) ? resolveLoftRings(rings) : rings;
|
|
131
|
+
const { resolved, shading } = rl;
|
|
132
|
+
const { closed = false, shading: hint } = opts;
|
|
133
|
+
if (shading && hint == null) {
|
|
134
|
+
const kinks = kinkRingsOf(resolved, closed);
|
|
135
|
+
const K = Math.max(...shading.sectorOf) + 1;
|
|
136
|
+
// Partition only when there is a boundary to express; a single smooth sector
|
|
137
|
+
// with no kinks keeps the legacy single-surface path (and its policy inference).
|
|
138
|
+
if (K > 1 || kinks.size > 0) return sectoredMesh(wasm, rl, kinks, opts, runPolicies);
|
|
139
|
+
}
|
|
57
140
|
const N = resolved[0].pts2d.length;
|
|
58
141
|
const V = [];
|
|
59
142
|
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
60
143
|
const Tr = [];
|
|
61
144
|
sideQuads(Tr, resolved.length, N, closed);
|
|
62
145
|
if (!closed) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
fanCap(V, Tr, (resolved.length - 1) * N, N, centroid(lastR.pts2d, lastR.z), false); // top faces +Z
|
|
146
|
+
triCap(wasm, Tr, 0, resolved[0].pts2d, true);
|
|
147
|
+
triCap(wasm, Tr, (resolved.length - 1) * N, resolved[resolved.length - 1].pts2d, false);
|
|
66
148
|
}
|
|
67
149
|
let out = manifoldFromMesh(wasm, V, Tr);
|
|
68
|
-
|
|
69
|
-
// rings invert every face, yielding a negative-volume solid that ofMesh imports without
|
|
70
|
-
// complaint but that behaves BACKWARDS under booleans (cut adds material). Detect the
|
|
71
|
-
// inversion and rebuild with reversed winding so loft is winding/z-order agnostic — this
|
|
72
|
-
// matches OCCT, whose native loft always returns a positively-oriented solid.
|
|
73
|
-
if (out.volume() < 0) {
|
|
150
|
+
if (out.volume() < 0) { // CW rings / descending z: rebuild outward (unchanged)
|
|
74
151
|
out.delete?.();
|
|
75
152
|
reverseWinding(Tr);
|
|
76
153
|
out = manifoldFromMesh(wasm, V, Tr);
|