partforge 0.85.2 → 0.85.4

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.
@@ -529,9 +529,9 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
529
529
 
530
530
  ## loftsmooth-samples-out-of-range
531
531
 
532
- - **Symptom:** `loftSmooth: samples must be 8…2048` — possibly without ever passing `samples`.
533
- - **Cause:** `samples` is clamped to 8…2048. The default is `max(64, largest section)`, so a control section with more than 2048 points pushes the DEFAULT out of rangethe throw then names an option the caller never passed.
534
- - **Fix:** pass a `samples` value in 8…2048, and thin any control section above ~2048 points `loftSmooth` interpolates a smooth outline through the points, so sections that dense defeat the sparse-sections design (pass the dense rings straight to `k.loft` instead). See the `loftSmooth` row in [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md).
532
+ - **Symptom:** `loftSmooth: samples must be 8…2048` — an explicit `samples` was rejected.
533
+ - **Cause:** `samples` is clamped to 8…2048. The default `max(64, largest section)` caps itself at 2048, so omitting the option never trips thiseven when a control section has more than 2048 points.
534
+ - **Fix:** pass a `samples` value in 8…2048, or omit it for the capped default — and thin any control section above ~2048 points: `loftSmooth` interpolates a smooth outline through sparse control points, so sections that dense defeat the sparse-sections design (pass the dense rings straight to `k.loft` instead). See the `loftSmooth` row in [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md).
535
535
 
536
536
  ## duplicate-preset-name-throws
537
537
 
