partforge 0.83.1 → 0.85.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.
@@ -324,6 +324,7 @@ and the detection rule.
324
324
  | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
325
325
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove). **Not for threads** — the profile is always circular and rides a frenet frame that rolls with the helix, tilting a tooth off-axis. For threads use `k.screwSweep` |
326
326
  | `k.screwSweep({ profile, pitch, turns, lefthand? })` | screw-motion sweep of an **axial** lathe profile `[[r, z], …]` (same convention as `k.revolve`) — threads, worms, helical ridges. `h = pitch · turns`. The profile's axial extent must not exceed `pitch`; a profile spanning exactly `pitch` must be **periodic** (first radius == last radius) and yields a complete threaded body with no boolean (both backends) |
327
+ | `k.loftSmooth({ sections, stations?, samples?, shading?, closed? })` | smooth organic loft: ≥2 sparse control sections — point rings, `sides`+`radius`, curve contours, or `Shape2D`, vertex/corner counts may differ per section — interpolated with splines on both backends — the "here are 5 airfoil sections, make it smooth" op. The surface passes through every section exactly. A point section may tag `sharp: [indices]` to keep those vertices true corners instead of letting the spline round them off; a curve/`Shape2D` section gets its corners implicitly from its own non-smooth joints. All sections need the same corner count. `closed: true` closes the loft into a loop (Manifold-only, like `k.loft`). See the propeller reference part (`sharpTE` toggle) |
327
328
  | `k.union(solids[])` | boolean union |
328
329
 
329
330
  **`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius | {start,segments} | Shape2D, z, rotate?, scale? }`
@@ -349,6 +350,52 @@ loft self-corrects a fully-inverted result so CW-wound or descending-z rings sti
349
350
  Multi-region or holed `Shape2D` throws — loft each region as its own solid and union the lofts, or cut holes from
350
351
  the lofted solid after it closes.
351
352
 
