partforge 0.7.0 → 0.8.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.
@@ -90,13 +90,55 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
90
90
  |---|---|
91
91
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
92
92
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
93
- | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
93
+ | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
94
+ | `k.extrude(profile, h, { twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
95
+ | `k.loft(rings, { ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
96
+ | `k.sweep(profile2D, path3D, { cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
94
97
  | `k.sphere(r)` | sphere centred at the origin |
95
98
  | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
96
99
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
97
100
  | `k.union(solids[])` | boolean union |
98
101
 
99
- 2-D polygon helpers for `prism`: `import { piePolygon, hexPolygon } from "partforge/geometry"`.
102
+ **`loft` rings** each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
103
+ (all rings must share the same vertex count; `rotate` is degrees about Z, `scale` is a
104
+ number or `[sx,sy]`). Author rings CCW and ordered by ascending `z` (the `regularPolygon`
105
+ / `polygon.js` helpers are already CCW); loft self-corrects a fully-inverted result so
106
+ CW-wound or descending-z rings still export a valid outward solid. (Arc profiles from
107
+ `roundedProfile` are **not** accepted as loft rings yet — a ring must be a point array;
108
+ use `prism`/`extrude` for true-arc STEP export.) **`sweep`** takes the same CCW
109
+ `polygon.js` outline as its `profile2D` and a plain `[[x,y,z],…]` point list as its
110
+ `path3D`; the profile stays perpendicular to the path (a rotation-minimizing frame), with
111
+ sharp mitered corners by default or `cornerRadius` fillets. Worked snippets:
112
+
113
+ ```js
114
+ // a square tube (extrude a region with a hole) — one op, no boolean cut
115
+ k.extrude({ outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, 10);
116
+
117
+ // a tapered, twisting faceted vase wall (see src/parts/faceted-vase.js)
118
+ const rings = [];
119
+ for (let i = 0; i <= 24; i++) { const t = i / 24;
120
+ rings.push({ sides: 6, radius: 30 - 8 * t, z: 120 * t, rotate: 90 * t }); }
121
+ k.loft(rings); // ruled walls, capped ends
122
+
123
+ // a cable/hose: sweep a circle along a 3-D polyline, with rounded bends
124
+ k.sweep(circleProfile(3), [[0, 0, 0], [0, 0, 20], [15, 0, 20]], { cornerRadius: 5 });
125
+
126
+ // round every corner of any CCW outline, then extrude/loft/prism it
127
+ k.prism(filletPolygon(bracketOutline, 3), 4); // tessellated corners (faceted in STEP)
128
+ k.prism(roundedProfile(bracketOutline, 3), 4); // true CIRCLE corners in STEP export
129
+ ```
130
+
131
+ 2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
132
+ regularPolygon, roundedRectPolygon, starPolygon, circleProfile, filletPolygon,
133
+ roundedProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
134
+ every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
135
+ and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
136
+ corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
137
+ rounds corners the same way but keeps them **mathematically true** — it carries the arc
138
+ symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (not yet
139
+ `loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
140
+ `r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
141
+ corner stays sharp).
100
142
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
101
143
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
102
144
  (importing the main entry there throws `document is not defined`).
@@ -195,7 +237,12 @@ module-level mutable state. An impure build will silently return stale geometry.
195
237
  Cache granularity follows the operations you call. Booleans and heavy primitives are
196
238
  cached; cheap transforms are recomputed. To make a multi-step shape into a single
197
239
  cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
198
- it hashes from its own arguments and never exposes its internals to the cache.
240
+ it hashes from its own arguments and never exposes its internals to the cache. The heavy
241
+ primitives `loft`, `sweep`, `extrude`, `prism`, and `revolve` are cached this way too:
242
+ their hash folds every shape-affecting argument (each `loft` ring's points/`z`/`rotate`/`scale`,
243
+ `sweep`'s profile points/path points/`cornerRadius`/`closed`, `extrude`'s holes, an arc
244
+ profile's segment specs from `roundedProfile`, and the tessellation from `twist`), so
245
+ changing any of them is a fresh cache node while an identical rebuild is a hit.
199
246
 
200
247
  ---
201
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,10 @@
1
+ import vasePart from "./parts/faceted-vase.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only example app for the faceted vase part. Identical wiring to app-planter.js —
5
+ // only the imported definition and the worker entry differ per part. `npm run dev`,
6
+ // then open /faceted-vase.html.
7
+ mount(vasePart, {
8
+ createWorker: (name) =>
9
+ new Worker(new URL("./faceted-vase-worker.js", import.meta.url), { type: "module", name }),
10
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/faceted-vase.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -1,8 +1,10 @@
1
1
  // Builds a watertight triangle mesh of a circular profile swept along a helix in
2
2
  // its frenet frame, then imports it as a Manifold solid. The profile stays
3
3
  // perpendicular to the helix tangent (unlike twist-extrude), so it matches an
4
- // exact frenet sweep. Winding is consistent-outward; getting it wrong makes
5
- // Manifold.ofMesh throw or import an inverted solid.
4
+ // exact frenet sweep. Winding is consistent-outward; the ring stitching + caps +
5
+ // ofMesh import are the shared ring-mesh helpers in mesh-build.js (also used by loft).
6
+ import { sideQuads, fanCap, manifoldFromMesh } from "./mesh-build.js";
7
+
6
8
  const norm = (v) => { const m = Math.hypot(...v); return [v[0] / m, v[1] / m, v[2] / m]; };
7
9
  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]];
8
10
 
@@ -28,23 +30,11 @@ export function helixTube(wasm, opts) {
28
30
  ctr[2] + profileR * (Math.cos(a) * N[2] + Math.sin(a) * B[2]));
29
31
  }
30
32
  }
31
- // side faces (outward winding)
32
- for (let i = 0; i < n - 1; i++) for (let j = 0; j < ringSegs; j++) {
33
- const a = i*ringSegs + j, b = i*ringSegs + (j+1)%ringSegs;
34
- const cc = (i+1)*ringSegs + j, dd = (i+1)*ringSegs + (j+1)%ringSegs;
35
- Tr.push(a, dd, cc, a, b, dd);
36
- }
37
- // end caps
38
- const c0 = V.length / 3;
39
- V.push(pathR * Math.cos(0), pathR * Math.sin(0), z0);
40
- for (let j = 0; j < ringSegs; j++) Tr.push(c0, (j+1)%ringSegs, j);
41
- const base = (n - 1) * ringSegs, cz = V.length / 3;
42
- V.push(pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax);
43
- for (let j = 0; j < ringSegs; j++) Tr.push(cz, base + j, base + (j+1)%ringSegs);
33
+ sideQuads(Tr, n, ringSegs, false); // side walls between the n stations
34
+ // end caps fanned from each end's path-center (outward: bottom flipped, top not)
35
+ fanCap(V, Tr, 0, ringSegs, [pathR, 0, z0], true);
36
+ fanCap(V, Tr, (n - 1) * ringSegs, ringSegs,
37
+ [pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax], false);
44
38
 
45
- const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
46
- mesh.merge();
47
- const out = wasm.Manifold.ofMesh(mesh);
48
- mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
49
- return out;
39
+ return manifoldFromMesh(wasm, V, Tr);
50
40
  }
@@ -15,6 +15,12 @@ export function finishKernel(k) {
15
15
  return rawPrism(pts, h, opts);
16
16
  };
17
17
 
18
+ const rawExtrude = k.extrude;
19
+ k.extrude = (profile, h, opts) => {
20
+ if ((opts?.scaleTop ?? 1) < 0) throw new Error("extrude: scaleTop must be ≥ 0");
21
+ return rawExtrude(profile, h, opts);
22
+ };
23
+
18
24
  const rawRevolve = k.revolve;
19
25
  k.revolve = (pts, opts) => {
20
26
  for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
@@ -7,8 +7,8 @@
7
7
 
8
8
  // Ops every backend kernel must implement.
9
9
  export const KERNEL_OPS = [
10
- "cylinder", "boredCylinder", "sphere", "box", "prism", "revolve",
11
- "helixSweptTube", "union", "toSTEP",
10
+ "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
11
+ "loft", "sweep", "helixSweptTube", "union", "toSTEP",
12
12
  ];
13
13
 
14
14
  // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
@@ -70,6 +70,9 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
70
70
  * @property {(r:number) => Solid} sphere sphere centred at the origin
71
71
  * @property {(min:number[], max:number[]) => Solid} box
72
72
  * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
73
+ * @property {(profile:number[][]|{outer:number[][],holes?:number[][][]}, h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} extrude extrude a polygon-with-holes region from z=0 in one op (bare array = outer only)
74
+ * @property {(rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[], opts?:{ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections (per-ring z/rotate/scale), ruled walls, capped ends
75
+ * @property {(profile2D:number[][], path3D:number[][], opts?:{closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a fixed 2-D profile along a 3-D polyline path (sharp mitered corners or cornerRadius fillets; capped ends; closed:true loops and smooth:true native B-rep are backend-specific)
73
76
  * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
74
77
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
75
78
  * @property {(solids:Solid[]) => Solid} union
@@ -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
- let pen = draw(pts[0]);
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
- let pen = draw(pts[0]);
101
- for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
102
- const sketch = pen.close().sketchOnPlane("XZ");
103
- return wrap(sketch.revolve([0, 0, 1], { angle: degrees }));
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
+ }
@@ -0,0 +1,75 @@
1
+ // Example PartDefinition — the motivating showcase for k.loft(). Silhouette rings are
2
+ // stacked up a smooth base→waist→rim curve; each ring is a regular n-gon rotated by a
3
+ // running twist plus an alternating half-facet offset, so the facets zig-zag into a
4
+ // woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
5
+ // backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
6
+ import { regularPolygon } from "partforge/geometry";
7
+
8
+ const RINGS = 28; // silhouette resolution (ring count up the height)
9
+
10
+ // Body radius at height fraction t (0..1): a quadratic Bézier through base/waist/rim.
11
+ const silhouette = (t, p) => { const a = 1 - t; return a * a * p.baseR + 2 * a * t * p.waistR + t * t * p.rimR; };
12
+
13
+ // Ring list for a wall at radial `inner` inset (offset along the face normal so the
14
+ // perpendicular wall stays == p.wall on every facet). inner=false → outer surface.
15
+ const vaseRings = (p, inner) => {
16
+ const inset = inner ? p.wall / Math.cos(Math.PI / p.facets) : 0;
17
+ const out = [];
18
+ for (let i = 0; i <= RINGS; i++) {
19
+ const t = i / RINGS;
20
+ const radius = Math.max(silhouette(t, p) - inset, 0.5);
21
+ const rotate = p.twist * t + (i % 2) * (180 / p.facets); // running twist + alternating half-facet
22
+ out.push({ sides: p.facets, radius, z: p.height * t, rotate });
23
+ }
24
+ return out;
25
+ };
26
+
27
+ export default {
28
+ meta: { title: "Faceted Vase", units: "mm", background: 0x15181d },
29
+ parameters: [
30
+ {
31
+ id: "body",
32
+ title: "Body",
33
+ description: "A faceted, twisting vase built from stacked cross-sections (`k.loft`). " +
34
+ "Pick a preset, or open **Advanced** for exact dimensions. **Facets** and **Twist** are the styling; **Wall** decides whether it prints cleanly.",
35
+ presets: {
36
+ "Tulip vase": { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2 },
37
+ "Barrel pot": { height: 90, baseR: 40, waistR: 44, rimR: 38, facets: 8, twist: 0, wall: 2.4 },
38
+ "Twist column": { height: 180, baseR: 30, waistR: 30, rimR: 30, facets: 6, twist: 120, wall: 2 },
39
+ },
40
+ advanced: [
41
+ { key: "height", label: "Height", unit: "mm", min: 40, max: 220, step: 1, description: "Overall height along the axis." },
42
+ { key: "baseR", label: "Base radius", unit: "mm", min: 15, max: 70, step: 1, description: "Across-corners radius at the foot." },
43
+ { key: "waistR", label: "Waist radius", unit: "mm", min: 12, max: 80, step: 1, description: "Radius at mid-height — set below base+rim to pinch a waist, above to bulge a belly." },
44
+ { key: "rimR", label: "Rim radius", unit: "mm", min: 12, max: 80, step: 1, description: "Across-corners radius at the mouth." },
45
+ { key: "facets", label: "Facets", min: 3, max: 12, step: 1, description: "Sides of each cross-section. Low counts read crystalline; high counts approach smooth." },
46
+ { key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5, description: "Total rotation of the facets from foot to rim, for a spiral." },
47
+ { key: "wall", label: "Wall thickness", unit: "mm", min: 1, max: 5, step: 0.1, description: "Perpendicular wall thickness. The fdm-pla profile wants **≥ 1.2 mm**." },
48
+ { key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 8, step: 0.5, hidden: true, description: "Internal: solid base thickness; hidden but drives the geometry." },
49
+ ],
50
+ },
51
+ ],
52
+ defaults: { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2, floor: 3 },
53
+ parts: {
54
+ vase: {
55
+ label: "Vase", views: ["vase"], export: { name: "vase" },
56
+ build: (k, p) => {
57
+ const body = k.loft(vaseRings(p, false)).label("Faceted wall");
58
+ // Hollow it: an inset loft clipped to z ≥ floor (so the base stays solid), cut from the body.
59
+ const cavity = k.loft(vaseRings(p, true))
60
+ .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10])).label("Cavity");
61
+ return body.cut(cavity);
62
+ },
63
+ },
64
+ },
65
+ views: { vase: { label: "Vase" } },
66
+ // Self-verification: opt into the FDM-PLA profile (bed-fit gate + min-wall warning) and
67
+ // pin the intent — an open vessel (no through-holes), fits the bed, no interpenetration.
68
+ verify: {
69
+ process: "fdm-pla",
70
+ expect: {
71
+ vase: { holes: 0, bbox: "<=[220,220,230]" },
72
+ _view: { overlaps: 0 },
73
+ },
74
+ },
75
+ };