partforge 0.85.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.85.3",
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",
@@ -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
  },