partforge 0.84.0 → 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,7 +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? })` | smooth organic loft: ≥2 sparse control sections (same ring spec as `k.loft`; vertex counts may differ, **point rings only**) interpolated with splines on both backends — the "here are 5 airfoil sections, make it smooth" op. The surface passes through every section exactly. Corners round at the `samples` LOD (sharp tags are future work); see the propeller reference part |
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) |
328
328
  | `k.union(solids[])` | boolean union |
329
329
 
330
330
  **`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius | {start,segments} | Shape2D, z, rotate?, scale? }`
@@ -365,8 +365,36 @@ const blade = k.loftSmooth({ sections });
365
365
  ```
366
366
 
367
367
  Raise `samples` if the cross-section shows facets, `stations` if banding runs
368
- along the spine. Sections must be point rings — vertex order and the vertex-0
369
- seam are how corresponding points line up across sections.
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).
370
398
 
371
399
  **`sweep`** takes the same CCW `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
372
400
  `path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with sharp mitered corners by
@@ -497,20 +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-sections-point-arrays
500
+ ## loftsmooth-corner-count-mismatch
501
501
 
502
- - **Symptom:** `loftSmooth: section 0 is an arc profile — control sections must be point arrays (for now)`a `loftSmooth` section was a curve contour (`roundedProfile`, `pathProfile`).
503
- - **Cause:** v1 interpolates through *points*; accepting a curve section would silently replace the authored curve with a nearby spline, so it is rejected (spec: `docs/superpowers/specs/2026-08-24-loft-smooth-design.md`, "The op").
504
- - **Fix:** pass the section as a plain `[[x,y],…]` point ring (sample the curve yourself at the density you mean), or use `k.loft` with curve rings if you want the exact curve swept without spline interpolation. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md)'s `k.loftSmooth` row.
502
+ - **Symptom:** `loftSmooth: every section must have the same corner countsection 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
505
 
506
- A `Shape2D` section is not a curve contour, so it doesn't hit this message — it instead fails validation as a non-point-array with `loftSmooth: section ${i} needs polygon:[[x,y],…] (≥3 points) or sides+radius shorthand`, which greps to this same entry.
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).
507
511
 
508
512
  ## loftsmooth-looks-faceted
509
513
 
510
514
  - **Symptom:** a `loftSmooth` solid shows flat facets around the cross-section, in preview or in STEP, even though nothing errored.
511
- - **Cause:** `samples` is the around-ring LOD on **both** backends the B-rep skin is smooth *across stations* only, so STEP is faceted around the ring at the `samples` count.
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.
512
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).
513
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
+
514
524
  ## duplicate-preset-name-throws
515
525
 
516
526
  - **Symptom:** `duplicate preset name across sections:` thrown from verify/measure, naming the repeated preset (e.g. `duplicate preset name across sections: "Compact"`).
@@ -288,7 +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?})` | Spline-interpolated loft of ≥2 sparse control sections (loft-style ring specs `{polygon\|sides+radius, z, rotate?, scale?}`; vertex counts **may differ** point rings only; curve/`Shape2D` sections throw). Compound (`kernel-front.js` + `loft-smooth.js`): a shared centripetal Catmull-Rom reconciles every section to `samples` vertices (arc-length resample **from vertex 0**authored correspondence: vertex `j` is the same material line on every section, so authored `rotate` twist sweeps instead of being re-seamed away). A mesh kernel lofts `stations` densified rings (every control knot emitted as a real ring; poly-exact path, smooth-shaded by default); a B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — the densified-wire alternative measured 23 s / WASM-abort territory. Options-only. Defaults `stations = (n−1)·8+1` (raised to the section count when lower), `samples = max(64, largest section)`; clamps 2…1024 / 8…2048. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class the backends interpolate across stations differently; ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is smooth across stations and faceted around rings at the `samples` LOD (the `extrude` `bevel` trade). |
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. |
292
292
  | `union(solids[])` | Boolean union of one or more solids. |
