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.
- package/docs/AUTHORING-PARTS.md +31 -3
- package/docs/ERROR-PATTERNS.md +16 -6
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/framework/geometry/kernel-front.js +24 -11
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/loft-smooth.js +263 -41
- package/src/framework/geometry/op-options.js +1 -1
- package/src/parts/propeller.js +16 -3
- package/types/kernel.d.ts +18 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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.
|
|
369
|
-
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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-
|
|
500
|
+
## loftsmooth-corner-count-mismatch
|
|
501
501
|
|
|
502
|
-
- **Symptom:** `loftSmooth: section
|
|
503
|
-
- **Cause:**
|
|
504
|
-
- **Fix:**
|
|
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
505
|
|
|
506
|
-
|
|
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`
|
|
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"`).
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
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 /
|
|
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
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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 <
|
|
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 =
|
|
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 <
|
|
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 },
|
package/src/parts/propeller.js
CHANGED
|
@@ -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.
|
|
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:
|
|
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 {
|