partforge 0.7.0 → 0.9.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 +47 -14
- package/docs/AUTHORING-PARTS.md +118 -19
- package/docs/ERROR-PATTERNS.md +150 -0
- package/package.json +2 -1
- package/skills/partforge/SKILL.md +5 -0
- package/src/app-faceted-vase.js +10 -0
- package/src/faceted-vase-worker.js +3 -0
- package/src/framework/assembly.js +9 -3
- package/src/framework/geometry/helix-tube.js +10 -20
- package/src/framework/geometry/kernel-front.js +6 -0
- package/src/framework/geometry/kernel.js +5 -2
- package/src/framework/geometry/loft.js +79 -0
- package/src/framework/geometry/manifold-backend.js +22 -1
- package/src/framework/geometry/mesh-build.js +53 -0
- package/src/framework/geometry/occt-backend.js +70 -10
- package/src/framework/geometry/polygon.js +89 -0
- package/src/framework/geometry/profile.js +96 -0
- package/src/framework/geometry/sweep.js +151 -0
- package/src/parts/faceted-vase.js +75 -0
- package/src/testing/error-patterns.js +78 -0
- package/src/testing/measure.js +3 -1
- package/src/testing/verify.js +51 -17
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Backend-shared loft support. resolveRings() validates the declarative ring specs and
|
|
2
|
+
// applies each ring's in-plane transform ONCE, so both backends build the identical set
|
|
3
|
+
// of placed cross-sections (Manifold hand-meshes them; OCCT turns each into a wire and
|
|
4
|
+
// calls native loft). loftMesh() is the Manifold path — the helix-tube ring recipe
|
|
5
|
+
// generalized to arbitrary polygon rings via the mesh-build.js helpers.
|
|
6
|
+
import { regularPolygon } from "./polygon.js";
|
|
7
|
+
import { isArcContour } from "./profile.js";
|
|
8
|
+
import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
|
|
9
|
+
|
|
10
|
+
// A ring: { polygon:[[x,y],…] | (sides,radius), z, rotate?:deg, scale?:number|[sx,sy] }.
|
|
11
|
+
// Returns [{ pts2d:[[x,y],…], z }] with scale-then-rotate(Z) baked into pts2d. Throws
|
|
12
|
+
// on malformed input and on rings whose vertex counts differ (straight quad stitching
|
|
13
|
+
// needs a shared N — no re-sampling), so an LLM gets a loud, specific error.
|
|
14
|
+
export function resolveRings(rings) {
|
|
15
|
+
if (!Array.isArray(rings) || rings.length < 2)
|
|
16
|
+
throw new Error("loft: rings must be an array of at least 2 rings");
|
|
17
|
+
const out = rings.map((r, i) => {
|
|
18
|
+
if (!r || typeof r !== "object") throw new Error(`loft: ring ${i} must be an object { polygon|sides+radius, z }`);
|
|
19
|
+
if (!Number.isFinite(r.z)) throw new Error(`loft: ring ${i} needs a finite z`);
|
|
20
|
+
let pts = r.polygon;
|
|
21
|
+
if (isArcContour(pts)) // arc profiles (roundedProfile) are extrude/prism-only in v1
|
|
22
|
+
throw new Error(`loft: ring ${i} is an arc profile — loft rings must be a point array (arc rings are not supported yet; use prism/extrude for true STEP arcs)`);
|
|
23
|
+
if (!pts && Number.isFinite(r.sides) && Number.isFinite(r.radius)) pts = regularPolygon(r.sides, r.radius);
|
|
24
|
+
if (!Array.isArray(pts) || pts.length < 3)
|
|
25
|
+
throw new Error(`loft: ring ${i} needs polygon:[[x,y],…] (≥3 points) or sides+radius shorthand`);
|
|
26
|
+
const s = r.scale ?? 1;
|
|
27
|
+
const [sx, sy] = Array.isArray(s) ? s : [s, s];
|
|
28
|
+
const rot = ((r.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const centroid = (pts2d, z) => {
|
|
42
|
+
let cx = 0, cy = 0;
|
|
43
|
+
for (const [x, y] of pts2d) { cx += x; cy += y; }
|
|
44
|
+
return [cx / pts2d.length, cy / pts2d.length, z];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Manifold path: stack the resolved rings, stitch side quads, and (unless closed) fan a
|
|
48
|
+
// cap over each end from its centroid. Caps assume star-convex-from-centroid rings, which
|
|
49
|
+
// covers regular n-gons and every polygon.js helper. Returns a raw Manifold (caller T()s).
|
|
50
|
+
//
|
|
51
|
+
// NOTE on `ruled`: the OCCT backend's native loft honours `ruled:false` (a smooth C2 blend
|
|
52
|
+
// between rings); this hand-mesh always emits faceted straight walls between consecutive
|
|
53
|
+
// rings and ignores `ruled`. So `ruled:false` previews faceted here and only exports the
|
|
54
|
+
// true smooth surface via the OCCT (STEP) path — documented on the loft doc row.
|
|
55
|
+
export function loftMesh(wasm, rings, { closed = false } = {}) {
|
|
56
|
+
const resolved = resolveRings(rings);
|
|
57
|
+
const N = resolved[0].pts2d.length;
|
|
58
|
+
const V = [];
|
|
59
|
+
for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
|
|
60
|
+
const Tr = [];
|
|
61
|
+
sideQuads(Tr, resolved.length, N, closed);
|
|
62
|
+
if (!closed) {
|
|
63
|
+
const first = resolved[0], lastR = resolved[resolved.length - 1];
|
|
64
|
+
fanCap(V, Tr, 0, N, centroid(first.pts2d, first.z), true); // bottom faces −Z
|
|
65
|
+
fanCap(V, Tr, (resolved.length - 1) * N, N, centroid(lastR.pts2d, lastR.z), false); // top faces +Z
|
|
66
|
+
}
|
|
67
|
+
let out = manifoldFromMesh(wasm, V, Tr);
|
|
68
|
+
// The mesh helpers wind for CCW rings ordered along +Z. CW-wound rings or descending-z
|
|
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) {
|
|
74
|
+
out.delete?.();
|
|
75
|
+
reverseWinding(Tr);
|
|
76
|
+
out = manifoldFromMesh(wasm, V, Tr);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
|
+
import { loftMesh } from "./loft.js";
|
|
3
|
+
import { sweepMesh } from "./sweep.js";
|
|
4
|
+
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
2
5
|
import { h } from "./solid-hash.js";
|
|
3
6
|
import { createSolidCache } from "./solid-cache.js";
|
|
4
7
|
import { addSugar } from "./solid-sugar.js";
|
|
@@ -140,13 +143,31 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
140
143
|
},
|
|
141
144
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
142
145
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
143
|
-
const cs = T(CrossSection.ofPolygons([pts]));
|
|
146
|
+
const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segs)]));
|
|
144
147
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
145
148
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
146
149
|
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
147
150
|
// X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
|
|
148
151
|
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
149
152
|
}),
|
|
153
|
+
// Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
|
|
154
|
+
// holes regardless of their winding (outer + holes, no per-hole boolean cut).
|
|
155
|
+
extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
156
|
+
cached(h("extrude", profile, height, twist, scaleTop, segs), () => {
|
|
157
|
+
const { outer, holes } = tessellateProfile(profile, segs);
|
|
158
|
+
const cs = T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
159
|
+
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
160
|
+
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
161
|
+
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
162
|
+
}),
|
|
163
|
+
// Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
|
|
164
|
+
// Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
|
|
165
|
+
loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
|
|
166
|
+
// Sweep a fixed 2-D profile along a 3-D polyline: hand-meshed from the shared station
|
|
167
|
+
// list (sweep.js), so it agrees with OCCT's ruled loft of the same stations by
|
|
168
|
+
// construction. Cached atomically; the hash folds profile pts, path pts, and opts
|
|
169
|
+
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
170
|
+
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
150
171
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
151
172
|
revolve: (pts, { degrees = 360 } = {}) =>
|
|
152
173
|
cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared triangle-mesh assembly for ring-based Manifold primitives. Both helix-tube
|
|
2
|
+
// (a circular profile swept up a helix) and loft (stacked polygon cross-sections)
|
|
3
|
+
// build the same way: N-vertex rings stacked in order, stitched with side quads and
|
|
4
|
+
// closed with fan caps, then imported via Manifold.ofMesh. The winding convention is
|
|
5
|
+
// CCW = outward (getting it wrong makes ofMesh throw or import an inverted solid), so
|
|
6
|
+
// these helpers own the winding once for every ring-mesh primitive.
|
|
7
|
+
|
|
8
|
+
// Stitch side walls between consecutive rings. `V` already holds ringCount rings of
|
|
9
|
+
// ringSegs vertices each, ring i occupying indices [i*ringSegs, (i+1)*ringSegs). When
|
|
10
|
+
// `closed` also stitches the last ring back to the first (a tube/loop with no caps).
|
|
11
|
+
// Winding assumes each ring is CCW viewed from +Z and rings are ordered along +Z.
|
|
12
|
+
export function sideQuads(Tr, ringCount, ringSegs, closed = false) {
|
|
13
|
+
const last = closed ? ringCount : ringCount - 1;
|
|
14
|
+
for (let i = 0; i < last; i++) {
|
|
15
|
+
const i0 = i * ringSegs, i1 = ((i + 1) % ringCount) * ringSegs;
|
|
16
|
+
for (let j = 0; j < ringSegs; j++) {
|
|
17
|
+
const a = i0 + j, b = i0 + (j + 1) % ringSegs;
|
|
18
|
+
const cc = i1 + j, dd = i1 + (j + 1) % ringSegs;
|
|
19
|
+
Tr.push(a, dd, cc, a, b, dd);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Add a triangle fan closing one ring around a center point (pushed as a new vertex).
|
|
25
|
+
// `flip` reverses the winding: use flip=true for a bottom cap (faces −Z) and flip=false
|
|
26
|
+
// for a top cap (faces +Z), so both caps point outward. The center should lie inside the
|
|
27
|
+
// ring's polygon (its centroid), so the fan is valid for convex / star-convex rings.
|
|
28
|
+
export function fanCap(V, Tr, ringStart, ringSegs, center, flip) {
|
|
29
|
+
const c = V.length / 3;
|
|
30
|
+
V.push(center[0], center[1], center[2]);
|
|
31
|
+
for (let j = 0; j < ringSegs; j++) {
|
|
32
|
+
const a = ringStart + j, b = ringStart + (j + 1) % ringSegs;
|
|
33
|
+
if (flip) Tr.push(c, b, a); else Tr.push(c, a, b);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reverse the winding of every triangle in `Tr` in place (swap the 2nd and 3rd index of
|
|
38
|
+
// each tri). Flips which way all faces point, i.e. turns an inward-facing (negative-volume)
|
|
39
|
+
// mesh into an outward-facing one. Used to make loft winding/z-order agnostic.
|
|
40
|
+
export function reverseWinding(Tr) {
|
|
41
|
+
for (let t = 0; t < Tr.length; t += 3) { const tmp = Tr[t + 1]; Tr[t + 1] = Tr[t + 2]; Tr[t + 2] = tmp; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Import a flat vertex array + triangle indices as a watertight Manifold. merge() welds
|
|
45
|
+
// coincident vertices so ofMesh sees a closed manifold; ofMesh consumes the mesh handle,
|
|
46
|
+
// so free it here and let the caller track the returned Manifold.
|
|
47
|
+
export function manifoldFromMesh(wasm, V, Tr) {
|
|
48
|
+
const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
|
|
49
|
+
mesh.merge();
|
|
50
|
+
const out = wasm.Manifold.ofMesh(mesh);
|
|
51
|
+
mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -7,11 +7,14 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
7
7
|
import { finishKernel } from "./kernel-front.js";
|
|
8
8
|
import { createOcctRepair } from "./occt-repair.js";
|
|
9
9
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
10
|
+
import { resolveRings } from "./loft.js";
|
|
11
|
+
import { resolveSweepStations } from "./sweep.js";
|
|
12
|
+
import { normalizeProfile } from "./profile.js";
|
|
10
13
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
11
14
|
|
|
12
15
|
export function createOcctKernel(replicad) {
|
|
13
16
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
14
|
-
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
17
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
15
18
|
|
|
16
19
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
17
20
|
// see occt-repair.js for the policies and why they differ per op.
|
|
@@ -83,11 +86,25 @@ export function createOcctKernel(replicad) {
|
|
|
83
86
|
return wrap(loft([w1, w2]));
|
|
84
87
|
};
|
|
85
88
|
|
|
89
|
+
// Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
|
|
90
|
+
// the former polyDrawing) OR an ArcContour whose { to, via } segments become true
|
|
91
|
+
// OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
|
|
92
|
+
// real CIRCLE B-rep entity, not a fan of LINEs. close() joins the last point back to
|
|
93
|
+
// the start with a straight edge (mirrors the implied ArcContour closure).
|
|
94
|
+
const contourDrawing = (contour) => {
|
|
95
|
+
if (Array.isArray(contour)) {
|
|
96
|
+
let pen = draw(contour[0]);
|
|
97
|
+
for (let i = 1; i < contour.length; i++) pen = pen.lineTo(contour[i]);
|
|
98
|
+
return pen.close();
|
|
99
|
+
}
|
|
100
|
+
let pen = draw(contour.start);
|
|
101
|
+
for (const seg of contour.segments) pen = seg.via ? pen.threePointsArcTo(seg.to, seg.via) : pen.lineTo(seg.to);
|
|
102
|
+
return pen.close();
|
|
103
|
+
};
|
|
104
|
+
|
|
86
105
|
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
87
106
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
88
|
-
|
|
89
|
-
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
90
|
-
const sketch = pen.close().sketchOnPlane("XY");
|
|
107
|
+
const sketch = contourDrawing(pts).sketchOnPlane("XY");
|
|
91
108
|
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
92
109
|
const cfg = {};
|
|
93
110
|
if (twist !== 0) cfg.twistAngle = twist;
|
|
@@ -96,11 +113,54 @@ export function createOcctKernel(replicad) {
|
|
|
96
113
|
};
|
|
97
114
|
|
|
98
115
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
99
|
-
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
const revolve = (pts, { degrees = 360 } = {}) =>
|
|
117
|
+
wrap(contourDrawing(pts).sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
|
|
118
|
+
|
|
119
|
+
// extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
|
|
120
|
+
// Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
|
|
121
|
+
const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
122
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
123
|
+
let region = contourDrawing(outer);
|
|
124
|
+
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
125
|
+
const sketch = region.sketchOnPlane("XY");
|
|
126
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
127
|
+
const cfg = {};
|
|
128
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
129
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
130
|
+
return wrap(sketch.extrude(h, cfg));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
|
|
134
|
+
// the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
|
|
135
|
+
const loftOp = (rings, { ruled = true, closed = false } = {}) => {
|
|
136
|
+
if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
|
|
137
|
+
const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
|
|
138
|
+
return wrap(loft(wires, { ruled }));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Sweep a 2-D profile along a 3-D polyline path. DEFAULT (§3A recipe): loft the SAME
|
|
142
|
+
// 3-D stations resolveSweepStations() hands the Manifold backend, as ruled polygon wires
|
|
143
|
+
// — so the two backends produce identical elbow geometry by construction (the loft-parity
|
|
144
|
+
// mechanism, not a tolerance). smooth:true switches to the OCCT-native genericSweep along
|
|
145
|
+
// a spline spine for an exact swept B-rep (STEP-exact / preview-faceted, parity waived —
|
|
146
|
+
// the same contract loft ships for ruled:false). closed:true loops are Manifold-only.
|
|
147
|
+
const sweepSmooth = (profile2D, path3D, cornerRadius) => {
|
|
148
|
+
const edges = [];
|
|
149
|
+
for (let i = 0; i < path3D.length - 1; i++) edges.push(makeLine(path3D[i], path3D[i + 1]));
|
|
150
|
+
const spine = assembleWire(edges);
|
|
151
|
+
const t0 = [path3D[1][0] - path3D[0][0], path3D[1][1] - path3D[0][1], path3D[1][2] - path3D[0][2]];
|
|
152
|
+
const profileWire = contourDrawing(profile2D).sketchOnPlane(new Plane(path3D[0], null, t0)).wire;
|
|
153
|
+
return wrap(genericSweep(profileWire, spine, {
|
|
154
|
+
transitionMode: cornerRadius > 0 ? "round" : "right", // sharp miter analogue vs rounded joint
|
|
155
|
+
forceProfileSpineOthogonality: true,
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
const sweep = (profile2D, path3D, { closed = false, cornerRadius = 0, ruled = true, smooth = false } = {}) => {
|
|
159
|
+
if (closed) throw new Error("sweep: closed:true loops are only supported on the Manifold backend");
|
|
160
|
+
if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius);
|
|
161
|
+
const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
|
|
162
|
+
const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
|
|
163
|
+
return wrap(loft(wires, { ruled }));
|
|
104
164
|
};
|
|
105
165
|
|
|
106
166
|
// circle profile swept along a helix (frenet)
|
|
@@ -114,7 +174,7 @@ export function createOcctKernel(replicad) {
|
|
|
114
174
|
|
|
115
175
|
return finishKernel({
|
|
116
176
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
117
|
-
box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
177
|
+
box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
118
178
|
sphere: (r) => wrap(makeSphere(r)),
|
|
119
179
|
union: (solids) => wrap(
|
|
120
180
|
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
@@ -97,6 +97,95 @@ export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
|
|
|
97
97
|
return pts;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Per-corner rounding geometry, shared verbatim by filletPolygon (which tessellates it)
|
|
101
|
+
// and roundedProfile (which emits it as a symbolic arc). Given a corner p0→p1→p2 and a
|
|
102
|
+
// requested radius r, returns the incoming/outgoing tangent points `a`/`b`, the arc centre
|
|
103
|
+
// `c`, the clamped radius `rr`, the short sweep `dA`, and the incoming start angle `a0` —
|
|
104
|
+
// or null for a corner that must stay sharp (zero-length edge or a straight/180° corner).
|
|
105
|
+
// The per-corner clamp (t ≤ min(l0,l2)/2) keeps neighbouring arcs from overlapping.
|
|
106
|
+
// Extracting this means the two consumers can never diverge on clamping/winding.
|
|
107
|
+
export function cornerArc(p0, p1, p2, r) {
|
|
108
|
+
let v0 = [p0[0] - p1[0], p0[1] - p1[1]], v2 = [p2[0] - p1[0], p2[1] - p1[1]];
|
|
109
|
+
const l0 = Math.hypot(v0[0], v0[1]), l2 = Math.hypot(v2[0], v2[1]);
|
|
110
|
+
if (l0 < 1e-9 || l2 < 1e-9) return null; // zero-length edge → sharp
|
|
111
|
+
v0 = [v0[0] / l0, v0[1] / l0]; v2 = [v2[0] / l2, v2[1] / l2];
|
|
112
|
+
const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
|
|
113
|
+
const half = Math.acos(cosA) / 2; // half the corner's interior angle
|
|
114
|
+
let bis = [v0[0] + v2[0], v0[1] + v2[1]];
|
|
115
|
+
const bl = Math.hypot(bis[0], bis[1]);
|
|
116
|
+
if (half < 1e-6 || bl < 1e-9) return null; // straight (180°) corner → sharp
|
|
117
|
+
bis = [bis[0] / bl, bis[1] / bl];
|
|
118
|
+
let rr = r, t = r / Math.tan(half); // tangent setback along each edge
|
|
119
|
+
const tmax = Math.min(l0, l2) / 2; // clamp: never past an edge midpoint
|
|
120
|
+
if (t > tmax) { t = tmax; rr = t * Math.tan(half); }
|
|
121
|
+
const a = [p1[0] + v0[0] * t, p1[1] + v0[1] * t]; // tangent point on the incoming edge
|
|
122
|
+
const b = [p1[0] + v2[0] * t, p1[1] + v2[1] * t]; // tangent point on the outgoing edge
|
|
123
|
+
const c = [p1[0] + bis[0] * (rr / Math.sin(half)), p1[1] + bis[1] * (rr / Math.sin(half))]; // arc center
|
|
124
|
+
const a0 = Math.atan2(a[1] - c[1], a[0] - c[0]);
|
|
125
|
+
let dA = Math.atan2(b[1] - c[1], b[0] - c[0]) - a0; // sweep the SHORT arc from a to b
|
|
126
|
+
while (dA <= -Math.PI) dA += 2 * Math.PI;
|
|
127
|
+
while (dA > Math.PI) dA -= 2 * Math.PI;
|
|
128
|
+
return { a, b, c, rr, dA, a0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Round every corner of a CCW polygon: each vertex is replaced by a tangent circular
|
|
132
|
+
// arc of radius r, tessellated with `segs` segments per corner (default 8, matching
|
|
133
|
+
// roundedRectPolygon). Returns a plain [[x,y],…] point list usable by prism/extrude/loft
|
|
134
|
+
// on BOTH kernels by construction. Corners are CLAMPED per-corner: r is reduced so an
|
|
135
|
+
// arc's tangent points never pass the midpoint of either adjacent edge, so neighbouring
|
|
136
|
+
// rounded corners can never overlap (pass a very large r to fully round every corner).
|
|
137
|
+
// Intended for convex CCW outlines (brackets, gussets, pads, knob/star profiles); a
|
|
138
|
+
// reflex corner is still rounded but its arc is placed on the angle bisector.
|
|
139
|
+
// NOTE: bakes each arc into `segs` straight facets, so STEP export of a filletPolygon
|
|
140
|
+
// part has faceted (LINE) corners; for mathematically-true CIRCLE corners in STEP use
|
|
141
|
+
// roundedProfile, which carries the arc symbolically to both backends.
|
|
142
|
+
export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
143
|
+
const n = points.length;
|
|
144
|
+
if (n < 3) throw new Error("filletPolygon: need at least 3 points");
|
|
145
|
+
if (!(r > 0)) throw new Error("filletPolygon: r must be > 0");
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
const arc = cornerArc(points[(i - 1 + n) % n], points[i], points[(i + 1) % n], r);
|
|
149
|
+
if (!arc) { out.push([points[i][0], points[i][1]]); continue; } // sharp corner
|
|
150
|
+
const { c, rr, dA, a0 } = arc;
|
|
151
|
+
for (let s = 0; s <= segs; s++) {
|
|
152
|
+
const ang = a0 + dA * (s / segs);
|
|
153
|
+
out.push([c[0] + rr * Math.cos(ang), c[1] + rr * Math.sin(ang)]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
|
|
160
|
+
// tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
|
|
161
|
+
// line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
|
|
162
|
+
// that carries the arc SYMBOLICALLY. Feed it to prism/extrude (not loft yet): OCCT builds a
|
|
163
|
+
// true CIRCLE B-rep edge (exact STEP fillets) while Manifold tessellates the same spec, so
|
|
164
|
+
// both kernels agree by construction. `r` is a scalar (every corner) or a per-corner array
|
|
165
|
+
// r[] (length === points.length; a 0 or a degenerate corner stays sharp — a plain line).
|
|
166
|
+
export function roundedProfile(points, r) {
|
|
167
|
+
const n = points.length;
|
|
168
|
+
if (n < 3) throw new Error("roundedProfile: need at least 3 points");
|
|
169
|
+
const radii = Array.isArray(r) ? r : null;
|
|
170
|
+
if (radii && radii.length !== n)
|
|
171
|
+
throw new Error("roundedProfile: r[] length must match points length");
|
|
172
|
+
if (!radii && !(r >= 0)) throw new Error("roundedProfile: r must be ≥ 0 (or a per-corner r[]); 0 keeps every corner sharp");
|
|
173
|
+
const segments = [];
|
|
174
|
+
let start = null;
|
|
175
|
+
const lineTo = (p) => { if (start === null) start = [p[0], p[1]]; else segments.push({ to: [p[0], p[1]] }); };
|
|
176
|
+
for (let i = 0; i < n; i++) {
|
|
177
|
+
const p1 = points[i];
|
|
178
|
+
const ri = radii ? radii[i] : r;
|
|
179
|
+
const arc = ri > 0 ? cornerArc(points[(i - 1 + n) % n], p1, points[(i + 1) % n], ri) : null;
|
|
180
|
+
if (!arc) { lineTo(p1); continue; } // sharp / degenerate corner → plain vertex
|
|
181
|
+
const { a, b, c, rr, dA, a0 } = arc;
|
|
182
|
+
lineTo(a); // straight run into the incoming tangent point
|
|
183
|
+
const mid = a0 + dA / 2; // arc midpoint (three-point via — sign/winding-free)
|
|
184
|
+
segments.push({ to: [b[0], b[1]], via: [c[0] + rr * Math.cos(mid), c[1] + rr * Math.sin(mid)] });
|
|
185
|
+
}
|
|
186
|
+
return { start, segments, arc: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
100
189
|
const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
101
190
|
|
|
102
191
|
// `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
|
|
2
|
+
// is EITHER a bare points array (legacy, all straight edges) OR a canonical ArcContour
|
|
3
|
+
// { start:[x,y], segments:[{to}|{to,via}], arc:true } carrying true circular arcs (from
|
|
4
|
+
// roundedProfile). normalizeProfile validates the polymorphic { outer, holes } envelope
|
|
5
|
+
// (bare array = outer only), preserving each contour's shape; tessellateProfile turns the
|
|
6
|
+
// arcs into point rings for the Manifold (mesh) path. The OCCT path consumes the same
|
|
7
|
+
// ArcContour directly (contourDrawing → threePointsArcTo) for true CIRCLE B-rep edges.
|
|
8
|
+
// Legacy point-array contours take the exact former path byte-for-byte — no cache-busting.
|
|
9
|
+
|
|
10
|
+
// An ArcContour is a non-array object carrying arcs symbolically.
|
|
11
|
+
export function isArcContour(c) {
|
|
12
|
+
return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateContour(c, role) {
|
|
16
|
+
if (isArcContour(c)) {
|
|
17
|
+
if (!Array.isArray(c.start) || c.start.length < 2)
|
|
18
|
+
throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
|
|
19
|
+
if (!Array.isArray(c.segments) || c.segments.length < 1)
|
|
20
|
+
throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProfile(profile) {
|
|
27
|
+
let outer, holes;
|
|
28
|
+
if (Array.isArray(profile) || isArcContour(profile)) { outer = profile; holes = []; }
|
|
29
|
+
else if (profile && typeof profile === "object") { outer = profile.outer; holes = profile.holes ?? []; }
|
|
30
|
+
else throw new Error("extrude: profile must be [[x,y],…], an arc contour, or { outer, holes? }");
|
|
31
|
+
// Preserve the historical, test-pinned wording for the legacy point-array path.
|
|
32
|
+
if (isArcContour(outer)) validateContour(outer, "outer contour");
|
|
33
|
+
else if (!Array.isArray(outer) || outer.length < 3) throw new Error("extrude: outer contour needs ≥3 points");
|
|
34
|
+
if (!Array.isArray(holes)) throw new Error("extrude: holes must be an array of contours");
|
|
35
|
+
for (const hole of holes) {
|
|
36
|
+
if (isArcContour(hole)) validateContour(hole, "hole arc contour");
|
|
37
|
+
else if (!Array.isArray(hole) || hole.length < 3) throw new Error("extrude: each hole needs ≥3 points");
|
|
38
|
+
}
|
|
39
|
+
return { outer, holes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
|
|
43
|
+
// emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
|
|
44
|
+
// the last point is exactly p1). The circle is recovered from the circumcircle of the
|
|
45
|
+
// three points; the sweep direction is the one whose arc actually passes through `via`
|
|
46
|
+
// (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
|
|
47
|
+
// full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
|
|
48
|
+
// arc and a circleProfile of equal radius facet identically. A degenerate (collinear)
|
|
49
|
+
// triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
|
|
50
|
+
// side gets when roundedProfile emits no `via`.
|
|
51
|
+
export function sampleArc(p0, via, p1, segs) {
|
|
52
|
+
const [ax, ay] = p0, [bx, by] = via, [cx, cy] = p1;
|
|
53
|
+
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
54
|
+
if (Math.abs(d) < 1e-12) return [[cx, cy]]; // collinear → straight line
|
|
55
|
+
const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
|
|
56
|
+
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
57
|
+
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
58
|
+
const rr = Math.hypot(ax - ux, ay - uy);
|
|
59
|
+
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
60
|
+
const av = Math.atan2(by - uy, bx - ux);
|
|
61
|
+
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
62
|
+
const twoPi = 2 * Math.PI;
|
|
63
|
+
const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
|
|
64
|
+
const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
|
|
65
|
+
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
|
|
66
|
+
const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let s = 1; s <= steps; s++) {
|
|
69
|
+
const ang = a0 + dA * (s / steps);
|
|
70
|
+
out.push([ux + rr * Math.cos(ang), uy + rr * Math.sin(ang)]);
|
|
71
|
+
}
|
|
72
|
+
out[out.length - 1] = [cx, cy]; // pin the exact endpoint
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
77
|
+
// (identical to the former path); an ArcContour is walked start→segment→segment, lines
|
|
78
|
+
// pushing their `to` and arcs pushing their sampled points.
|
|
79
|
+
export function tessellateContour(contour, segs) {
|
|
80
|
+
if (Array.isArray(contour)) return contour;
|
|
81
|
+
const ring = [[contour.start[0], contour.start[1]]];
|
|
82
|
+
let prev = contour.start;
|
|
83
|
+
for (const seg of contour.segments) {
|
|
84
|
+
if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
|
|
85
|
+
else ring.push([seg.to[0], seg.to[1]]);
|
|
86
|
+
prev = seg.to;
|
|
87
|
+
}
|
|
88
|
+
return ring;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
|
|
92
|
+
// point rings, ready for CrossSection.ofPolygons on the Manifold path.
|
|
93
|
+
export function tessellateProfile(profile, segs) {
|
|
94
|
+
const { outer, holes } = normalizeProfile(profile);
|
|
95
|
+
return { outer: tessellateContour(outer, segs), holes: holes.map((hl) => tessellateContour(hl, segs)) };
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Backend-shared sweep support. resolveSweepStations() walks a 3-D polyline path with a
|
|
2
|
+
// rotation-minimizing frame (parallel transport, specialised to piecewise-linear paths:
|
|
3
|
+
// the frame only ever rotates ACROSS a vertex, by the minimal rotation carrying the
|
|
4
|
+
// incoming tangent onto the outgoing one) and places the 2-D profile at a fixed, shared
|
|
5
|
+
// set of 3-D cross-section stations. BOTH backends build from that SAME station list —
|
|
6
|
+
// Manifold hand-meshes it (sweepMesh below, the loft/helix-tube recipe via mesh-build.js),
|
|
7
|
+
// OCCT lofts the same rings ruled (occt-backend.js). So the elbow shape agrees BY
|
|
8
|
+
// CONSTRUCTION, not by tolerance — the same parity mechanism loft's resolveRings uses.
|
|
9
|
+
//
|
|
10
|
+
// Corners: cornerRadius==0 → a SHARP MITER (one station per vertex, in the bisecting
|
|
11
|
+
// plane, stretched by 1/cos(turn/2) so straight walls meet flush). cornerRadius>0 →
|
|
12
|
+
// a tangent circular ARC FAN (setback clamped like filletPolygon). Fold conditions
|
|
13
|
+
// (profile too wide for a bend; 180° reversal) throw up front — the volume+bbox oracle
|
|
14
|
+
// would ship a fold silently otherwise.
|
|
15
|
+
import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
|
|
16
|
+
|
|
17
|
+
const EPS = 1e-9;
|
|
18
|
+
const Z = [0, 0, 1], X = [1, 0, 0];
|
|
19
|
+
// Corner-arc station density, in degrees of turn per station. A shared CONSTANT (not a
|
|
20
|
+
// backend/quality value) so both backends subdivide a cornerRadius arc identically →
|
|
21
|
+
// identical stations → parity by construction.
|
|
22
|
+
const ARC_STEP_DEG = 12;
|
|
23
|
+
|
|
24
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
25
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
26
|
+
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
27
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
28
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
29
|
+
const vlen = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
30
|
+
const norm = (a) => { const m = vlen(a) || 1; return [a[0] / m, a[1] / m, a[2] / m]; };
|
|
31
|
+
// Rotate vector v about unit axis k by angle ang (Rodrigues) — same math as
|
|
32
|
+
// manifold-backend.js axisAngleMat4, applied to a vector.
|
|
33
|
+
const rodrigues = (v, k, ang) => {
|
|
34
|
+
const c = Math.cos(ang), s = Math.sin(ang), kd = dot(k, v), kv = cross(k, v);
|
|
35
|
+
return [
|
|
36
|
+
v[0] * c + kv[0] * s + k[0] * kd * (1 - c),
|
|
37
|
+
v[1] * c + kv[1] * s + k[1] * kd * (1 - c),
|
|
38
|
+
v[2] * c + kv[2] * s + k[2] * kd * (1 - c),
|
|
39
|
+
];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Place the 2-D profile into 3-D at `center` using frame axes (N=profile-x, B=profile-y).
|
|
43
|
+
const placeRing = (profile2D, center, N, B) =>
|
|
44
|
+
profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
|
|
45
|
+
|
|
46
|
+
export function resolveSweepStations(profile2D, path3D, { closed = false, cornerRadius = 0 } = {}) {
|
|
47
|
+
if (!Array.isArray(profile2D) || profile2D.length < 3)
|
|
48
|
+
throw new Error("sweep: profile2D must be an array of ≥3 [x,y] points");
|
|
49
|
+
if (!Array.isArray(path3D) || path3D.length < 2)
|
|
50
|
+
throw new Error("sweep: path3D must be an array of ≥2 [x,y,z] points");
|
|
51
|
+
for (let i = 0; i < path3D.length; i++) {
|
|
52
|
+
const p = path3D[i];
|
|
53
|
+
if (!Array.isArray(p) || p.length < 3 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]) || !Number.isFinite(p[2]))
|
|
54
|
+
throw new Error(`sweep: path3D[${i}] must be a finite [x,y,z]`);
|
|
55
|
+
}
|
|
56
|
+
const P = path3D, m = P.length;
|
|
57
|
+
const segCount = closed ? m : m - 1;
|
|
58
|
+
const dir = [], segLen = [];
|
|
59
|
+
for (let k = 0; k < segCount; k++) {
|
|
60
|
+
const d = sub(P[(k + 1) % m], P[k]), l = vlen(d);
|
|
61
|
+
if (l < EPS) throw new Error(`sweep: path segment ${k} has zero length (coincident points ${k} and ${(k + 1) % m})`);
|
|
62
|
+
dir.push(scl(d, 1 / l)); segLen.push(l);
|
|
63
|
+
}
|
|
64
|
+
// profile half-width = the farthest a profile vertex reaches from its own origin
|
|
65
|
+
let maxReach = 0;
|
|
66
|
+
for (const [x, y] of profile2D) maxReach = Math.max(maxReach, Math.hypot(x, y));
|
|
67
|
+
|
|
68
|
+
// Seed the frame ⟂ the tangent coming INTO the first processed station (reference-vector
|
|
69
|
+
// method; the ref pick avoids N collapsing when the path starts along Z).
|
|
70
|
+
const seedT = closed ? dir[segCount - 1] : dir[0];
|
|
71
|
+
const ref = Math.abs(dot(seedT, Z)) < 0.9 ? Z : X;
|
|
72
|
+
let N = norm(sub(ref, scl(seedT, dot(ref, seedT))));
|
|
73
|
+
let B = cross(seedT, N);
|
|
74
|
+
|
|
75
|
+
const stations = [];
|
|
76
|
+
|
|
77
|
+
// Emit station(s) for an interior vertex and advance (N,B) from ⟂tIn to ⟂tOut.
|
|
78
|
+
const corner = (center, tIn, tOut, vtx, lenIn, lenOut) => {
|
|
79
|
+
const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
|
|
80
|
+
const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
|
|
81
|
+
if (cdot < -1 + 1e-6)
|
|
82
|
+
throw new Error(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`);
|
|
83
|
+
if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
|
|
84
|
+
const axis = scl(axisRaw, 1 / s);
|
|
85
|
+
const theta = Math.atan2(s, cdot); // exterior turn angle
|
|
86
|
+
if (cornerRadius > 0) {
|
|
87
|
+
if (cornerRadius < maxReach)
|
|
88
|
+
throw new Error(`sweep: cornerRadius ${cornerRadius} < profile half-width ${maxReach.toFixed(3)} at vertex ${vtx} — the inner wall would fold; increase cornerRadius`);
|
|
89
|
+
const t = cornerRadius * Math.tan(theta / 2); // tangent setback along each leg (= r/tan(interiorHalf))
|
|
90
|
+
const tmax = Math.min(lenIn, lenOut) / 2;
|
|
91
|
+
if (t > tmax)
|
|
92
|
+
throw new Error(`sweep: cornerRadius too large for the bend at vertex ${vtx} (setback ${t.toFixed(3)} > half the shorter segment ${tmax.toFixed(3)}) — reduce cornerRadius or lengthen the segment`);
|
|
93
|
+
const a = sub(center, scl(tIn, t)); // arc start on the incoming leg
|
|
94
|
+
const arcCenter = add(center, scl(norm(sub(tOut, tIn)), cornerRadius / Math.cos(theta / 2)));
|
|
95
|
+
const va = sub(a, arcCenter);
|
|
96
|
+
const steps = Math.max(2, Math.ceil(((theta * 180) / Math.PI) / ARC_STEP_DEG));
|
|
97
|
+
for (let i = 0; i <= steps; i++) { // smooth arc: rotate frame with the tangent, no miter tilt
|
|
98
|
+
const ang = (theta * i) / steps;
|
|
99
|
+
stations.push(placeRing(profile2D, add(arcCenter, rodrigues(va, axis, ang)),
|
|
100
|
+
rodrigues(N, axis, ang), rodrigues(B, axis, ang)));
|
|
101
|
+
}
|
|
102
|
+
} else { // sharp miter: one station in the bisecting plane
|
|
103
|
+
if (maxReach * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
104
|
+
throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
|
|
105
|
+
const Nh = rodrigues(N, axis, theta / 2), Bh = rodrigues(B, axis, theta / 2);
|
|
106
|
+
const mDir = rodrigues(tIn, axis, theta / 2); // ring-plane normal (average travel dir)
|
|
107
|
+
const u = norm(cross(axis, mDir)); // in-plane bend direction (stretch axis)
|
|
108
|
+
const cosh = Math.cos(theta / 2);
|
|
109
|
+
stations.push(profile2D.map(([x, y]) => {
|
|
110
|
+
const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
|
|
111
|
+
return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
N = rodrigues(N, axis, theta); B = rodrigues(B, axis, theta); // advance frame to ⟂ tOut
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (closed) {
|
|
118
|
+
for (let k = 0; k < m; k++)
|
|
119
|
+
corner(P[k], dir[(k - 1 + m) % m], dir[k], k, segLen[(k - 1 + m) % m], segLen[k]);
|
|
120
|
+
} else {
|
|
121
|
+
stations.push(placeRing(profile2D, P[0], N, B)); // start cap ring ⟂ dir[0]
|
|
122
|
+
for (let k = 1; k <= m - 2; k++) corner(P[k], dir[k - 1], dir[k], k, segLen[k - 1], segLen[k]);
|
|
123
|
+
stations.push(placeRing(profile2D, P[m - 1], N, B)); // end cap ring ⟂ dir[last]
|
|
124
|
+
}
|
|
125
|
+
return { stations, closed };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const centroid = (ring) => {
|
|
129
|
+
let cx = 0, cy = 0, cz = 0;
|
|
130
|
+
for (const [x, y, z] of ring) { cx += x; cy += y; cz += z; }
|
|
131
|
+
return [cx / ring.length, cy / ring.length, cz / ring.length];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Manifold path: stack the resolved stations, stitch side quads, and (unless closed) fan a
|
|
135
|
+
// cap over each end from its 3-D centroid. Winding self-heals via loft's signed-volume
|
|
136
|
+
// check so the sweep is winding/direction-agnostic. Returns a raw Manifold (caller T()s).
|
|
137
|
+
export function sweepMesh(wasm, profile2D, path3D, opts = {}) {
|
|
138
|
+
const { stations, closed } = resolveSweepStations(profile2D, path3D, opts);
|
|
139
|
+
const N = profile2D.length;
|
|
140
|
+
const V = [];
|
|
141
|
+
for (const ring of stations) for (const [x, y, z] of ring) V.push(x, y, z);
|
|
142
|
+
const Tr = [];
|
|
143
|
+
sideQuads(Tr, stations.length, N, closed);
|
|
144
|
+
if (!closed) {
|
|
145
|
+
fanCap(V, Tr, 0, N, centroid(stations[0]), true); // start cap faces backward
|
|
146
|
+
fanCap(V, Tr, (stations.length - 1) * N, N, centroid(stations[stations.length - 1]), false); // end faces forward
|
|
147
|
+
}
|
|
148
|
+
let out = manifoldFromMesh(wasm, V, Tr);
|
|
149
|
+
if (out.volume() < 0) { out.delete?.(); reverseWinding(Tr); out = manifoldFromMesh(wasm, V, Tr); }
|
|
150
|
+
return out;
|
|
151
|
+
}
|