293
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. |
294
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.84.0",
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",
@@ -56,21 +56,34 @@ export function finishKernel(k) {
56
56
  twist: (lefthand ? -360 : 360) * turns,
57
57
  });
58
58
 
59
- // Compound default: spline-smoothed loft. On a mesh
60
- // kernel the shared Catmull-Rom densifier expands the sparse control sections in
61
- // both directions and plain loft stitches them. On a B-rep kernel that strategy
62
- // is both slow and fragile (native loft through dozens of dense wires aborted at
63
- // 48×128 in the spike), so it gets only the around-ring reconciliation and its
64
- // OWN smooth skin across stations (`ruled: false` exact B-spline surfaces, and
65
- // ~10× faster). Parity is therefore screwSweep's tolerance class, not sweep's
66
- // by-construction class: measured ~0.4% volume divergence on the propeller reference part.
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.
67
74
  // B-rep detection: `toSTEP` exists here only on a B-rep backend — the stub for
68
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".
69
80
  const brepLoft = typeof k.toSTEP === "function";
70
- k.loftSmooth ??= ({ sections, stations, samples, shading = "smooth" }) =>
71
- brepLoft
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
72
84
  ? k.loft({ rings: smoothLoftRings(sections, { stations: "controls", samples }), ruled: false })
73
- : k.loft({ rings: smoothLoftRings(sections, { stations, samples }), shading });
85
+ : k.loft({ rings: smoothLoftRings(sections, { stations, samples, closed }), ...(shading ? { shading } : {}), closed });
86
+ };
74
87
 
75
88
  for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
76
89
  const raw = k[op];
@@ -149,7 +149,7 @@ export const ROUTED_CAD_OPS = ["shell"];
149
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)
150
150
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
151
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}) => Solid} loftSmooth Catmull-Rom-densified loft of sparse control sections; 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
153
153
  * @property {(solids:Solid[]) => Solid} union
154
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
155
155
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
@@ -16,11 +16,40 @@
16
16
  // planar — which the k.loft ring format requires.
17
17
  // End stations are clamped with reflection phantoms, so the surface interpolates
18
18
  // the first and last control sections exactly.
19
- import { isArcContour } from "./profile.js";
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
+ }
20
47
 
21
48
  // Mirror of loft's ring spec resolution (loft.js resolveRings), minus the
22
49
  // equal-vertex-count rule — the whole point here is that control sections may