353
+ **Smooth organic lofts.** When the silhouette should be a smooth curve rather
354
+ than faceted stations, don't densify rings by hand — hand `k.loftSmooth` the
355
+ few sections you can reason about and let it interpolate (both backends;
356
+ `k.loft` stays the right tool for deliberate facets and exact station control):
357
+
358
+ ```js
359
+ const sections = [0, 0.3, 0.6, 0.85, 1].map((t) => ({
360
+ polygon: airfoil(chord(t)), // plain [[x,y],…] point rings; counts may differ
361
+ z: span * t,
362
+ rotate: pitch(t), // authored twist sweeps correctly — vertex j
363
+ })); // is the same material line on every section
364
+ const blade = k.loftSmooth({ sections });
365
+ ```
366
+
367
+ Raise `samples` if the cross-section shows facets, `stations` if banding runs
368
+ along the spine. Vertex order and the vertex-0 seam are how corresponding
369
+ points line up across sections (or corner 0, when sections are tagged —
370
+ below).
371
+
372
+ Tag a true corner (e.g. an airfoil trailing edge that shouldn't be smeared
373
+ into a smooth curve) with `sharp`, an index list into that section's points —
374
+ every other section needs the same *count* of corners, tagged or implicit:
375
+
376
+ ```js
377
+ // src/parts/propeller.js's sharpTE toggle: vertex 0 of each airfoil section
378
+ // is the trailing edge (upper and lower surfaces meet there); tagging it
379
+ // keeps that meeting point a crease instead of letting the spline round it.
380
+ const sections = airfoilSections.map((s) => ({ ...s, sharp: [0] }));
381
+ ```
382
+
383
+ A section can also be a curve contour instead of a point ring — its corners
384
+ come for free from wherever the contour itself isn't smooth (a line/arc
385
+ joint, say), so it needs no `sharp` of its own (and rejects one if given):
386
+
387
+ ```js
388
+ // a half-round "D" profile: one line segment + one arc — 2 implicit corners
389
+ // (the line/arc joints), so it can loft alongside a point section tagged
390
+ // with exactly 2 sharp indices.
391
+ const D = { start: [0, -8], segments: [{ to: [0, 8] }, { to: [0, -8], via: [8, 0] }] };
392
+ k.loftSmooth({ sections: [{ polygon: D, z: 0 }, { polygon: D, z: 10 }] });
393
+ ```
394
+
395
+ Pass `closed: true` to close the loft into a loop instead of capping both
396
+ ends (Manifold-only, same restriction as `k.loft`'s `closed`; a part that
397
+ needs STEP export can't use it).
398
+
352
399
  **`sweep`** takes the same CCW `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
353
400
  `path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with sharp mitered corners by
354
401
  default or `cornerRadius` fillets. Worked snippets:
@@ -497,6 +497,30 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
497
497
  - **Cause:** the ring Shape2D has an inner contour (a `.cut()` inside the outline). Lofting hole tunnels needs its own correspondence and is not supported.
498
498
  - **Fix:** loft the outer outline, then `.cut()` a second loft (or an extrusion) of the hole profile from the solid. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (`loft` rings).
499
499
 
500
+ ## loftsmooth-corner-count-mismatch
501
+
502
+ - **Symptom:** `loftSmooth: every section must have the same corner count — section 1 has 0, section 0 has 2`
503
+ - **Cause:** one section carries a `sharp` list (or is a curve contour with corner joints) and another doesn't — every control section must resolve to the same corner count `m` (tagged or implicit), so correspondence across sections is unambiguous.
504
+ - **Fix:** tag the same corners on every section (or none) — a curve section's corners come from its own line/arc joints, so match that count with `sharp` on the point sections it lofts alongside. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md)'s `k.loftSmooth` row.
505
+
506
+ ## loftsmooth-closed-needs-manifold
507
+
508
+ - **Symptom:** `loftSmooth: closed:true loops are only supported on the Manifold backend`
509
+ - **Cause:** the part routed to OCCT — STEP export, or an explicit `meta.backend: "occt"` — and `closed: true` loft loops aren't supported there, same restriction as `k.loft`'s `closed`.
510
+ - **Fix:** drop `closed` for a part (or sub-part) that needs STEP export, or keep it mesh-only (no STEP, no `meta.backend: "occt"`). See the `loftSmooth` row in [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md).
511
+
512
+ ## loftsmooth-looks-faceted
513
+
514
+ - **Symptom:** a `loftSmooth` solid shows flat facets around the cross-section, in preview or in STEP, even though nothing errored.
515
+ - **Cause:** `samples` governs curve-*fit* resolution — how many cubic-Bézier spans the emitted ring is cut into — not a facet count, on **either** backend. A B-rep kernel lofts each span as one exact curve edge, so STEP is curve-exact around every ring regardless of `samples`; too few spans just means the fit follows the control sections less faithfully (a visible corner or fast bend flattens). A mesh kernel doesn't facet one triangle per span either — `k.loft`'s curve mode adaptively subdivides each span by curvature at the shared `LOFT_SEGS = 64` budget (`loft-rings.js`'s `segNaturalCount`/`sampleBezier`), so around-ring facet density comes from that curve LOD, and rises with `samples` only because more spans means more things to subdivide, not proportionally.
516
+ - **Fix:** raise `samples` (default `max(64, largest section)`, clamp ≤ 2048). If the banding runs along the spine instead, raise `stations`. See the `loftSmooth` row in [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md).
517
+
518
+ ## loftsmooth-zero-perimeter-arc
519
+
520
+ - **Symptom:** `loftSmooth: a control section has zero perimeter`
521
+ - **Cause:** two sharp-tagged vertices (or two contour corners) in the same section are numerically coincident, so the arc between them has zero length — e.g. tagging *both* ends of a closed NACA trailing edge, whose closure coefficient already brings them to the same point (or the same point after a per-section `rotate`). This also fires for the v1 meaning of the message: a genuinely degenerate section.
522
+ - **Fix:** tag only one of the coincident vertices as the corner (see `src/parts/propeller.js`'s `sharpTE` comment for why a second tag there would create exactly this zero-length arc). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md)'s `k.loftSmooth` row.
523
+
500
524
  ## duplicate-preset-name-throws
501
525
 
502
526
  - **Symptom:** `duplicate preset name across sections:` thrown from verify/measure, naming the repeated preset (e.g. `duplicate preset name across sections: "Compact"`).
@@ -288,6 +288,7 @@ above. All ops return a `Solid`.
288
288
  | `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
289
289
  | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
290
290
  | `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
291
+ | `loftSmooth({sections, stations?, samples?, shading?, closed?})` | Spline-interpolated loft of ≥2 sparse control sections — loft-style ring specs `{polygon\|sides+radius\|curve contour\|Shape2D, z, rotate?, scale?, sharp?}`; vertex counts **may differ**. A point section may tag `sharp: [indices]` as true corners (integers in `0…points.length-1`, sorted/deduped silently); a curve/`Shape2D` section takes corners implicitly from its non-smooth joints (single-region, hole-free, `loftSmooth:`-prefixed `k.loft` validation) and rejects an explicit `sharp`. Every section must resolve to the **same corner count `m`** (frozen error otherwise); with `m ≥ 1` corner 0 anchors the seam (replacing vertex 0), with `m = 0` v1's vertex-0 anchor holds verbatim. Compound (`kernel-front.js` + `loft-smooth.js`): each section's outline is a closed centripetal Catmull-Rom split into `m` clamped open arcs at its corners (or one closed periodic CR when `m = 0`); the `samples` budget is apportioned across arcs by mean arc-length fraction (largest-remainder, min 1 span/arc) and each arc resampled by arc length — total ring vertex count is `samples`, identical across sections, exactly v1's invariant now corner-anchored. The cross-station direction is v1 verbatim (shared centroid-spine knots, per-vertex CR, reflection phantoms at the ends, or periodic knots when `closed: true`). What's new is emission: every station — the dense list and the sparse `stations:"controls"` list alike — is fitted back to an **all-cubic Bézier contour**, arc-by-arc, via exact 4-point CR→Bézier inversion, so **both backends receive identical curve rings**. A B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — curve-exact around each ring in STEP (the densified-*point*-wire alternative measured 23 s / WASM-abort territory, which curve wires don't hit). A mesh kernel densifies `stations` rings and lofts them through `k.loft`'s curve-mode per-segment sampling, creasing sharp/corner columns via loft's geometric corner policy. `closed: true` (default false; needs ≥3 control sections, frozen error otherwise) makes the cross-station CR periodic (no reflection phantoms, ring 0 not repeated) and is **Manifold-only**, same restriction as `loft` `closed: true`: a B-rep kernel throws `loftSmooth: closed:true loops are only supported on the Manifold backend` in the composition, before building any rings; combining `closed: true` with `stations:"controls"` is rejected as a defensive invariant (reachable only by explicitly passing the internal `stations:"controls"` value; the composition never produces the combination itself). Options-only. Defaults `stations = (n−1)·8+1` open / `n·8` closed (raised to the section count `n` when lower), `samples = max(64, largest section)` (raised to the corner count `m` when lower); clamps 2…1024 / 8…2048. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class, unchanged from v1 — ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is now curve-exact around each ring (previously faceted at the `samples` LOD); the cross-station skin remains ThruSections' native fit, not the shared CR — exact cross-station B-splines are a v3 candidate. Additive: `sharp`, curve/`Shape2D` sections, and `closed` are new options on top of v1's `{sections, stations?, samples?, shading?}`; `CONTRACT_VERSION` stays 4 — the same non-bump precedent as `import` above, a refinement inside the op's already-stated tolerance class rather than a new one. |
291
292
  | `union(solids[])` | Boolean union of one or more solids. |
292
293
  | `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
293
294
  | `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.83.1",
3
+ "version": "0.85.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,15 @@
1
+ // Glue for the loftSmooth propeller reference part (see parts/propeller.js).
2
+ // Dev-only: `npm run dev`, then open /propeller.html.
3
+ import "@fontsource-variable/geist";
4
+ import "@fontsource-variable/geist-mono";
5
+ import part from "./parts/propeller.js";
6
+ import { mount } from "./framework/index.js";
7
+
8
+ window.__pfRuntime = mount(part, {
9
+ createWorker: (name) =>
10
+ new Worker(new URL("./propeller-worker.js", import.meta.url), { type: "module", name }),
11
+ onAnnotationSend: (payload) => {
12
+ window.__pfLastAnnotation = payload;
13
+ console.log("annotation payload", payload);
14
+ },
15
+ });
@@ -25,6 +25,7 @@ import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
25
25
  import { convexHull, hullPoints } from "./hull.js";
26
26
  import { latheRoundedRect, torusContour } from "./rounded-solids.js";
27
27
  import { screwCrossSection } from "./screw-profile.js";
28
+ import { smoothLoftRings } from "./loft-smooth.js";
28
29
 
29
30
  export function finishKernel(k) {
30
31
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -55,6 +56,35 @@ export function finishKernel(k) {
55
56
  twist: (lefthand ? -360 : 360) * turns,
56
57
  });
57
58
 
59
+ // Compound default: spline-smoothed loft. The shared Catmull-Rom densifier
60
+ // (loft-smooth.js) now emits all-cubic curve rings on BOTH paths — every ring
61
+ // is contour IR ({start, segments:[{to,c1,c2}…]}), fitted exactly from the CR
62
+ // spline (a CR span IS a cubic; 4-point inversion has no approximation error).
63
+ // On a mesh kernel it expands the sparse control sections in both directions
64
+ // and plain loft stitches the curve rings (matched per-segment samples). On a
65
+ // B-rep kernel, dense *point* wires broke OCCT (v1 spike: 23 s at 32×96, WASM
66
+ // abort at 48×128) — that fragility is exactly why `stations:"controls"`
67
+ // exists, skipping cross-station interpolation and handing the backend only
68
+ // the around-ring reconciliation. Curve wires don't share that fragility:
69
+ // OCCT's ThruSections handles many-span cubic-Bézier wires in milliseconds
70
+ // (`ruled: false` — exact B-spline surfaces), probed at 74–261 ms for
71
+ // 24–128 spans on the propeller reference part. Parity is therefore
72
+ // screwSweep's tolerance class, not sweep's by-construction class: measured
73
+ // ~0.4% volume divergence there.
74
+ // B-rep detection: `toSTEP` exists here only on a B-rep backend — the stub for
75
+ // mesh kernels is assigned later in this function.
76
+ // The guarded shading spread keeps an undefined key out of loft's option
77
+ // validation; a caller-supplied hint still passes through, and otherwise
78
+ // loft's own curve-ring shading policy decides (corners crease, curves stay
79
+ // smooth) rather than this composition forcing "smooth".
80
+ const brepLoft = typeof k.toSTEP === "function";
81
+ k.loftSmooth ??= ({ sections, stations, samples, shading, closed = false }) => {
82
+ if (brepLoft && closed) throw new Error("loftSmooth: closed:true loops are only supported on the Manifold backend");
83
+ return brepLoft
84
+ ? k.loft({ rings: smoothLoftRings(sections, { stations: "controls", samples }), ruled: false })
85
+ : k.loft({ rings: smoothLoftRings(sections, { stations, samples, closed }), ...(shading ? { shading } : {}), closed });
86
+ };
87
+
58
88
  for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
59
89
  const raw = k[op];
60
90
  if (!raw) continue;
@@ -23,6 +23,8 @@ export const KERNEL_OPS = [
23
23
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
24
24
  "loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
25
25
  "roundedCylinder", "torus", "roundedBox", "import",
26
+ // Additive in 0.84 (no CONTRACT_VERSION bump — the import-op precedent).
27
+ "loftSmooth",
26
28
  ];
27
29
 
28
30
  // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
@@ -147,6 +149,7 @@ export const ROUTED_CAD_OPS = ["shell"];
147
149
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
148
150
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
149
151
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
152
+ * @property {(o:{sections:object[],stations?:number,samples?:number,shading?:string,closed?:boolean}) => Solid} loftSmooth Catmull-Rom-densified loft of sparse control sections; options-only
150
153
  * @property {(solids:Solid[]) => Solid} union
151
154
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
152
155
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
@@ -0,0 +1,422 @@
1
+ // The k.loftSmooth densifier (see the loftSmooth row in docs/KERNEL-CONTRACT.md and
2
+ // docs/superpowers/specs/2026-08-24-loft-smooth-design.md).
3
+ // Shared spline densifier behind k.loftSmooth: sparse control sections in, a dense
4
+ // ring list for k.loft out. Pure JS and backend-free, so both backends receive the
5
+ // IDENTICAL densified station list — parity by construction, the sweep/screwSweep
6
+ // precedent — rather than each backend interpolating its own surface.
7
+ //
8
+ // Interpolation is Catmull-Rom both ways:
9
+ // • around each ring — centripetal (α=0.5) through the section's control points,
10
+ // closed/periodic, resampled to a shared vertex count uniformly by arc length
11
+ // (centripetal because airfoil-style sections cluster points unevenly, and
12
+ // uniform CR overshoots on uneven chords);
13
+ // • across stations — through each vertex index's control polyline, with ONE
14
+ // shared knot vector taken from the centroid spine (centroid + z chord length).
15
+ // Shared knots mean every vertex's z blend is identical, so output rings stay
16
+ // planar — which the k.loft ring format requires.
17
+ // End stations are clamped with reflection phantoms, so the surface interpolates
18
+ // the first and last control sections exactly.
19
+ //
20
+ // Output rings are now all-cubic contour IR ({start, segments:[{to,c1,c2}…]}),
21
+ // not point arrays: each ring's smooth CR outline is fitted back to one exact
22
+ // cubic Bézier span per vertex (fitBezierRing — a CR span IS a cubic, so the
23
+ // 4-point inversion has no approximation error), explicitly closed. k.loft's
24
+ // curve mode then lofts them as exact wires on OCCT and matched per-segment
25
+ // samples on Manifold.
26
+ import { isArcContour, sampleArc, sampleBezier, closeContourGap } from "./profile.js";
27
+ import { profileCorners } from "./contour-ops.js";
28
+ import { LOFT_SEGS } from "./loft-rings.js";
29
+
30
+ // Tessellate a path contour tracking each segment joint's output index
31
+ // (vertex i = start of segment i; jointIdx[i] is its position in pts).
32
+ function tessellateWithJoints(contour, segs) {
33
+ const pts = [[contour.start[0], contour.start[1]]];
34
+ const jointIdx = [0];
35
+ let prev = contour.start;
36
+ for (const seg of contour.segments) {
37
+ if (seg.c1) for (const p of sampleBezier(prev, seg.c1, seg.c2, seg.to, segs)) pts.push(p);
38
+ else if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) pts.push(p);
39
+ else pts.push([seg.to[0], seg.to[1]]);
40
+ jointIdx.push(pts.length - 1);
41
+ prev = seg.to;
42
+ }
43
+ pts.pop(); // explicit closure lands on start — drop the duplicate for a closed ring
44
+ jointIdx.pop(); // and the wrap entry with it
45
+ return { pts, jointIdx };
46
+ }
47
+
48
+ // Mirror of loft's ring spec resolution (loft.js resolveRings), minus the
49
+ // equal-vertex-count rule — the whole point here is that control sections may
50
+ // disagree; the resampler reconciles them. Point sections may carry sharp
51
+ // corner tags; curve contours/Shape2D sections carry corners implicitly
52
+ // (their line/curve joints).
53
+ function resolveSections(sections) {
54
+ if (!Array.isArray(sections) || sections.length < 2)
55
+ throw new Error("loftSmooth: sections must be an array of at least 2 control sections");
56
+ return sections.map((s, i) => {
57
+ if (!s || typeof s !== "object") throw new Error(`loftSmooth: section ${i} must be an object { polygon|sides+radius, z }`);
58
+ if (!Number.isFinite(s.z)) throw new Error(`loftSmooth: section ${i} needs a finite z`);
59
+ let pts = s.polygon;
60
+ let corners;
61
+ if (pts && pts._shape2d) {
62
+ const regions = pts._regions;
63
+ if (regions.length === 0) throw new Error(`loftSmooth: section ${i} is an empty Shape2D — nothing to loft`);
64
+ if (regions.length > 1) throw new Error(
65
+ `loftSmooth: section ${i} is a Shape2D with ${regions.length} regions — a loft section must be a single closed outline (union the regions into one, or loft each separately)`);
66
+ if (regions[0].holes.length > 0) throw new Error(
67
+ `loftSmooth: section ${i} has holes — loft sections must be hole-free outlines (cut the holes from the lofted solid instead)`);
68
+ pts = JSON.parse(JSON.stringify(regions[0].outer));
69
+ }
70
+ if (isArcContour(pts)) {
71
+ if (s.sharp != null)
72
+ throw new Error(`loftSmooth: section ${i} is a curve contour — its corners are implicit; sharp is only for point sections`);
73
+ const contour = closeContourGap(pts);
74
+ const t = tessellateWithJoints(contour, LOFT_SEGS);
75
+ corners = profileCorners(contour).map((c) => t.jointIdx[c.index]).sort((a, b) => a - b);
76
+ pts = t.pts;
77
+ } else {
78
+ if (!pts && Number.isFinite(s.sides) && Number.isFinite(s.radius)) {
79
+ pts = [];
80
+ for (let j = 0; j < s.sides; j++) {
81
+ const a = (j / s.sides) * 2 * Math.PI;
82
+ pts.push([Math.cos(a) * s.radius, Math.sin(a) * s.radius]);
83
+ }
84
+ }
85
+ if (!Array.isArray(pts) || pts.length < 3)
86
+ throw new Error(`loftSmooth: section ${i} needs polygon:[[x,y],…] (≥3 points), a curve contour, a Shape2D, or sides+radius shorthand`);
87
+ if (s.sharp != null) {
88
+ if (!Array.isArray(s.sharp) || s.sharp.some((x) => !Number.isInteger(x) || x < 0 || x >= pts.length))
89
+ throw new Error(`loftSmooth: section ${i} sharp indices must be integers in 0…${pts.length - 1}`);
90
+ corners = [...new Set(s.sharp)].sort((a, b) => a - b);
91
+ } else corners = [];
92
+ }
93
+ const sc = s.scale ?? 1;
94
+ const [sx, sy] = Array.isArray(sc) ? sc : [sc, sc];
95
+ const rot = ((s.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
96
+ let pts2d = pts.map(([x, y]) => {
97
+ const X = x * sx, Y = y * sy;
98
+ return [X * cos - Y * sin, X * sin + Y * cos];
99
+ });
100
+ // Corner 0 anchors the seam (spec §2): rotate the ring so it leads at vertex 0.
101
+ if (corners.length && corners[0] !== 0) {
102
+ const shift = corners[0];
103
+ pts2d = [...pts2d.slice(shift), ...pts2d.slice(0, shift)];
104
+ corners = corners.map((c) => c - shift);
105
+ }
106
+ return { pts2d, corners, z: s.z };
107
+ });
108
+ }
109
+
110
+ // Barry–Goldman pyramid for one Catmull-Rom segment: evaluates the curve through
111
+ // p1..p2 at knot value t ∈ [t1, t2], for arbitrary (e.g. centripetal) knots.
112
+ function crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t) {
113
+ const lerpP = (a, b, ta, tb) => {
114
+ const w = tb === ta ? 0 : (t - ta) / (tb - ta);
115
+ return [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w];
116
+ };
117
+ const a1 = lerpP(p0, p1, t0, t1), a2 = lerpP(p1, p2, t1, t2), a3 = lerpP(p2, p3, t2, t3);
118
+ const b1 = lerpP(a1, a2, t0, t2), b2 = lerpP(a2, a3, t1, t3);
119
+ return lerpP(b1, b2, t1, t2);
120
+ }
121
+
122
+ const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
123
+
124
+ // Closed centripetal Catmull-Rom through `pts`, resampled to `n` points uniformly
125
+ // by arc length (measured on a dense polyline — SUB samples per control segment).
126
+ const SUB = 8;
127
+ export function resampleClosedSpline(pts, n) {
128
+ const N = pts.length;
129
+ const dense = [];
130
+ for (let i = 0; i < N; i++) {
131
+ const p0 = pts[(i - 1 + N) % N], p1 = pts[i], p2 = pts[(i + 1) % N], p3 = pts[(i + 2) % N];
132
+ // Centripetal knots (α=0.5); coincident control points get a tiny ε so the
133
+ // pyramid never divides by zero.
134
+ const t0 = 0;
135
+ const t1 = t0 + Math.max(Math.sqrt(dist(p0, p1)), 1e-6);
136
+ const t2 = t1 + Math.max(Math.sqrt(dist(p1, p2)), 1e-6);
137
+ const t3 = t2 + Math.max(Math.sqrt(dist(p2, p3)), 1e-6);
138
+ for (let s = 0; s < SUB; s++)
139
+ dense.push(crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t1 + ((t2 - t1) * s) / SUB));
140
+ }
141
+ // Uniform-by-arc-length resample of the dense closed polyline.
142
+ const M = dense.length;
143
+ const cum = [0];
144
+ for (let i = 1; i <= M; i++) cum.push(cum[i - 1] + dist(dense[i - 1], dense[i % M]));
145
+ const total = cum[M];
146
+ if (!(total > 0)) throw new Error("loftSmooth: a control section has zero perimeter");
147
+ const out = [];
148
+ let seg = 0;
149
+ for (let j = 0; j < n; j++) {
150
+ const target = (j / n) * total;
151
+ while (cum[seg + 1] < target) seg++;
152
+ const a = dense[seg], b = dense[(seg + 1) % M];
153
+ const w = (target - cum[seg]) / (cum[seg + 1] - cum[seg] || 1);
154
+ out.push([a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w]);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ const reflectPt = (p, q) => [2 * p[0] - q[0], 2 * p[1] - q[1]];
160
+
161
+ // Dense polyline of the clamped open centripetal CR through `pts` (reflection
162
+ // phantoms at both ends), endpoints exact. Shared by resampleOpenArc and the
163
+ // arc-length weights in reconcile().
164
+ function openArcDense(pts) {
165
+ const A = pts.length;
166
+ const ctrl = [reflectPt(pts[0], pts[1]), ...pts, reflectPt(pts[A - 1], pts[A - 2])];
167
+ const dense = [];
168
+ for (let i = 1; i < A; i++) {
169
+ const p0 = ctrl[i - 1], p1 = ctrl[i], p2 = ctrl[i + 1], p3 = ctrl[i + 2];
170
+ const t0 = 0;
171
+ const t1 = t0 + Math.max(Math.sqrt(dist(p0, p1)), 1e-6);
172
+ const t2 = t1 + Math.max(Math.sqrt(dist(p1, p2)), 1e-6);
173
+ const t3 = t2 + Math.max(Math.sqrt(dist(p2, p3)), 1e-6);
174
+ for (let s = 0; s < SUB; s++)
175
+ dense.push(crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t1 + ((t2 - t1) * s) / SUB));
176
+ }
177
+ dense.push([pts[A - 1][0], pts[A - 1][1]]);
178
+ return dense;
179
+ }
180
+
181
+ const polyLen = (poly) => {
182
+ let L = 0;
183
+ for (let i = 1; i < poly.length; i++) L += dist(poly[i - 1], poly[i]);
184
+ return L;
185
+ };
186
+
187
+ // Clamped open CR through `pts`, resampled uniformly by arc length to spans+1
188
+ // points; both endpoints are interpolated exactly.
189
+ export function resampleOpenArc(pts, spans) {
190
+ const dense = openArcDense(pts);
191
+ const M = dense.length - 1;
192
+ const cum = [0];
193
+ for (let i = 1; i <= M; i++) cum.push(cum[i - 1] + dist(dense[i - 1], dense[i]));
194
+ const total = cum[M];
195
+ if (!(total > 0)) throw new Error("loftSmooth: a control section has zero perimeter");
196
+ const out = [];
197
+ let seg = 0;
198
+ for (let j = 0; j <= spans; j++) {
199
+ const target = (j / spans) * total;
200
+ while (seg < M - 1 && cum[seg + 1] < target) seg++;
201
+ const a = dense[seg], b = dense[seg + 1];
202
+ const w = (target - cum[seg]) / (cum[seg + 1] - cum[seg] || 1);
203
+ out.push([a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w]);
204
+ }
205
+ out[0] = [pts[0][0], pts[0][1]];
206
+ out[spans] = [pts[pts.length - 1][0], pts[pts.length - 1][1]];
207
+ return out;
208
+ }
209
+
210
+ // Exact 4-point inversion: a CR span is a cubic, so sampling it at u = 0, 1/3,
211
+ // 2/3, 1 and inverting the Bernstein matrix reproduces it with no approximation
212
+ // (spec §3). Endpoints are written from the actual ring vertices so the contour
213
+ // interpolates them bit-exactly.
214
+ function invertSpan(S0, S1, S2, S3) {
215
+ return {
216
+ to: [S3[0], S3[1]],
217
+ c1: [(-5 * S0[0] + 18 * S1[0] - 9 * S2[0] + 2 * S3[0]) / 6, (-5 * S0[1] + 18 * S1[1] - 9 * S2[1] + 2 * S3[1]) / 6],
218
+ c2: [(2 * S0[0] - 9 * S1[0] + 18 * S2[0] - 5 * S3[0]) / 6, (2 * S0[1] - 9 * S1[1] + 18 * S2[1] - 5 * S3[1]) / 6],
219
+ };
220
+ }
221
+
222
+ // Fit the ring's smooth outline (closed periodic CR, or corner-clamped open CR
223
+ // arcs) back to an all-cubic contour IR — one segment per vertex, explicitly
224
+ // closed. `corners` are sorted output vertex indices with corner 0 at index 0.
225
+ export function fitBezierRing(pts, corners = []) {
226
+ const V = pts.length;
227
+ const segments = [];
228
+ const emitSpan = (p0, p1, p2, p3) => {
229
+ const t0 = 0;
230
+ const t1 = t0 + Math.max(Math.sqrt(dist(p0, p1)), 1e-6);
231
+ const t2 = t1 + Math.max(Math.sqrt(dist(p1, p2)), 1e-6);
232
+ const t3 = t2 + Math.max(Math.sqrt(dist(p2, p3)), 1e-6);
233
+ const at = (u) => crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t1 + (t2 - t1) * u);
234
+ segments.push(invertSpan([p1[0], p1[1]], at(1 / 3), at(2 / 3), [p2[0], p2[1]]));
235
+ };
236
+ if (corners.length === 0) {
237
+ for (let i = 0; i < V; i++)
238
+ emitSpan(pts[(i - 1 + V) % V], pts[i], pts[(i + 1) % V], pts[(i + 2) % V]);
239
+ } else {
240
+ for (let j = 0; j < corners.length; j++) {
241
+ const arc = arcPoints(pts, corners, j);
242
+ const A = arc.length;
243
+ const ctrl = [reflectPt(arc[0], arc[1]), ...arc, reflectPt(arc[A - 1], arc[A - 2])];
244
+ for (let i = 1; i < A; i++) emitSpan(ctrl[i - 1], ctrl[i], ctrl[i + 1], ctrl[i + 2]);
245
+ }
246
+ }
247
+ segments[segments.length - 1].to = [pts[0][0], pts[0][1]]; // exact explicit closure
248
+ return { start: [pts[0][0], pts[0][1]], segments };
249
+ }
250
+
251
+ // Cyclic slice of a ring from corner j to corner j+1, both endpoints included.
252
+ function arcPoints(pts, corners, j) {
253
+ const N = pts.length, m = corners.length;
254
+ const a = corners[j], b = corners[(j + 1) % m];
255
+ const out = [];
256
+ for (let k = a; ; k = (k + 1) % N) {
257
+ out.push(pts[k]);
258
+ if (k === b && out.length > 1) break;
259
+ }
260
+ return out;
261
+ }
262
+
263
+ // Reconcile all sections to a shared vertex count: samples spans apportioned
264
+ // among the m corner-delimited arcs by mean arc-length fraction (largest
265
+ // remainder, ties to the lower arc index, minimum 1 span per arc — spec §2).
266
+ function reconcile(resolved, V) {
267
+ const m = resolved[0].corners.length;
268
+ for (let i = 1; i < resolved.length; i++)
269
+ if (resolved[i].corners.length !== m)
270
+ throw new Error(
271
+ `loftSmooth: every section must have the same corner count — section ${i} has ${resolved[i].corners.length}, section 0 has ${m}`);
272
+ if (m === 0)
273
+ return { rings: resolved.map((r) => resampleClosedSpline(r.pts2d, V)), corners: [] };
274
+ const V2 = Math.max(V, m);
275
+ const arcs = resolved.map((r) => Array.from({ length: m }, (_, j) => arcPoints(r.pts2d, r.corners, j)));
276
+ const fracs = Array.from({ length: m }, () => 0);
277
+ for (const sectionArcs of arcs) {
278
+ const lens = sectionArcs.map((a) => polyLen(openArcDense(a)));
279
+ const perim = lens.reduce((a, b) => a + b, 0);
280
+ for (let j = 0; j < m; j++) fracs[j] += lens[j] / perim;
281
+ }
282
+ for (let j = 0; j < m; j++) fracs[j] /= resolved.length;
283
+ const extra = V2 - m;
284
+ const exact = fracs.map((f) => extra * f);
285
+ const alloc = exact.map(Math.floor);
286
+ let left = extra - alloc.reduce((a, b) => a + b, 0);
287
+ const order = exact.map((e, j) => [e - alloc[j], j]).sort((p, q) => q[0] - p[0] || p[1] - q[1]);
288
+ for (let j = 0; j < left; j++) alloc[order[j][1]]++;
289
+ const spans = alloc.map((a) => a + 1);
290
+ const rings = arcs.map((sectionArcs) =>
291
+ sectionArcs.flatMap((arcPts2, j) => resampleOpenArc(arcPts2, spans[j]).slice(0, -1)));
292
+ const corners = [];
293
+ let acc = 0;
294
+ for (let j = 0; j < m; j++) { corners.push(acc); acc += spans[j]; }
295
+ return { rings, corners };
296
+ }
297
+
298
+ /**
299
+ * Densify sparse control sections into a ring list for k.loft.
300
+ * @param {Array} sections loft-style ring specs ({polygon|sides+radius, z, rotate?, scale?});
301
+ * vertex counts may differ between sections. Point arrays may
302
+ * carry sharp:[indices]; curve contours/Shape2D sections carry
303
+ * corners implicitly.
304
+ * @param {{stations?: number|"controls", samples?: number, closed?: boolean}} opts
305
+ * stations — output ring count along the spine (default 8 per span + 1 open,
306
+ * 8 per section closed; ≥ 2; raised to the section count when lower; every
307
+ * control knot is always emitted). The string "controls" skips cross-station
308
+ * interpolation entirely and emits one ring per control section at its own z
309
+ * — the B-rep path, where the backend's native smooth loft (`ruled: false`)
310
+ * does the skinning through exact wires and only the around-ring
311
+ * reconciliation is needed; incompatible with closed:true;
312
+ * samples — output vertex count around each ring (default max(64, largest section));
313
+ * closed — periodic spine: the spline wraps from the last control section back
314
+ * to the first (no duplicate ring at the wrap point), needs ≥ 3 sections.
315
+ * @returns {Array<{polygon: {start, segments}, z: number}>}
316
+ */
317
+ export function smoothLoftRings(sections, { stations, samples, closed = false } = {}) {
318
+ const resolved = resolveSections(sections);
319
+ const n = resolved.length;
320
+ if (closed && stations === "controls")
321
+ throw new Error('loftSmooth: closed:true cannot combine with stations:"controls"');
322
+ if (closed && n < 3) throw new Error("loftSmooth: closed:true needs at least 3 control sections");
323
+ const S = stations ?? (closed ? n * 8 : (n - 1) * 8 + 1);
324
+ const V = samples ?? Math.max(64, ...resolved.map((r) => r.pts2d.length));
325
+ if (stations !== "controls" && !(Number.isFinite(S) && S >= 2 && S <= 1024))
326
+ throw new Error('loftSmooth: stations must be 2…1024 (or "controls")');
327
+ if (!(Number.isFinite(V) && V >= 8 && V <= 2048)) throw new Error("loftSmooth: samples must be 8…2048");
328
+
329
+ // 1. Reconcile every section to a shared vertex count on its smooth closed
330
+ // outline — around each corner-delimited arc when corners are present,
331
+ // or the whole closed spline (v1-identical) when m = 0.
332
+ const { rings, corners } = reconcile(resolved, V);
333
+ const VOut = rings[0].length; // V raised to the corner count when larger
334
+ if (stations === "controls")
335
+ return resolved.map((r, i) => ({ polygon: fitBezierRing(rings[i], corners), z: r.z }));
336
+
337
+ // 2. Shared across-station knots from the centroid spine (chord length in
338
+ // centroid-xy + z space). Shared knots ⇒ planar output rings (see header).
339
+ const spine = resolved.map((r, i) => {
340
+ let cx = 0, cy = 0;
341
+ for (const [x, y] of rings[i]) { cx += x; cy += y; }
342
+ return [cx / VOut, cy / VOut, r.z];
343
+ });
344
+ const knots = [0];
345
+ for (let i = 1; i < n; i++)
346
+ knots.push(knots[i - 1] + Math.max(Math.hypot(
347
+ spine[i][0] - spine[i - 1][0], spine[i][1] - spine[i - 1][1], spine[i][2] - spine[i - 1][2]), 1e-6));
348
+ if (closed)
349
+ knots.push(knots[n - 1] + Math.max(Math.hypot(
350
+ spine[0][0] - spine[n - 1][0], spine[0][1] - spine[n - 1][1], spine[0][2] - spine[n - 1][2]), 1e-6));
351
+
352
+ // Reflection phantoms clamp the ends (open mode): the curve passes through
353
+ // ring 0 and ring n−1 exactly, with a natural-looking end tangent. Closed mode
354
+ // instead wraps every accessor modulo n — no phantoms, no clamping.
355
+ const reflect = (a, b) => [2 * a[0] - b[0], 2 * a[1] - b[1]];
356
+ const wrap = (i) => ((i % n) + n) % n;
357
+ const knotAt = (i) => { // phantom/periodic knots mirror the end spacing
358
+ if (closed) {
359
+ if (i < 0) return knots[0] - (knots[n] - knots[n - 1]);
360
+ if (i > n) return knots[n] + (knots[1] - knots[0]);
361
+ return knots[i];
362
+ }
363
+ if (i < 0) return knots[0] - (knots[1] - knots[0]);
364
+ if (i >= n) return knots[n - 1] + (knots[n - 1] - knots[n - 2]);
365
+ return knots[i];
366
+ };
367
+ const ptAt = (j, i) => {
368
+ if (closed) return rings[wrap(i)][j];
369
+ if (i < 0) return reflect(rings[0][j], rings[1][j]);
370
+ if (i >= n) return reflect(rings[n - 1][j], rings[n - 2][j]);
371
+ return rings[i][j];
372
+ };
373
+ const zCtrl = (i) => {
374
+ if (closed) return resolved[wrap(i)].z;
375
+ if (i < 0) return 2 * resolved[0].z - resolved[1].z;
376
+ if (i >= n) return 2 * resolved[n - 1].z - resolved[n - 2].z;
377
+ return resolved[i].z;
378
+ };
379
+
380
+ // 3. Station parameter list: every control knot is always emitted, plus interior
381
+ // stations distributed per span proportionally to knot length (largest-
382
+ // remainder apportionment; ties to the lower index — deterministic), so each
383
+ // control section appears as an actual output ring, not just a point the
384
+ // underlying spline passes through. `stations` below the section count is
385
+ // raised to it (the knots alone already cost n rings). Closed mode has one
386
+ // more span than open mode (the wrap chord back to control 0) and never
387
+ // re-emits the final knot — it would duplicate ring 0.
388
+ const spanCount = closed ? n : n - 1;
389
+ const tEnd = knots[closed ? n : n - 1];
390
+ const S2 = Math.max(S, n);
391
+ const extra = S2 - n;
392
+ const spans = [];
393
+ for (let i = 0; i < spanCount; i++) spans.push(knots[i + 1] - knots[i]);
394
+ const exact = spans.map((len) => (extra * len) / tEnd);
395
+ const alloc = exact.map(Math.floor);
396
+ let left = extra - alloc.reduce((a, b) => a + b, 0);
397
+ const order = exact.map((e, i) => [e - alloc[i], i]).sort((p, q) => q[0] - p[0] || p[1] - q[1]);
398
+ for (let j = 0; j < left; j++) alloc[order[j][1]]++;
399
+ const ts = [];
400
+ for (let i = 0; i < spanCount; i++) {
401
+ ts.push(knots[i]);
402
+ for (let m = 1; m <= alloc[i]; m++) ts.push(knots[i] + (spans[i] * m) / (alloc[i] + 1));
403
+ }
404
+ if (!closed) ts.push(tEnd);
405
+
406
+ // 4. Evaluate the stations. z uses the same segment/knots as every vertex,
407
+ // evaluated once per station (1-D Barry–Goldman via crPoint).
408
+ const out = [];
409
+ for (const t of ts) {
410
+ let seg = 0; // segment index: t ∈ [knots[seg], knots[seg+1]]
411
+ while (seg < (closed ? n - 1 : n - 2) && t > knots[seg + 1]) seg++;
412
+ const t0 = knotAt(seg - 1), t1 = knots[seg], t2 = knotAt(seg + 1), t3 = knotAt(seg + 2);
413
+ const z1d = (a, b, c, d) =>
414
+ crPoint([a, 0], [b, 0], [c, 0], [d, 0], t0, t1, t2, t3, t)[0];
415
+ const z = z1d(zCtrl(seg - 1), zCtrl(seg), zCtrl(seg + 1), zCtrl(seg + 2));
416
+ const polygon = [];
417
+ for (let j = 0; j < VOut; j++)
418
+ polygon.push(crPoint(ptAt(j, seg - 1), ptAt(j, seg), ptAt(j, seg + 1), ptAt(j, seg + 2), t0, t1, t2, t3, t));
419
+ out.push({ polygon: fitBezierRing(polygon, corners), z });
420
+ }
421
+ return out;
422
+ }
@@ -279,6 +279,8 @@ export const KERNEL_OP_SPECS = {
279
279
  if (!(o.turns > 0)) throw new Error("screwSweep: turns must be > 0");
280
280
  },
281
281
  },
282
+ // loftSmooth: range checks live in loft-smooth.js, next to the defaults they guard.
283
+ loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading", "closed"], ["sections"]) },
282
284
  roundedBox: { toArgs: roundedBoxArgs },
283
285
  roundedCylinder: { toArgs: roundedCylinderArgs },
284
286
  torus: { toArgs: torusArgs },
@@ -0,0 +1,128 @@
1
+ // The k.loftSmooth reference part: a boat propeller — bored hub + N airfoil
2
+ // blades, each blade a spline-interpolated loft of 5 sparse control sections.
3
+ // The "Surface" section keeps the didactic A/B: untick **Smooth** to see the raw
4
+ // k.loft of the same control sections. Specs:
5
+ // docs/superpowers/specs/2026-08-24-loft-smooth-design.md
6
+ // docs/superpowers/specs/2026-08-25-loft-smooth-v2-design.md
7
+
8
+ // NACA-4-ish airfoil contour, closed and CCW, centered near the quarter chord so
9
+ // per-ring `rotate` twists about a sensible pitch axis. `n` points per surface;
10
+ // cosine spacing clusters points at the leading edge, which is exactly the uneven
11
+ // spacing the centripetal densifier is supposed to handle.
12
+ const airfoil = (chord, thickPct, camberPct, n) => {
13
+ const t = thickPct / 100, m = camberPct / 100, p = 0.4;
14
+ const yt = (x) => 5 * t * (0.2969 * Math.sqrt(x) - 0.126 * x - 0.3516 * x * x + 0.2843 * x ** 3 - 0.1036 * x ** 4);
15
+ const yc = (x) => (x < p ? (m / (p * p)) * (2 * p * x - x * x) : (m / ((1 - p) ** 2)) * (1 - 2 * p + 2 * p * x - x * x));
16
+ const upper = [], lower = [];
17
+ for (let i = 0; i <= n; i++) {
18
+ const x = (1 - Math.cos((i / n) * Math.PI)) / 2; // cosine spacing, LE→TE
19
+ upper.push([x, yc(x) + yt(x)]);
20
+ lower.push([x, yc(x) - yt(x)]);
21
+ }
22
+ // TE→LE along the top, LE→TE along the bottom; drop duplicated LE/TE points.
23
+ const pts = [...upper.reverse().slice(0, -1), ...lower.slice(1)];
24
+ return pts.map(([x, y]) => [(x - 0.3) * chord, y * chord]);
25
+ };
26
+
27
+ // Five control stations up the span: a propeller-y chord outline (widest mid-span,
28
+ // closing toward a rounded tip) and a root→tip pitch-angle washout.
29
+ const SPAN_T = [0, 0.3, 0.6, 0.85, 1];
30
+ const CHORD_MUL = [1, 1.12, 1.0, 0.72, 0.28];
31
+ const bladeSections = (p) =>
32
+ SPAN_T.map((t, i) => ({
33
+ polygon: airfoil(
34
+ (p.rootChord + (p.tipChord - p.rootChord) * t) * CHORD_MUL[i],
35
+ p.thickness * (1 - 0.45 * t), // blades thin toward the tip
36
+ p.camber,
37
+ p.sectionPts,
38
+ ),
39
+ // The trailing edge is the ring's two end vertices (upper TE vertex 0, lower
40
+ // TE the last) — this NACA closure (coefficient -0.1036) already brings them
41
+ // together at the same point, so the "gap" between them is a genuine zero-
42
+ // length edge, not a blunt base. Tagging vertex 0 as the single corner keeps
43
+ // that meeting point a crease instead of letting the CR spline round it off;
44
+ // a *second* tag at the last vertex would mark a zero-length arc between two
45
+ // coincident corners, which — after the per-section pitch `rotate` below
46
+ // collapses their sub-epsilon separation to bit-identical floats — the
47
+ // resampler rejects as a zero-perimeter section.
48
+ ...(p.sharpTE && p.smooth ? { sharp: [0] } : {}),
49
+ z: p.span * t,
50
+ rotate: p.twistRoot + (p.twistTip - p.twistRoot) * t,
51
+ }));
52
+
53
+ export default {
54
+ meta: { title: "Propeller", units: "mm", background: 0x15181d },
55
+ parameters: [
56
+ {
57
+ id: "prop",
58
+ title: "Propeller",
59
+ description: "A boat propeller: bored hub + airfoil blades. The blade is the organic-surface exerciser — every surface is a `loftSmooth` of 5 sparse control sections.",
60
+ controls: [
61
+ { key: "blades", label: "Blades", min: 2, max: 6, step: 1 },
62
+ { key: "span", label: "Blade span", unit: "mm", min: 30, max: 120, step: 1 },
63
+ { key: "rootChord", label: "Root chord", unit: "mm", min: 10, max: 50, step: 1 },
64
+ { key: "tipChord", label: "Tip chord", unit: "mm", min: 6, max: 40, step: 1 },
65
+ { key: "twistRoot", label: "Root pitch", unit: "°", min: 0, max: 80, step: 1,
66
+ description: "Blade angle at the root. 0° puts the chord in the rotation plane." },
67
+ { key: "twistTip", label: "Tip pitch", unit: "°", min: 0, max: 80, step: 1 },
68
+ { key: "thickness", label: "Thickness", unit: "%", min: 4, max: 25, step: 1,
69
+ description: "Airfoil thickness as % of chord, at the root." },
70
+ { key: "camber", label: "Camber", unit: "%", min: 0, max: 12, step: 1 },
71
+ { type: "group", title: "Hub", collapsed: "auto", controls: [
72
+ { key: "hubD", label: "Hub diameter", unit: "mm", min: 14, max: 60, step: 1 },
73
+ { key: "hubH", label: "Hub length", unit: "mm", min: 10, max: 60, step: 1 },
74
+ { key: "boreD", label: "Shaft bore", unit: "mm", min: 2, max: 20, step: 0.5 },
75
+ ] },
76
+ ],
77
+ },
78
+ {
79
+ id: "surface",
80
+ title: "Surface",
81
+ description: "**Smooth** interpolates the 5 sparse control sections with `loftSmooth`; off shows the raw `k.loft` of the same sections. **Sharp trailing edge** tags the TE vertex as a crease instead of letting the spline smear it. **Stations/Samples** are the densifier resolution; **Section points** is how sparse the control sections are.",
82
+ controls: [
83
+ { key: "smooth", type: "checkbox", label: "Smooth (loftSmooth)",
84
+ description: "A/B toggle: spline-densified vs raw loft of identical control sections." },
85
+ { key: "sharpTE", type: "checkbox", label: "Sharp trailing edge", when: { smooth: 1 },
86
+ description: "Tags the trailing-edge vertex as a true corner — the spline interpolates it with a crease instead of smearing it round." },
87
+ { key: "stations", label: "Stations", min: 5, max: 128, step: 1, when: { smooth: 1 } },
88
+ { key: "samples", label: "Samples / ring", min: 16, max: 512, step: 4, when: { smooth: 1 } },
89
+ { key: "sectionPts", label: "Section points", min: 6, max: 40, step: 1,
90
+ description: "Points per airfoil *surface* in each control section — the sparse input both paths share." },
91
+ ],
92
+ },
93
+ ],
94
+ defaults: {
95
+ blades: 3, span: 70, rootChord: 26, tipChord: 16, twistRoot: 62, twistTip: 30,
96
+ thickness: 12, camber: 6, hubD: 30, hubH: 26, boreD: 8,
97
+ smooth: 1, sharpTE: 1, stations: 48, samples: 128, sectionPts: 12,
98
+ },
99
+ parts: {
100
+ propeller: {
101
+ label: "Propeller", views: ["propeller"], export: { name: "propeller" },
102
+ build: (k, p) => {
103
+ const sections = bladeSections(p);
104
+ const bladeUp = p.smooth
105
+ ? k.loftSmooth({ sections, stations: p.stations, samples: p.samples })
106
+ : k.loft({ rings: sections });
107
+ // Built span-up (+Z); lay it radial along +X — the airfoil chord then sits
108
+ // in the axis/rotation-plane frame, so `rotate` above reads as pitch angle.
109
+ // Root sinks to 62% of hub radius so the union has generous overlap.
110
+ const blade = bladeUp.rotateY(90).translate([p.hubD * 0.31, 0, 0]).label("Blade");
111
+ const blades = [];
112
+ for (let i = 0; i < p.blades; i++) blades.push(blade.clone().rotateZ((360 / p.blades) * i));
113
+ const hub = k.cylinder({ r: p.hubD / 2, h: p.hubH })
114
+ .translate([0, 0, -p.hubH / 2]).label("Hub")
115
+ .cut(k.cylinder({ r: p.boreD / 2, h: p.hubH + 4 }).translate([0, 0, -p.hubH / 2 - 2]).label("Bore"));
116
+ return k.union([hub, ...blades]);
117
+ },
118
+ },
119
+ },
120
+ views: { propeller: { label: "Propeller" } },
121
+ verify: {
122
+ expect: {
123
+ // One through-hole (the shaft bore); everything unioned into one watertight body.
124
+ propeller: { holes: 1, bbox: "<=[300,300,300]" },
125
+ _view: { overlaps: 0 },
126
+ },
127
+ },
128
+ };
@@ -0,0 +1,4 @@
1
+ // Glue for the loftSmooth propeller reference part (see parts/propeller.js).
2
+ import part from "./parts/propeller.js";
3
+ import { runWorker } from "./framework/worker.js";
4
+ runWorker(part);
package/types/kernel.d.ts CHANGED
@@ -380,6 +380,33 @@ export interface ScrewSweepOptions {
380
380
  lefthand?: boolean;
381
381
  }
382
382
 
383
+ /** One `k.loftSmooth` control section. Point arrays may tag true corners with
384
+ * `sharp`; curve contours and Shape2D outlines carry corners implicitly. */
385
+ export interface LoftSmoothSection {
386
+ polygon?: Contour | Shape2D;
387
+ sides?: number;
388
+ radius?: number;
389
+ z: number;
390
+ /** Degrees about Z. */
391
+ rotate?: number;
392
+ scale?: number | Point2;
393
+ /** Corner indices into a point-array polygon. */
394
+ sharp?: number[];
395
+ }
396
+
397
+ /** k.loftSmooth — spline-interpolated loft of sparse control sections. */
398
+ export interface LoftSmoothOptions {
399
+ /** Sparse control sections; vertex counts may differ between sections. */
400
+ sections: LoftSmoothSection[];
401
+ /** Output ring count along the spine (default 8 per span + 1; closed: 8 per section). */
402
+ stations?: number;
403
+ /** Output vertex count around each ring (default max(64, largest section)). */
404
+ samples?: number;
405
+ shading?: "smooth" | "faceted";
406
+ /** Capless loop — Manifold only, ≥3 sections. */
407
+ closed?: boolean;
408
+ }
409
+
383
410
  export interface RoundedCylinderOptions {
384
411
  r?: number;
385
412
  d?: number;
@@ -460,6 +487,8 @@ export interface GeometryKernel {
460
487
  helixSweptTube(o: HelixSweptTubeOptions): Solid;
461
488
  /** Sweep an axial lathe profile by screw motion — threads. */
462
489
  screwSweep(o: ScrewSweepOptions): Solid;
490
+ /** Spline-interpolated loft of sparse control sections. */
491
+ loftSmooth(o: LoftSmoothOptions): Solid;
463
492
  /** Rim round-overs via one lathe revolve; curve-exact in STEP. */
464
493
  roundedCylinder(o: RoundedCylinderOptions): Solid;
465
494
  torus(o: TorusOptions): Solid;