partforge 0.83.1 → 0.84.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 +19 -0
- package/docs/ERROR-PATTERNS.md +14 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/package.json +1 -1
- package/src/app-propeller.js +15 -0
- package/src/framework/geometry/kernel-front.js +17 -0
- package/src/framework/geometry/kernel.js +3 -0
- package/src/framework/geometry/loft-smooth.js +200 -0
- package/src/framework/geometry/op-options.js +2 -0
- package/src/parts/propeller.js +115 -0
- package/src/propeller-worker.js +4 -0
- package/types/kernel.d.ts +13 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -324,6 +324,7 @@ and the detection rule.
|
|
|
324
324
|
| `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
|
|
325
325
|
| `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove). **Not for threads** — the profile is always circular and rides a frenet frame that rolls with the helix, tilting a tooth off-axis. For threads use `k.screwSweep` |
|
|
326
326
|
| `k.screwSweep({ profile, pitch, turns, lefthand? })` | screw-motion sweep of an **axial** lathe profile `[[r, z], …]` (same convention as `k.revolve`) — threads, worms, helical ridges. `h = pitch · turns`. The profile's axial extent must not exceed `pitch`; a profile spanning exactly `pitch` must be **periodic** (first radius == last radius) and yields a complete threaded body with no boolean (both backends) |
|
|
327
|
+
| `k.loftSmooth({ sections, stations?, samples?, shading? })` | 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
328
|
| `k.union(solids[])` | boolean union |
|
|
328
329
|
|
|
329
330
|
**`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius | {start,segments} | Shape2D, z, rotate?, scale? }`
|
|
@@ -349,6 +350,24 @@ loft self-corrects a fully-inverted result so CW-wound or descending-z rings sti
|
|
|
349
350
|
Multi-region or holed `Shape2D` throws — loft each region as its own solid and union the lofts, or cut holes from
|
|
350
351
|
the lofted solid after it closes.
|
|
351
352
|
|
|
353
|
+
**Smooth organic lofts.** When the silhouette should be a smooth curve rather
|
|
354
|
+
than faceted stations, don't densify rings by hand — hand `k.loftSmooth` the
|
|
355
|
+
few sections you can reason about and let it interpolate (both backends;
|
|
356
|
+
`k.loft` stays the right tool for deliberate facets and exact station control):
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
const sections = [0, 0.3, 0.6, 0.85, 1].map((t) => ({
|
|
360
|
+
polygon: airfoil(chord(t)), // plain [[x,y],…] point rings; counts may differ
|
|
361
|
+
z: span * t,
|
|
362
|
+
rotate: pitch(t), // authored twist sweeps correctly — vertex j
|
|
363
|
+
})); // is the same material line on every section
|
|
364
|
+
const blade = k.loftSmooth({ sections });
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Raise `samples` if the cross-section shows facets, `stations` if banding runs
|
|
368
|
+
along the spine. Sections must be point rings — vertex order and the vertex-0
|
|
369
|
+
seam are how corresponding points line up across sections.
|
|
370
|
+
|
|
352
371
|
**`sweep`** takes the same CCW `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
|
|
353
372
|
`path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with sharp mitered corners by
|
|
354
373
|
default or `cornerRadius` fillets. Worked snippets:
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -497,6 +497,20 @@ 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
|
|
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.
|
|
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.
|
|
507
|
+
|
|
508
|
+
## loftsmooth-looks-faceted
|
|
509
|
+
|
|
510
|
+
- **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.
|
|
512
|
+
- **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
|
+
|
|
500
514
|
## duplicate-preset-name-throws
|
|
501
515
|
|
|
502
516
|
- **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,6 +288,7 @@ above. All ops return a `Solid`.
|
|
|
288
288
|
| `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
|
|
289
289
|
| `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
|
|
290
290
|
| `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
|
|
291
|
+
| `loftSmooth({sections, stations?, samples?, shading?})` | 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
292
|
| `union(solids[])` | Boolean union of one or more solids. |
|
|
292
293
|
| `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
|
|
293
294
|
| `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Glue for the loftSmooth propeller reference part (see parts/propeller.js).
|
|
2
|
+
// Dev-only: `npm run dev`, then open /propeller.html.
|
|
3
|
+
import "@fontsource-variable/geist";
|
|
4
|
+
import "@fontsource-variable/geist-mono";
|
|
5
|
+
import part from "./parts/propeller.js";
|
|
6
|
+
import { mount } from "./framework/index.js";
|
|
7
|
+
|
|
8
|
+
window.__pfRuntime = mount(part, {
|
|
9
|
+
createWorker: (name) =>
|
|
10
|
+
new Worker(new URL("./propeller-worker.js", import.meta.url), { type: "module", name }),
|
|
11
|
+
onAnnotationSend: (payload) => {
|
|
12
|
+
window.__pfLastAnnotation = payload;
|
|
13
|
+
console.log("annotation payload", payload);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -25,6 +25,7 @@ import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
|
25
25
|
import { convexHull, hullPoints } from "./hull.js";
|
|
26
26
|
import { latheRoundedRect, torusContour } from "./rounded-solids.js";
|
|
27
27
|
import { screwCrossSection } from "./screw-profile.js";
|
|
28
|
+
import { smoothLoftRings } from "./loft-smooth.js";
|
|
28
29
|
|
|
29
30
|
export function finishKernel(k) {
|
|
30
31
|
// Compound default: bored-through cylinder (tool overshoots 2 mm each end for
|
|
@@ -55,6 +56,22 @@ export function finishKernel(k) {
|
|
|
55
56
|
twist: (lefthand ? -360 : 360) * turns,
|
|
56
57
|
});
|
|
57
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.
|
|
67
|
+
// B-rep detection: `toSTEP` exists here only on a B-rep backend — the stub for
|
|
68
|
+
// mesh kernels is assigned later in this function.
|
|
69
|
+
const brepLoft = typeof k.toSTEP === "function";
|
|
70
|
+
k.loftSmooth ??= ({ sections, stations, samples, shading = "smooth" }) =>
|
|
71
|
+
brepLoft
|
|
72
|
+
? k.loft({ rings: smoothLoftRings(sections, { stations: "controls", samples }), ruled: false })
|
|
73
|
+
: k.loft({ rings: smoothLoftRings(sections, { stations, samples }), shading });
|
|
74
|
+
|
|
58
75
|
for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
|
|
59
76
|
const raw = k[op];
|
|
60
77
|
if (!raw) continue;
|
|
@@ -23,6 +23,8 @@ export const KERNEL_OPS = [
|
|
|
23
23
|
"cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
|
|
24
24
|
"loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
|
|
25
25
|
"roundedCylinder", "torus", "roundedBox", "import",
|
|
26
|
+
// Additive in 0.84 (no CONTRACT_VERSION bump — the import-op precedent).
|
|
27
|
+
"loftSmooth",
|
|
26
28
|
];
|
|
27
29
|
|
|
28
30
|
// Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
|
|
@@ -147,6 +149,7 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
147
149
|
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
|
|
148
150
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
149
151
|
* @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
|
|
152
|
+
* @property {(o:{sections:object[],stations?:number,samples?:number,shading?:string}) => Solid} loftSmooth Catmull-Rom-densified loft of sparse control sections; options-only
|
|
150
153
|
* @property {(solids:Solid[]) => Solid} union
|
|
151
154
|
* @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
|
|
152
155
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The k.loftSmooth densifier (see the loftSmooth row in docs/KERNEL-CONTRACT.md and
|
|
2
|
+
// docs/superpowers/specs/2026-08-24-loft-smooth-design.md).
|
|
3
|
+
// Shared spline densifier behind k.loftSmooth: sparse control sections in, a dense
|
|
4
|
+
// ring list for k.loft out. Pure JS and backend-free, so both backends receive the
|
|
5
|
+
// IDENTICAL densified station list — parity by construction, the sweep/screwSweep
|
|
6
|
+
// precedent — rather than each backend interpolating its own surface.
|
|
7
|
+
//
|
|
8
|
+
// Interpolation is Catmull-Rom both ways:
|
|
9
|
+
// • around each ring — centripetal (α=0.5) through the section's control points,
|
|
10
|
+
// closed/periodic, resampled to a shared vertex count uniformly by arc length
|
|
11
|
+
// (centripetal because airfoil-style sections cluster points unevenly, and
|
|
12
|
+
// uniform CR overshoots on uneven chords);
|
|
13
|
+
// • across stations — through each vertex index's control polyline, with ONE
|
|
14
|
+
// shared knot vector taken from the centroid spine (centroid + z chord length).
|
|
15
|
+
// Shared knots mean every vertex's z blend is identical, so output rings stay
|
|
16
|
+
// planar — which the k.loft ring format requires.
|
|
17
|
+
// End stations are clamped with reflection phantoms, so the surface interpolates
|
|
18
|
+
// the first and last control sections exactly.
|
|
19
|
+
import { isArcContour } from "./profile.js";
|
|
20
|
+
|
|
21
|
+
// Mirror of loft's ring spec resolution (loft.js resolveRings), minus the
|
|
22
|
+
// equal-vertex-count rule — the whole point here is that control sections may
|
|
23
|
+
// disagree; the resampler reconciles them.
|
|
24
|
+
function resolveSections(sections) {
|
|
25
|
+
if (!Array.isArray(sections) || sections.length < 2)
|
|
26
|
+
throw new Error("loftSmooth: sections must be an array of at least 2 control sections");
|
|
27
|
+
return sections.map((s, i) => {
|
|
28
|
+
if (!s || typeof s !== "object") throw new Error(`loftSmooth: section ${i} must be an object { polygon|sides+radius, z }`);
|
|
29
|
+
if (!Number.isFinite(s.z)) throw new Error(`loftSmooth: section ${i} needs a finite z`);
|
|
30
|
+
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]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
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
|
+
const sc = s.scale ?? 1;
|
|
43
|
+
const [sx, sy] = Array.isArray(sc) ? sc : [sc, sc];
|
|
44
|
+
const rot = ((s.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
|
|
45
|
+
const pts2d = pts.map(([x, y]) => {
|
|
46
|
+
const X = x * sx, Y = y * sy;
|
|
47
|
+
return [X * cos - Y * sin, X * sin + Y * cos];
|
|
48
|
+
});
|
|
49
|
+
return { pts2d, z: s.z };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Barry–Goldman pyramid for one Catmull-Rom segment: evaluates the curve through
|
|
54
|
+
// p1..p2 at knot value t ∈ [t1, t2], for arbitrary (e.g. centripetal) knots.
|
|
55
|
+
function crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t) {
|
|
56
|
+
const lerpP = (a, b, ta, tb) => {
|
|
57
|
+
const w = tb === ta ? 0 : (t - ta) / (tb - ta);
|
|
58
|
+
return [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w];
|
|
59
|
+
};
|
|
60
|
+
const a1 = lerpP(p0, p1, t0, t1), a2 = lerpP(p1, p2, t1, t2), a3 = lerpP(p2, p3, t2, t3);
|
|
61
|
+
const b1 = lerpP(a1, a2, t0, t2), b2 = lerpP(a2, a3, t1, t3);
|
|
62
|
+
return lerpP(b1, b2, t1, t2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
66
|
+
|
|
67
|
+
// Closed centripetal Catmull-Rom through `pts`, resampled to `n` points uniformly
|
|
68
|
+
// by arc length (measured on a dense polyline — SUB samples per control segment).
|
|
69
|
+
const SUB = 8;
|
|
70
|
+
export function resampleClosedSpline(pts, n) {
|
|
71
|
+
const N = pts.length;
|
|
72
|
+
const dense = [];
|
|
73
|
+
for (let i = 0; i < N; i++) {
|
|
74
|
+
const p0 = pts[(i - 1 + N) % N], p1 = pts[i], p2 = pts[(i + 1) % N], p3 = pts[(i + 2) % N];
|
|
75
|
+
// Centripetal knots (α=0.5); coincident control points get a tiny ε so the
|
|
76
|
+
// pyramid never divides by zero.
|
|
77
|
+
const t0 = 0;
|
|
78
|
+
const t1 = t0 + Math.max(Math.sqrt(dist(p0, p1)), 1e-6);
|
|
79
|
+
const t2 = t1 + Math.max(Math.sqrt(dist(p1, p2)), 1e-6);
|
|
80
|
+
const t3 = t2 + Math.max(Math.sqrt(dist(p2, p3)), 1e-6);
|
|
81
|
+
for (let s = 0; s < SUB; s++)
|
|
82
|
+
dense.push(crPoint(p0, p1, p2, p3, t0, t1, t2, t3, t1 + ((t2 - t1) * s) / SUB));
|
|
83
|
+
}
|
|
84
|
+
// Uniform-by-arc-length resample of the dense closed polyline.
|
|
85
|
+
const M = dense.length;
|
|
86
|
+
const cum = [0];
|
|
87
|
+
for (let i = 1; i <= M; i++) cum.push(cum[i - 1] + dist(dense[i - 1], dense[i % M]));
|
|
88
|
+
const total = cum[M];
|
|
89
|
+
if (!(total > 0)) throw new Error("loftSmooth: a control section has zero perimeter");
|
|
90
|
+
const out = [];
|
|
91
|
+
let seg = 0;
|
|
92
|
+
for (let j = 0; j < n; j++) {
|
|
93
|
+
const target = (j / n) * total;
|
|
94
|
+
while (cum[seg + 1] < target) seg++;
|
|
95
|
+
const a = dense[seg], b = dense[(seg + 1) % M];
|
|
96
|
+
const w = (target - cum[seg]) / (cum[seg + 1] - cum[seg] || 1);
|
|
97
|
+
out.push([a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w]);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Densify sparse control sections into a ring list for k.loft.
|
|
104
|
+
* @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}>}
|
|
115
|
+
*/
|
|
116
|
+
export function smoothLoftRings(sections, { stations, samples } = {}) {
|
|
117
|
+
const resolved = resolveSections(sections);
|
|
118
|
+
const n = resolved.length;
|
|
119
|
+
const S = stations ?? (n - 1) * 8 + 1;
|
|
120
|
+
const V = samples ?? Math.max(64, ...resolved.map((r) => r.pts2d.length));
|
|
121
|
+
if (stations !== "controls" && !(Number.isFinite(S) && S >= 2 && S <= 1024))
|
|
122
|
+
throw new Error('loftSmooth: stations must be 2…1024 (or "controls")');
|
|
123
|
+
if (!(Number.isFinite(V) && V >= 8 && V <= 2048)) throw new Error("loftSmooth: samples must be 8…2048");
|
|
124
|
+
|
|
125
|
+
// 1. Reconcile every section to V vertices on its smooth closed outline.
|
|
126
|
+
const rings = resolved.map((r) => resampleClosedSpline(r.pts2d, V));
|
|
127
|
+
if (stations === "controls")
|
|
128
|
+
return resolved.map((r, i) => ({ polygon: rings[i], z: r.z }));
|
|
129
|
+
|
|
130
|
+
// 2. Shared across-station knots from the centroid spine (chord length in
|
|
131
|
+
// centroid-xy + z space). Shared knots ⇒ planar output rings (see header).
|
|
132
|
+
const spine = resolved.map((r, i) => {
|
|
133
|
+
let cx = 0, cy = 0;
|
|
134
|
+
for (const [x, y] of rings[i]) { cx += x; cy += y; }
|
|
135
|
+
return [cx / V, cy / V, r.z];
|
|
136
|
+
});
|
|
137
|
+
const knots = [0];
|
|
138
|
+
for (let i = 1; i < n; i++)
|
|
139
|
+
knots.push(knots[i - 1] + Math.max(Math.hypot(
|
|
140
|
+
spine[i][0] - spine[i - 1][0], spine[i][1] - spine[i - 1][1], spine[i][2] - spine[i - 1][2]), 1e-6));
|
|
141
|
+
|
|
142
|
+
// Reflection phantoms clamp the ends: the curve passes through ring 0 and ring
|
|
143
|
+
// n−1 exactly, with a natural-looking end tangent.
|
|
144
|
+
const reflect = (a, b) => [2 * a[0] - b[0], 2 * a[1] - b[1]];
|
|
145
|
+
const knotAt = (i) => { // phantom knots mirror the end spacing
|
|
146
|
+
if (i < 0) return knots[0] - (knots[1] - knots[0]);
|
|
147
|
+
if (i >= n) return knots[n - 1] + (knots[n - 1] - knots[n - 2]);
|
|
148
|
+
return knots[i];
|
|
149
|
+
};
|
|
150
|
+
const ptAt = (j, i) => {
|
|
151
|
+
if (i < 0) return reflect(rings[0][j], rings[1][j]);
|
|
152
|
+
if (i >= n) return reflect(rings[n - 1][j], rings[n - 2][j]);
|
|
153
|
+
return rings[i][j];
|
|
154
|
+
};
|
|
155
|
+
const zCtrl = (i) => {
|
|
156
|
+
if (i < 0) return 2 * resolved[0].z - resolved[1].z;
|
|
157
|
+
if (i >= n) return 2 * resolved[n - 1].z - resolved[n - 2].z;
|
|
158
|
+
return resolved[i].z;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// 3. Station parameter list: every control knot is always emitted, plus interior
|
|
162
|
+
// stations distributed per span proportionally to knot length (largest-
|
|
163
|
+
// remainder apportionment; ties to the lower index — deterministic), so each
|
|
164
|
+
// control section appears as an actual output ring, not just a point the
|
|
165
|
+
// 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];
|
|
168
|
+
const S2 = Math.max(S, n);
|
|
169
|
+
const extra = S2 - n;
|
|
170
|
+
const spans = [];
|
|
171
|
+
for (let i = 0; i < n - 1; i++) spans.push(knots[i + 1] - knots[i]);
|
|
172
|
+
const exact = spans.map((len) => (extra * len) / tEnd);
|
|
173
|
+
const alloc = exact.map(Math.floor);
|
|
174
|
+
let left = extra - alloc.reduce((a, b) => a + b, 0);
|
|
175
|
+
const order = exact.map((e, i) => [e - alloc[i], i]).sort((p, q) => q[0] - p[0] || p[1] - q[1]);
|
|
176
|
+
for (let j = 0; j < left; j++) alloc[order[j][1]]++;
|
|
177
|
+
const ts = [];
|
|
178
|
+
for (let i = 0; i < n - 1; i++) {
|
|
179
|
+
ts.push(knots[i]);
|
|
180
|
+
for (let m = 1; m <= alloc[i]; m++) ts.push(knots[i] + (spans[i] * m) / (alloc[i] + 1));
|
|
181
|
+
}
|
|
182
|
+
ts.push(tEnd);
|
|
183
|
+
|
|
184
|
+
// 4. Evaluate the stations. z uses the same segment/knots as every vertex,
|
|
185
|
+
// evaluated once per station (1-D Barry–Goldman via crPoint).
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const t of ts) {
|
|
188
|
+
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);
|
|
191
|
+
const z1d = (a, b, c, d) =>
|
|
192
|
+
crPoint([a, 0], [b, 0], [c, 0], [d, 0], t0, t1, t2, t3, t)[0];
|
|
193
|
+
const z = z1d(zCtrl(seg - 1), zCtrl(seg), zCtrl(seg + 1), zCtrl(seg + 2));
|
|
194
|
+
const polygon = [];
|
|
195
|
+
for (let j = 0; j < V; j++)
|
|
196
|
+
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 });
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
@@ -279,6 +279,8 @@ export const KERNEL_OP_SPECS = {
|
|
|
279
279
|
if (!(o.turns > 0)) throw new Error("screwSweep: turns must be > 0");
|
|
280
280
|
},
|
|
281
281
|
},
|
|
282
|
+
// loftSmooth: range checks live in loft-smooth.js, next to the defaults they guard.
|
|
283
|
+
loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading"], ["sections"]) },
|
|
282
284
|
roundedBox: { toArgs: roundedBoxArgs },
|
|
283
285
|
roundedCylinder: { toArgs: roundedCylinderArgs },
|
|
284
286
|
torus: { toArgs: torusArgs },
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The k.loftSmooth reference part: a boat propeller — bored hub + N airfoil
|
|
2
|
+
// blades, each blade a spline-interpolated loft of 5 sparse control sections.
|
|
3
|
+
// The "Surface" section keeps the didactic A/B: untick **Smooth** to see the raw
|
|
4
|
+
// k.loft of the same control sections. Spec:
|
|
5
|
+
// docs/superpowers/specs/2026-08-24-loft-smooth-design.md
|
|
6
|
+
|
|
7
|
+
// NACA-4-ish airfoil contour, closed and CCW, centered near the quarter chord so
|
|
8
|
+
// per-ring `rotate` twists about a sensible pitch axis. `n` points per surface;
|
|
9
|
+
// cosine spacing clusters points at the leading edge, which is exactly the uneven
|
|
10
|
+
// spacing the centripetal densifier is supposed to handle.
|
|
11
|
+
const airfoil = (chord, thickPct, camberPct, n) => {
|
|
12
|
+
const t = thickPct / 100, m = camberPct / 100, p = 0.4;
|
|
13
|
+
const yt = (x) => 5 * t * (0.2969 * Math.sqrt(x) - 0.126 * x - 0.3516 * x * x + 0.2843 * x ** 3 - 0.1036 * x ** 4);
|
|
14
|
+
const yc = (x) => (x < p ? (m / (p * p)) * (2 * p * x - x * x) : (m / ((1 - p) ** 2)) * (1 - 2 * p + 2 * p * x - x * x));
|
|
15
|
+
const upper = [], lower = [];
|
|
16
|
+
for (let i = 0; i <= n; i++) {
|
|
17
|
+
const x = (1 - Math.cos((i / n) * Math.PI)) / 2; // cosine spacing, LE→TE
|
|
18
|
+
upper.push([x, yc(x) + yt(x)]);
|
|
19
|
+
lower.push([x, yc(x) - yt(x)]);
|
|
20
|
+
}
|
|
21
|
+
// TE→LE along the top, LE→TE along the bottom; drop duplicated LE/TE points.
|
|
22
|
+
const pts = [...upper.reverse().slice(0, -1), ...lower.slice(1)];
|
|
23
|
+
return pts.map(([x, y]) => [(x - 0.3) * chord, y * chord]);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Five control stations up the span: a propeller-y chord outline (widest mid-span,
|
|
27
|
+
// closing toward a rounded tip) and a root→tip pitch-angle washout.
|
|
28
|
+
const SPAN_T = [0, 0.3, 0.6, 0.85, 1];
|
|
29
|
+
const CHORD_MUL = [1, 1.12, 1.0, 0.72, 0.28];
|
|
30
|
+
const bladeSections = (p) =>
|
|
31
|
+
SPAN_T.map((t, i) => ({
|
|
32
|
+
polygon: airfoil(
|
|
33
|
+
(p.rootChord + (p.tipChord - p.rootChord) * t) * CHORD_MUL[i],
|
|
34
|
+
p.thickness * (1 - 0.45 * t), // blades thin toward the tip
|
|
35
|
+
p.camber,
|
|
36
|
+
p.sectionPts,
|
|
37
|
+
),
|
|
38
|
+
z: p.span * t,
|
|
39
|
+
rotate: p.twistRoot + (p.twistTip - p.twistRoot) * t,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
meta: { title: "Propeller", units: "mm", background: 0x15181d },
|
|
44
|
+
parameters: [
|
|
45
|
+
{
|
|
46
|
+
id: "prop",
|
|
47
|
+
title: "Propeller",
|
|
48
|
+
description: "A boat propeller: bored hub + airfoil blades. The blade is the organic-surface exerciser — every surface is a `loftSmooth` of 5 sparse control sections.",
|
|
49
|
+
controls: [
|
|
50
|
+
{ key: "blades", label: "Blades", min: 2, max: 6, step: 1 },
|
|
51
|
+
{ key: "span", label: "Blade span", unit: "mm", min: 30, max: 120, step: 1 },
|
|
52
|
+
{ key: "rootChord", label: "Root chord", unit: "mm", min: 10, max: 50, step: 1 },
|
|
53
|
+
{ key: "tipChord", label: "Tip chord", unit: "mm", min: 6, max: 40, step: 1 },
|
|
54
|
+
{ key: "twistRoot", label: "Root pitch", unit: "°", min: 0, max: 80, step: 1,
|
|
55
|
+
description: "Blade angle at the root. 0° puts the chord in the rotation plane." },
|
|
56
|
+
{ key: "twistTip", label: "Tip pitch", unit: "°", min: 0, max: 80, step: 1 },
|
|
57
|
+
{ key: "thickness", label: "Thickness", unit: "%", min: 4, max: 25, step: 1,
|
|
58
|
+
description: "Airfoil thickness as % of chord, at the root." },
|
|
59
|
+
{ key: "camber", label: "Camber", unit: "%", min: 0, max: 12, step: 1 },
|
|
60
|
+
{ type: "group", title: "Hub", collapsed: "auto", controls: [
|
|
61
|
+
{ key: "hubD", label: "Hub diameter", unit: "mm", min: 14, max: 60, step: 1 },
|
|
62
|
+
{ key: "hubH", label: "Hub length", unit: "mm", min: 10, max: 60, step: 1 },
|
|
63
|
+
{ key: "boreD", label: "Shaft bore", unit: "mm", min: 2, max: 20, step: 0.5 },
|
|
64
|
+
] },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "surface",
|
|
69
|
+
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.",
|
|
71
|
+
controls: [
|
|
72
|
+
{ key: "smooth", type: "checkbox", label: "Smooth (loftSmooth)",
|
|
73
|
+
description: "A/B toggle: spline-densified vs raw loft of identical control sections." },
|
|
74
|
+
{ key: "stations", label: "Stations", min: 5, max: 128, step: 1, when: { smooth: 1 } },
|
|
75
|
+
{ key: "samples", label: "Samples / ring", min: 16, max: 512, step: 4, when: { smooth: 1 } },
|
|
76
|
+
{ key: "sectionPts", label: "Section points", min: 6, max: 40, step: 1,
|
|
77
|
+
description: "Points per airfoil *surface* in each control section — the sparse input both paths share." },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
defaults: {
|
|
82
|
+
blades: 3, span: 70, rootChord: 26, tipChord: 16, twistRoot: 62, twistTip: 30,
|
|
83
|
+
thickness: 12, camber: 6, hubD: 30, hubH: 26, boreD: 8,
|
|
84
|
+
smooth: 1, stations: 48, samples: 128, sectionPts: 12,
|
|
85
|
+
},
|
|
86
|
+
parts: {
|
|
87
|
+
propeller: {
|
|
88
|
+
label: "Propeller", views: ["propeller"], export: { name: "propeller" },
|
|
89
|
+
build: (k, p) => {
|
|
90
|
+
const sections = bladeSections(p);
|
|
91
|
+
const bladeUp = p.smooth
|
|
92
|
+
? k.loftSmooth({ sections, stations: p.stations, samples: p.samples })
|
|
93
|
+
: k.loft({ rings: sections });
|
|
94
|
+
// Built span-up (+Z); lay it radial along +X — the airfoil chord then sits
|
|
95
|
+
// in the axis/rotation-plane frame, so `rotate` above reads as pitch angle.
|
|
96
|
+
// Root sinks to 62% of hub radius so the union has generous overlap.
|
|
97
|
+
const blade = bladeUp.rotateY(90).translate([p.hubD * 0.31, 0, 0]).label("Blade");
|
|
98
|
+
const blades = [];
|
|
99
|
+
for (let i = 0; i < p.blades; i++) blades.push(blade.clone().rotateZ((360 / p.blades) * i));
|
|
100
|
+
const hub = k.cylinder({ r: p.hubD / 2, h: p.hubH })
|
|
101
|
+
.translate([0, 0, -p.hubH / 2]).label("Hub")
|
|
102
|
+
.cut(k.cylinder({ r: p.boreD / 2, h: p.hubH + 4 }).translate([0, 0, -p.hubH / 2 - 2]).label("Bore"));
|
|
103
|
+
return k.union([hub, ...blades]);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
views: { propeller: { label: "Propeller" } },
|
|
108
|
+
verify: {
|
|
109
|
+
expect: {
|
|
110
|
+
// One through-hole (the shaft bore); everything unioned into one watertight body.
|
|
111
|
+
propeller: { holes: 1, bbox: "<=[300,300,300]" },
|
|
112
|
+
_view: { overlaps: 0 },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
package/types/kernel.d.ts
CHANGED
|
@@ -380,6 +380,17 @@ export interface ScrewSweepOptions {
|
|
|
380
380
|
lefthand?: boolean;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
/** k.loftSmooth — spline-interpolated loft of sparse control sections. */
|
|
384
|
+
export interface LoftSmoothOptions {
|
|
385
|
+
/** Sparse control sections; vertex counts may differ between sections. */
|
|
386
|
+
sections: LoftRing[];
|
|
387
|
+
/** Output ring count along the spine (default 8 per span + 1). */
|
|
388
|
+
stations?: number;
|
|
389
|
+
/** Output vertex count around each ring (default max(64, largest section)). */
|
|
390
|
+
samples?: number;
|
|
391
|
+
shading?: "smooth" | "faceted";
|
|
392
|
+
}
|
|
393
|
+
|
|
383
394
|
export interface RoundedCylinderOptions {
|
|
384
395
|
r?: number;
|
|
385
396
|
d?: number;
|
|
@@ -460,6 +471,8 @@ export interface GeometryKernel {
|
|
|
460
471
|
helixSweptTube(o: HelixSweptTubeOptions): Solid;
|
|
461
472
|
/** Sweep an axial lathe profile by screw motion — threads. */
|
|
462
473
|
screwSweep(o: ScrewSweepOptions): Solid;
|
|
474
|
+
/** Spline-interpolated loft of sparse control sections. */
|
|
475
|
+
loftSmooth(o: LoftSmoothOptions): Solid;
|
|
463
476
|
/** Rim round-overs via one lathe revolve; curve-exact in STEP. */
|
|
464
477
|
roundedCylinder(o: RoundedCylinderOptions): Solid;
|
|
465
478
|
torus(o: TorusOptions): Solid;
|