23
- // disagree; the resampler reconciles them.
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).
24
53
  function resolveSections(sections) {
25
54
  if (!Array.isArray(sections) || sections.length < 2)
26
55
  throw new Error("loftSmooth: sections must be an array of at least 2 control sections");
@@ -28,25 +57,53 @@ function resolveSections(sections) {
28
57
  if (!s || typeof s !== "object") throw new Error(`loftSmooth: section ${i} must be an object { polygon|sides+radius, z }`);
29
58
  if (!Number.isFinite(s.z)) throw new Error(`loftSmooth: section ${i} needs a finite z`);
30
59
  let pts = s.polygon;
31
- if (isArcContour(pts))
32
- throw new Error(`loftSmooth: section ${i} is an arc profile — control sections must be point arrays (for now)`);
33
- if (!pts && Number.isFinite(s.sides) && Number.isFinite(s.radius)) {
34
- pts = [];
35
- for (let j = 0; j < s.sides; j++) {
36
- const a = (j / s.sides) * 2 * Math.PI;
37
- pts.push([Math.cos(a) * s.radius, Math.sin(a) * s.radius]);
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
+ }
38
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 = [];
39
92
  }
40
- if (!Array.isArray(pts) || pts.length < 3)
41
- throw new Error(`loftSmooth: section ${i} needs polygon:[[x,y],…] (≥3 points) or sides+radius shorthand`);
42
93
  const sc = s.scale ?? 1;
43
94
  const [sx, sy] = Array.isArray(sc) ? sc : [sc, sc];
44
95
  const rot = ((s.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
45
- const pts2d = pts.map(([x, y]) => {
96
+ let pts2d = pts.map(([x, y]) => {
46
97
  const X = x * sx, Y = y * sy;
47
98
  return [X * cos - Y * sin, X * sin + Y * cos];
48
99
  });
49
- return { pts2d, z: s.z };
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 };
50
107
  });
51
108
  }
52
109
 
@@ -99,60 +156,222 @@ export function resampleClosedSpline(pts, n) {
99
156
  return out;
100
157
  }
101
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
+
102
298
  /**
103
299
  * Densify sparse control sections into a ring list for k.loft.
104
300
  * @param {Array} sections loft-style ring specs ({polygon|sides+radius, z, rotate?, scale?});
105
- * vertex counts may differ between sections.
106
- * @param {{stations?: number|"controls", samples?: number}} opts
107
- * stations — output ring count along the spine (default 8 per span + 1, ≥ 2;
108
- * raised to the section count when lower; every control knot is always emitted).
109
- * The string "controls" skips cross-station interpolation entirely and emits
110
- * one ring per control section at its own z the B-rep path, where the
111
- * backend's native smooth loft (`ruled: false`) does the skinning through
112
- * exact wires and only the around-ring reconciliation is needed;
113
- * samples output vertex count around each ring (default max(64, largest section)).
114
- * @returns {Array<{polygon: number[][], z: number}>}
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}>}
115
316
  */
116
- export function smoothLoftRings(sections, { stations, samples } = {}) {
317
+ export function smoothLoftRings(sections, { stations, samples, closed = false } = {}) {
117
318
  const resolved = resolveSections(sections);
118
319
  const n = resolved.length;
119
- const S = stations ?? (n - 1) * 8 + 1;
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);
120
324
  const V = samples ?? Math.max(64, ...resolved.map((r) => r.pts2d.length));
121
325
  if (stations !== "controls" && !(Number.isFinite(S) && S >= 2 && S <= 1024))
122
326
  throw new Error('loftSmooth: stations must be 2…1024 (or "controls")');
123
327
  if (!(Number.isFinite(V) && V >= 8 && V <= 2048)) throw new Error("loftSmooth: samples must be 8…2048");
124
328
 
125
- // 1. Reconcile every section to V vertices on its smooth closed outline.
126
- const rings = resolved.map((r) => resampleClosedSpline(r.pts2d, V));
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
127
334
  if (stations === "controls")
128
- return resolved.map((r, i) => ({ polygon: rings[i], z: r.z }));
335
+ return resolved.map((r, i) => ({ polygon: fitBezierRing(rings[i], corners), z: r.z }));
129
336
 
130
337
  // 2. Shared across-station knots from the centroid spine (chord length in
131
338
  // centroid-xy + z space). Shared knots ⇒ planar output rings (see header).
132
339
  const spine = resolved.map((r, i) => {
133
340
  let cx = 0, cy = 0;
134
341
  for (const [x, y] of rings[i]) { cx += x; cy += y; }
135
- return [cx / V, cy / V, r.z];
342
+ return [cx / VOut, cy / VOut, r.z];
136
343
  });
137
344
  const knots = [0];
138
345
  for (let i = 1; i < n; i++)
139
346
  knots.push(knots[i - 1] + Math.max(Math.hypot(
140
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));
141
351
 
142
- // Reflection phantoms clamp the ends: the curve passes through ring 0 and ring
143
- // n−1 exactly, with a natural-looking end tangent.
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.
144
355
  const reflect = (a, b) => [2 * a[0] - b[0], 2 * a[1] - b[1]];
145
- const knotAt = (i) => { // phantom knots mirror the end spacing
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
+ }
146
363
  if (i < 0) return knots[0] - (knots[1] - knots[0]);
147
364
  if (i >= n) return knots[n - 1] + (knots[n - 1] - knots[n - 2]);
148
365
  return knots[i];
149
366
  };
150
367
  const ptAt = (j, i) => {
368
+ if (closed) return rings[wrap(i)][j];
151
369
  if (i < 0) return reflect(rings[0][j], rings[1][j]);
152
370
  if (i >= n) return reflect(rings[n - 1][j], rings[n - 2][j]);
153
371
  return rings[i][j];
154
372
  };
155
373
  const zCtrl = (i) => {
374
+ if (closed) return resolved[wrap(i)].z;
156
375
  if (i < 0) return 2 * resolved[0].z - resolved[1].z;
157
376
  if (i >= n) return 2 * resolved[n - 1].z - resolved[n - 2].z;
158
377
  return resolved[i].z;
@@ -163,38 +382,41 @@ export function smoothLoftRings(sections, { stations, samples } = {}) {
163
382
  // remainder apportionment; ties to the lower index — deterministic), so each
164
383
  // control section appears as an actual output ring, not just a point the
165
384
  // underlying spline passes through. `stations` below the section count is
166
- // raised to it (the knots alone already cost n rings).
167
- const tEnd = knots[n - 1];
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];
168
390
  const S2 = Math.max(S, n);
169
391
  const extra = S2 - n;
170
392
  const spans = [];
171
- for (let i = 0; i < n - 1; i++) spans.push(knots[i + 1] - knots[i]);
393
+ for (let i = 0; i < spanCount; i++) spans.push(knots[i + 1] - knots[i]);
172
394
  const exact = spans.map((len) => (extra * len) / tEnd);
173
395
  const alloc = exact.map(Math.floor);
174
396
  let left = extra - alloc.reduce((a, b) => a + b, 0);
175
397
  const order = exact.map((e, i) => [e - alloc[i], i]).sort((p, q) => q[0] - p[0] || p[1] - q[1]);
176
398
  for (let j = 0; j < left; j++) alloc[order[j][1]]++;
177
399
  const ts = [];
178
- for (let i = 0; i < n - 1; i++) {
400
+ for (let i = 0; i < spanCount; i++) {
179
401
  ts.push(knots[i]);
180
402
  for (let m = 1; m <= alloc[i]; m++) ts.push(knots[i] + (spans[i] * m) / (alloc[i] + 1));
181
403
  }
182
- ts.push(tEnd);
404
+ if (!closed) ts.push(tEnd);
183
405
 
184
406
  // 4. Evaluate the stations. z uses the same segment/knots as every vertex,
185
407
  // evaluated once per station (1-D Barry–Goldman via crPoint).
186
408
  const out = [];
187
409
  for (const t of ts) {
188
410
  let seg = 0; // segment index: t ∈ [knots[seg], knots[seg+1]]
189
- while (seg < n - 2 && t > knots[seg + 1]) seg++;
190
- const t0 = knotAt(seg - 1), t1 = knots[seg], t2 = knots[seg + 1], t3 = knotAt(seg + 2);
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);
191
413
  const z1d = (a, b, c, d) =>
192
414
  crPoint([a, 0], [b, 0], [c, 0], [d, 0], t0, t1, t2, t3, t)[0];
193
415
  const z = z1d(zCtrl(seg - 1), zCtrl(seg), zCtrl(seg + 1), zCtrl(seg + 2));
194
416
  const polygon = [];
195
- for (let j = 0; j < V; j++)
417
+ for (let j = 0; j < VOut; j++)
196
418
  polygon.push(crPoint(ptAt(j, seg - 1), ptAt(j, seg), ptAt(j, seg + 1), ptAt(j, seg + 2), t0, t1, t2, t3, t));
197
- out.push({ polygon, z });
419
+ out.push({ polygon: fitBezierRing(polygon, corners), z });
198
420
  }
199
421
  return out;
200
422
  }
@@ -280,7 +280,7 @@ export const KERNEL_OP_SPECS = {
280
280
  },
281
281
  },
282
282
  // loftSmooth: range checks live in loft-smooth.js, next to the defaults they guard.
283
- loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading"], ["sections"]) },
283
+ loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading", "closed"], ["sections"]) },
284
284
  roundedBox: { toArgs: roundedBoxArgs },
285
285
  roundedCylinder: { toArgs: roundedCylinderArgs },
286
286
  torus: { toArgs: torusArgs },
@@ -1,8 +1,9 @@
1
1
  // The k.loftSmooth reference part: a boat propeller — bored hub + N airfoil
2
2
  // blades, each blade a spline-interpolated loft of 5 sparse control sections.
3
3
  // The "Surface" section keeps the didactic A/B: untick **Smooth** to see the raw
4
- // k.loft of the same control sections. Spec:
4
+ // k.loft of the same control sections. Specs:
5
5
  // docs/superpowers/specs/2026-08-24-loft-smooth-design.md
6
+ // docs/superpowers/specs/2026-08-25-loft-smooth-v2-design.md
6
7
 
7
8
  // NACA-4-ish airfoil contour, closed and CCW, centered near the quarter chord so
8
9
  // per-ring `rotate` twists about a sensible pitch axis. `n` points per surface;
@@ -35,6 +36,16 @@ const bladeSections = (p) =>
35
36
  p.camber,
36
37
  p.sectionPts,
37
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] } : {}),
38
49
  z: p.span * t,