@@ -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?, closed?})` | Spline-interpolated loft of ≥2 sparse control sections — loft-style ring specs `{polygon\|sides+radius\|curve contour\|Shape2D, z, rotate?, scale?, sharp?}`; vertex counts **may differ**. A point section may tag `sharp: [indices]` as true corners (integers in `0…points.length-1`, sorted/deduped silently); a curve/`Shape2D` section takes corners implicitly from its non-smooth joints (single-region, hole-free, `loftSmooth:`-prefixed `k.loft` validation) and rejects an explicit `sharp`. Every section must resolve to the **same corner count `m`** (frozen error otherwise); with `m ≥ 1` corner 0 anchors the seam (replacing vertex 0), with `m = 0` v1's vertex-0 anchor holds verbatim. Compound (`kernel-front.js` + `loft-smooth.js`): each section's outline is a closed centripetal Catmull-Rom split into `m` clamped open arcs at its corners (or one closed periodic CR when `m = 0`); the `samples` budget is apportioned across arcs by mean arc-length fraction (largest-remainder, min 1 span/arc) and each arc resampled by arc length — total ring vertex count is `samples`, identical across sections, exactly v1's invariant now corner-anchored. The cross-station direction is v1 verbatim (shared centroid-spine knots, per-vertex CR, reflection phantoms at the ends, or periodic knots when `closed: true`). What's new is emission: every station — the dense list and the sparse `stations:"controls"` list alike — is fitted back to an **all-cubic Bézier contour**, arc-by-arc, via exact 4-point CR→Bézier inversion, so **both backends receive identical curve rings**. A B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — curve-exact around each ring in STEP (the densified-*point*-wire alternative measured 23 s / WASM-abort territory, which curve wires don't hit). A mesh kernel densifies `stations` rings and lofts them through `k.loft`'s curve-mode per-segment sampling, creasing sharp/corner columns via loft's geometric corner policy. `closed: true` (default false; needs ≥3 control sections, frozen error otherwise) makes the cross-station CR periodic (no reflection phantoms, ring 0 not repeated) and is **Manifold-only**, same restriction as `loft` `closed: true`: a B-rep kernel throws `loftSmooth: closed:true loops are only supported on the Manifold backend` in the composition, before building any rings; combining `closed: true` with `stations:"controls"` is rejected as a defensive invariant (reachable only by explicitly passing the internal `stations:"controls"` value; the composition never produces the combination itself). Options-only. Defaults `stations = (n−1)·8+1` open / `n·8` closed (raised to the section count `n` when lower), `samples = max(64, largest section)` (raised to the corner count `m` when lower); clamps 2…1024 / 8…2048. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class, unchanged from v1 — ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is now curve-exact around each ring (previously faceted at the `samples` LOD); the cross-station skin remains ThruSections' native fit, not the shared CR — exact cross-station B-splines are a v3 candidate. Additive: `sharp`, curve/`Shape2D` sections, and `closed` are new options on top of v1's `{sections, stations?, samples?, shading?}`; `CONTRACT_VERSION` stays 4 — the same non-bump precedent as `import` above, a refinement inside the op's already-stated tolerance class rather than a new one. |
291
+ | `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 defaults cap themselves at the ceilings, only explicit out-of-range values throw. 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.85.2",
3
+ "version": "0.85.4",
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",
@@ -309,7 +309,8 @@ function reconcile(resolved, V) {
309
309
  * — the B-rep path, where the backend's native smooth loft (`ruled: false`)
310
310
  * does the skinning through exact wires and only the around-ring
311
311
  * reconciliation is needed; incompatible with closed:true;
312
- * samples — output vertex count around each ring (default max(64, largest section));
312
+ * samples — output vertex count around each ring (default max(64, largest
313
+ * section), capped at 2048);
313
314
  * closed — periodic spine: the spline wraps from the last control section back
314
315
  * to the first (no duplicate ring at the wrap point), needs ≥ 3 sections.
315
316
  * @returns {Array<{polygon: {start, segments}, z: number}>}
@@ -320,10 +321,11 @@ export function smoothLoftRings(sections, { stations, samples, closed = false }
320
321
  if (closed && stations === "controls")
321
322
  throw new Error('loftSmooth: closed:true cannot combine with stations:"controls"');
322
323
  if (closed && n < 3) throw new Error("loftSmooth: closed:true needs at least 3 control sections");
323
- // The default caps at the clamp ceiling: at 129+ sections the raw formula
324
+ // The defaults cap at the clamp ceilings: at 129+ sections the raw 8-per-span
325
+ // stations (and at a 2049+-point section the raw largest-section samples)
324
326
  // would trip the range check on an option the caller never passed.
325
327
  const S = stations ?? Math.min(closed ? n * 8 : (n - 1) * 8 + 1, 1024);
326
- const V = samples ?? Math.max(64, ...resolved.map((r) => r.pts2d.length));
328
+ const V = samples ?? Math.min(Math.max(64, ...resolved.map((r) => r.pts2d.length)), 2048);
327
329
  if (stations !== "controls" && !(Number.isFinite(S) && S >= 2 && S <= 1024))
328
330
  throw new Error('loftSmooth: stations must be 2…1024 (or "controls")');
329
331
  if (!(Number.isFinite(V) && V >= 8 && V <= 2048)) throw new Error("loftSmooth: samples must be 8…2048");
@@ -1,15 +1,56 @@
1
- // The k.loftSmooth reference part: a boat propeller bored hub + N airfoil
2
- // blades, each blade a spline-interpolated loft of 5 sparse control sections.
3
- // The "Surface" section keeps the didactic A/B: untick **Smooth** to see the raw
4
- // k.loft of the same control sections. Specs:
5
- // docs/superpowers/specs/2026-08-24-loft-smooth-design.md
6
- // docs/superpowers/specs/2026-08-25-loft-smooth-v2-design.md
1
+ // THE reference part for organic lofted geometry (docs/AUTHORING-PARTS.md "Smooth
2
+ // organic lofts"): a boat propeller bored hub + N airfoil blades, each blade one
3
+ // `k.loftSmooth` of 5 sparse control sections. If you are building a boat propeller,
4
+ // a fan, an impeller, or an airplane prop, start from this file: the blade recipe
5
+ // (airfoil ring → chord/twist schedule up the span → smooth loft → radial placement)
6
+ // is the whole pattern, and everything else is ordinary hub plumbing.
7
+ //
8
+ // Vocabulary → code:
9
+ // span — blade length root→tip (the loft's z axis, before placement)
10
+ // chord — airfoil width at a station (`rootChord`→`tipChord` × CHORD_MUL)
11
+ // pitch — per-section `rotate` (deg): angle between chord and rotation plane
12
+ // washout — the root→tip pitch DROP (`twistRoot` > `twistTip`), standard on
13
+ // real props/fans so the tip doesn't stall/over-bite
14
+ // camber — curvature of the airfoil midline (% chord): lift/thrust asymmetry
15
+ // handedness — `lefthand` mirrors every section and negates pitch: a true
16
+ // mirror-image prop for counter-rotating pairs or reversed shafts
17
+ //
18
+ // The blade-frame trick: each blade is lofted SPAN-UP (+Z), so its sections are
19
+ // plain 2-D rings and `rotate` is exactly the pitch angle; `rotateY(90)` then lays
20
+ // it radial along +X, where the chord sits in the axis/rotation-plane frame. Clone
21
+ // and `rotateZ` for the other blades; never author sections in the placed frame.
22
+ //
23
+ // Adapting this part (change parameters, not plumbing):
24
+ // boat propeller — the defaults: 3 blades, high pitch (62°→30°), thick sections,
25
+ // generous camber, stubby hub. Wide-chord "kaplan" styles: raise CHORD_MUL mid.
26
+ // fan / desk fan — more blades (4–6+), FLAT pitch (`twistRoot` ~35°, `twistTip`
27
+ // ~20°), thin sections (`thickness` 5–8%), modest camber (2–4%), longer span
28
+ // relative to hub. Reversible fans want `camber: 0` (symmetric sections).
29
+ // airplane prop — 2 blades, long span, small chord, washout similar to defaults.
30
+ // counter-rotating pair — build twice, second with `lefthand: 1`.
31
+ // Leave alone: the loft/`sharp` plumbing, the blade frame, the CCW/TE-at-vertex-0
32
+ // contracts of `airfoil()` below.
33
+ //
34
+ // The "Surface" section is didactic, not part of the propeller: untick **Smooth**
35
+ // to see the raw `k.loft` of the same sparse sections (the chunky failure mode
36
+ // `loftSmooth` exists to fix). Contract: docs/KERNEL-CONTRACT.md `loftSmooth` row.
37
+ // Specs: docs/superpowers/specs/2026-08-24-loft-smooth-design.md,
38
+ // docs/superpowers/specs/2026-08-25-loft-smooth-v2-design.md
7
39
 
8
- // NACA-4-ish airfoil contour, closed and CCW, centered near the quarter chord so
9
- // per-ring `rotate` twists about a sensible pitch axis. `n` points per surface;
10
- // cosine spacing clusters points at the leading edge, which is exactly the uneven
11
- // spacing the centripetal densifier is supposed to handle.
12
- const airfoil = (chord, thickPct, camberPct, n) => {
40
+ /**
41
+ * NACA-4-ish airfoil section as a closed CCW point ring.
42
+ * Contract (relied on by `bladeSections` and the `sharp` tag): the ring starts at
43
+ * the TRAILING EDGE (vertex 0), runs TE→LE along the upper surface, LE→TE along
44
+ * the lower, stays CCW, and is centered near the quarter chord so per-section
45
+ * `rotate` pitches about a sensible axis. `n` points per surface; cosine spacing
46
+ * clusters points at the leading edge — exactly the uneven spacing loftSmooth's
47
+ * centripetal resampler is built for, so ~12 points per surface is plenty.
48
+ * @param {number} chord section width, mm
49
+ * @param {number} thickPct max thickness, % of chord
50
+ * @param {number} camberPct midline camber, % of chord (0 = symmetric)
51
+ * @param {number} n points per surface (ring has 2n vertices)
52
+ */
53
+ export const airfoil = (chord, thickPct, camberPct, n) => {
13
54
  const t = thickPct / 100, m = camberPct / 100, p = 0.4;
14
55
  const yt = (x) => 5 * t * (0.2969 * Math.sqrt(x) - 0.126 * x - 0.3516 * x * x + 0.2843 * x ** 3 - 0.1036 * x ** 4);
15
56
  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));
@@ -24,31 +65,52 @@ const airfoil = (chord, thickPct, camberPct, n) => {
24
65
  return pts.map(([x, y]) => [(x - 0.3) * chord, y * chord]);
25
66
  };
26
67
 
27
- // Five control stations up the span: a propeller-y chord outline (widest mid-span,
28
- // closing toward a rounded tip) and a root→tip pitch-angle washout.
68
+ // Mirror a section ring across its chord (y −y) for a lefthand blade, keeping
69
+ // both `airfoil()` contracts intact: re-reversing the order restores CCW winding,
70
+ // and pinning the first point keeps the trailing edge at vertex 0 for `sharp`.
71
+ const mirrorRing = (pts) => {
72
+ const m = pts.map(([x, y]) => [x, -y]);
73
+ return [m[0], ...m.slice(1).reverse()];
74
+ };
75
+
76
+ // The blade's spanwise schedules. SPAN_T places the 5 control stations along the
77
+ // span (clustered toward the tip, where shape changes fastest); CHORD_MUL shapes
78
+ // the outline on top of the root→tip chord taper — widest mid-span, closing to a
79
+ // rounded tip. This is where a different blade silhouette lives.
29
80
  const SPAN_T = [0, 0.3, 0.6, 0.85, 1];
30
81
  const CHORD_MUL = [1, 1.12, 1.0, 0.72, 0.28];
31
- const bladeSections = (p) =>
32
- SPAN_T.map((t, i) => ({
33
- polygon: airfoil(
82
+
83
+ /**
84
+ * The 5 sparse control sections for one blade, span-up: `{polygon, z, rotate,
85
+ * sharp?}` ring specs ready for `k.loftSmooth({sections})`. Chord tapers
86
+ * root→tip (× CHORD_MUL), thickness thins 45% toward the tip, pitch interpolates
87
+ * `twistRoot`→`twistTip` (washout). `lefthand` mirrors each ring and negates
88
+ * pitch — together exactly a mirror of the whole blade.
89
+ */
90
+ export const bladeSections = (p) =>
91
+ SPAN_T.map((t, i) => {
92
+ const ring = airfoil(
34
93
  (p.rootChord + (p.tipChord - p.rootChord) * t) * CHORD_MUL[i],
35
94
  p.thickness * (1 - 0.45 * t), // blades thin toward the tip
36
95
  p.camber,
37
96
  p.sectionPts,
38
- ),
39
- // The trailing edge is the ring's two end vertices (upper TE vertex 0, lower
40
- // TE the last) — this NACA closure (coefficient -0.1036) already brings them
41
- // together at the same point, so the "gap" between them is a genuine zero-
42
- // length edge, not a blunt base. Tagging vertex 0 as the single corner keeps
43
- // that meeting point a crease instead of letting the CR spline round it off;
44
- // a *second* tag at the last vertex would mark a zero-length arc between two
45
- // coincident corners, which after the per-section pitch `rotate` below
46
- // collapses their sub-epsilon separation to bit-identical floats the
47
- // resampler rejects as a zero-perimeter section.
48
- ...(p.sharpTE && p.smooth ? { sharp: [0] } : {}),
49
- z: p.span * t,
50
- rotate: p.twistRoot + (p.twistTip - p.twistRoot) * t,
51
- }));
97
+ );
98
+ return {
99
+ polygon: p.lefthand ? mirrorRing(ring) : ring,
100
+ // The trailing edge is the ring's two end vertices (upper TE vertex 0, lower
101
+ // TE the last) this NACA closure (coefficient -0.1036) already brings them
102
+ // together at the same point, so the "gap" between them is a genuine zero-
103
+ // length edge, not a blunt base. Tagging vertex 0 as the single corner keeps
104
+ // that meeting point a crease instead of letting the CR spline round it off;
105
+ // a *second* tag at the last vertex would mark a zero-length arc between two
106
+ // coincident corners, which after the per-section pitch `rotate` below
107
+ // collapses their sub-epsilon separation to bit-identical floats the
108
+ // resampler rejects as a zero-perimeter section.
109
+ ...(p.sharpTE && p.smooth ? { sharp: [0] } : {}),
110
+ z: p.span * t,
111
+ rotate: (p.lefthand ? -1 : 1) * (p.twistRoot + (p.twistTip - p.twistRoot) * t),
112
+ };
113
+ });
52
114
 
53
115
  export default {
54
116
  meta: { title: "Propeller", units: "mm", background: 0x15181d },
@@ -56,22 +118,32 @@ export default {
56
118
  {
57
119
  id: "prop",
58
120
  title: "Propeller",
59
- description: "A boat propeller: bored hub + airfoil blades. The blade is the organic-surface exerciser — every surface is a `loftSmooth` of 5 sparse control sections.",
121
+ description: "A boat propeller: bored hub + airfoil blades, every blade surface a `loftSmooth` of 5 sparse control sections. Also the starting point for fans and airplane props — see the file header's \"Adapting this part\".",
60
122
  controls: [
61
- { key: "blades", label: "Blades", min: 2, max: 6, step: 1 },
62
- { key: "span", label: "Blade span", unit: "mm", min: 30, max: 120, step: 1 },
63
- { key: "rootChord", label: "Root chord", unit: "mm", min: 10, max: 50, step: 1 },
64
- { key: "tipChord", label: "Tip chord", unit: "mm", min: 6, max: 40, step: 1 },
123
+ { key: "blades", label: "Blades", min: 2, max: 6, step: 1,
124
+ description: "Boat props: 3–4. Fans: 4–6. Airplane props: 2." },
125
+ { key: "span", label: "Blade span", unit: "mm", min: 30, max: 120, step: 1,
126
+ description: "Root-to-tip blade length. Overall diameter hub + 2×span." },
127
+ { key: "rootChord", label: "Root chord", unit: "mm", min: 10, max: 50, step: 1,
128
+ description: "Airfoil width where the blade meets the hub." },
129
+ { key: "tipChord", label: "Tip chord", unit: "mm", min: 6, max: 40, step: 1,
130
+ description: "Airfoil width at the tip, before the outline's own taper closes it." },
65
131
  { key: "twistRoot", label: "Root pitch", unit: "°", min: 0, max: 80, step: 1,
66
- description: "Blade angle at the root. 0° puts the chord in the rotation plane." },
67
- { key: "twistTip", label: "Tip pitch", unit: "°", min: 0, max: 80, step: 1 },
132
+ description: "Blade angle at the root; 0° puts the chord in the rotation plane. Boat props run steep (50–70°); fans flat (25–40°)." },
133
+ { key: "twistTip", label: "Tip pitch", unit: "°", min: 0, max: 80, step: 1,
134
+ description: "Blade angle at the tip. Keep it below root pitch (washout) so the tip doesn't over-bite." },
68
135
  { key: "thickness", label: "Thickness", unit: "%", min: 4, max: 25, step: 1,
69
- description: "Airfoil thickness as % of chord, at the root." },
70
- { key: "camber", label: "Camber", unit: "%", min: 0, max: 12, step: 1 },
136
+ description: "Airfoil thickness as % of chord, at the root (thins 45% toward the tip). Boat props 10–15%; fans 5–8%." },
137
+ { key: "camber", label: "Camber", unit: "%", min: 0, max: 12, step: 1,
138
+ description: "Midline curvature as % of chord — the thrust asymmetry. 0 = symmetric section (reversible fans)." },
139
+ { key: "lefthand", type: "checkbox", label: "Left-hand rotation",
140
+ description: "Mirror-image blades for a counter-rotating pair or a reversed shaft. Every section mirrors and pitch negates — a true mirror of the whole propeller." },
71
141
  { type: "group", title: "Hub", collapsed: "auto", controls: [
72
- { key: "hubD", label: "Hub diameter", unit: "mm", min: 14, max: 60, step: 1 },
142
+ { key: "hubD", label: "Hub diameter", unit: "mm", min: 14, max: 60, step: 1,
143
+ description: "Blade roots sink to 62% of hub radius, so the union always has generous overlap." },
73
144
  { key: "hubH", label: "Hub length", unit: "mm", min: 10, max: 60, step: 1 },
74
- { key: "boreD", label: "Shaft bore", unit: "mm", min: 2, max: 20, step: 0.5 },
145
+ { key: "boreD", label: "Shaft bore", unit: "mm", min: 2, max: 20, step: 0.5,
146
+ description: "Through-hole for the shaft — the part's one expected hole (see `verify`)." },
75
147
  ] },
76
148
  ],
77
149
  },
@@ -81,11 +153,13 @@ export default {
81
153
  description: "**Smooth** interpolates the 5 sparse control sections with `loftSmooth`; off shows the raw `k.loft` of the same sections. **Sharp trailing edge** tags the TE vertex as a crease instead of letting the spline smear it. **Stations/Samples** are the densifier resolution; **Section points** is how sparse the control sections are.",
82
154
  controls: [
83
155
  { key: "smooth", type: "checkbox", label: "Smooth (loftSmooth)",
84
- description: "A/B toggle: spline-densified vs raw loft of identical control sections." },
156
+ description: "A/B toggle: spline-densified vs raw loft of identical control sections. Leave ON in real parts — the raw loft is the chunky failure mode." },
85
157
  { key: "sharpTE", type: "checkbox", label: "Sharp trailing edge", when: { smooth: 1 },
86
158
  description: "Tags the trailing-edge vertex as a true corner — the spline interpolates it with a crease instead of smearing it round." },
87
- { key: "stations", label: "Stations", min: 5, max: 128, step: 1, when: { smooth: 1 } },
88
- { key: "samples", label: "Samples / ring", min: 16, max: 512, step: 4, when: { smooth: 1 } },
159
+ { key: "stations", label: "Stations", min: 5, max: 128, step: 1, when: { smooth: 1 },
160
+ description: "Interpolated rings along the span. The default (48) is visually converged; more costs build time." },
161
+ { key: "samples", label: "Samples / ring", min: 16, max: 512, step: 4, when: { smooth: 1 },
162
+ description: "Spline-fit resolution around each ring. STEP file size scales with this; volume converges by ~48." },
89
163
  { key: "sectionPts", label: "Section points", min: 6, max: 40, step: 1,
90
164
  description: "Points per airfoil *surface* in each control section — the sparse input both paths share." },
91
165
  ],
@@ -93,7 +167,7 @@ export default {
93
167
  ],
94
168
  defaults: {
95
169
  blades: 3, span: 70, rootChord: 26, tipChord: 16, twistRoot: 62, twistTip: 30,
96
- thickness: 12, camber: 6, hubD: 30, hubH: 26, boreD: 8,
170
+ thickness: 12, camber: 6, lefthand: 0, hubD: 30, hubH: 26, boreD: 8,
97
171
  smooth: 1, sharpTE: 1, stations: 48, samples: 128, sectionPts: 12,
98
172
  },
99
173
  parts: {
@@ -120,7 +194,9 @@ export default {
120
194
  views: { propeller: { label: "Propeller" } },
121
195
  verify: {
122
196
  expect: {
123
- // One through-hole (the shaft bore); everything unioned into one watertight body.
197
+ // holes: 1 — exactly the shaft bore; a second hole means a blade/hub union
198
+ // gap. bbox bounds catch a runaway parameter. overlaps: 0 — the union must
199
+ // leave one watertight body, no interpenetrating leftovers.
124
200
  propeller: { holes: 1, bbox: "<=[300,300,300]" },
125
201
  _view: { overlaps: 0 },
126
202
  },
package/types/kernel.d.ts CHANGED
@@ -401,7 +401,7 @@ export interface LoftSmoothOptions {
401
401
  sections: LoftSmoothSection[];
402
402
  /** Output ring count along the spine (default 8 per span + 1; closed: 8 per section; capped at 1024). */
403
403
  stations?: number;
404
- /** Output vertex count around each ring (default max(64, largest section)). */
404
+ /** Output vertex count around each ring (default max(64, largest section), capped at 2048). */
405
405
  samples?: number;
406
406
  shading?: "smooth" | "faceted";
407
407
  /** Capless loop — Manifold only, ≥3 sections. */