39
50
  rotate: p.twistRoot + (p.twistTip - p.twistRoot) * t,
40
51
  }));
@@ -67,10 +78,12 @@ export default {
67
78
  {
68
79
  id: "surface",
69
80
  title: "Surface",
70
- description: "**Smooth** interpolates the 5 sparse control sections with `loftSmooth`; off shows the raw `k.loft` of the same sections. **Stations/Samples** are the densifier resolution; **Section points** is how sparse the control sections are.",
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.",
71
82
  controls: [
72
83
  { key: "smooth", type: "checkbox", label: "Smooth (loftSmooth)",
73
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." },
74
87
  { key: "stations", label: "Stations", min: 5, max: 128, step: 1, when: { smooth: 1 } },
75
88
  { key: "samples", label: "Samples / ring", min: 16, max: 512, step: 4, when: { smooth: 1 } },
76
89
  { key: "sectionPts", label: "Section points", min: 6, max: 40, step: 1,
@@ -81,7 +94,7 @@ export default {
81
94
  defaults: {
82
95
  blades: 3, span: 70, rootChord: 26, tipChord: 16, twistRoot: 62, twistTip: 30,
83
96
  thickness: 12, camber: 6, hubD: 30, hubH: 26, boreD: 8,
84
- smooth: 1, stations: 48, samples: 128, sectionPts: 12,
97
+ smooth: 1, sharpTE: 1, stations: 48, samples: 128, sectionPts: 12,
85
98
  },
86
99
  parts: {
87
100
  propeller: {
package/types/kernel.d.ts CHANGED
@@ -380,15 +380,31 @@ 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
+
383
397
  /** k.loftSmooth — spline-interpolated loft of sparse control sections. */
384
398
  export interface LoftSmoothOptions {
385
399
  /** Sparse control sections; vertex counts may differ between sections. */
386
- sections: LoftRing[];
387
- /** Output ring count along the spine (default 8 per span + 1). */
400
+ sections: LoftSmoothSection[];
401
+ /** Output ring count along the spine (default 8 per span + 1; closed: 8 per section). */
388
402
  stations?: number;
389
403
  /** Output vertex count around each ring (default max(64, largest section)). */
390
404
  samples?: number;
391
405
  shading?: "smooth" | "faceted";
406
+ /** Capless loop — Manifold only, ≥3 sections. */
407
+ closed?: boolean;
392
408
  }
393
409
 
394
410
  export interface RoundedCylinderOptions {