partforge 0.51.0 → 0.53.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.
@@ -312,7 +312,8 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
312
312
  | `k.roundedCylinder({ r\|d, h, center?, round })` | cylinder with rounded rims — `round` = number (both) or `{ top?, bottom? }`; `round: r` with `top+bottom = h` gives a sphere (capsule when `h > 2r`); one lathe revolve, curve-exact in STEP |
313
313
  | `k.torus({ rMajor, rMinor })` | torus centered at the origin (tube centerline in z=0); `0 < rMinor < rMajor` |
314
314
  | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
315
- | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
315
+ | `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` |
316
+ | `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) |
316
317
  | `k.union(solids[])` | boolean union |
317
318
 
318
319
  **`loft` rings** — each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
@@ -1036,6 +1037,55 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
1036
1037
  body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
1037
1038
  ```
1038
1039
 
1040
+ **Helical & threaded features** (screws, threads, bolts, worms, helical ridges):
1041
+
1042
+ Use `k.screwSweep({ profile, pitch, turns })`. The profile is an **axial**
1043
+ `[[r, z]]` contour — the shape you would see slicing the thread down its axis —
1044
+ exactly `k.revolve`'s convention, with an axial rise added.
1045
+
1046
+ The strongly preferred form is **periodic**: span exactly one `pitch`, start and
1047
+ end at the same radius. That makes the cross-section enclose the axis, so one op
1048
+ gives you the whole threaded body — no union with a core cylinder, which is both
1049
+ faster and avoids a boolean the B-rep backend handles badly
1050
+ ([screw-thread-vanishes-on-occt](ERROR-PATTERNS.md#screw-thread-vanishes-on-occt)).
1051
+
1052
+ ```js
1053
+ // an ISO-ish M10x1.5 external thread: 60° flanks, crest flat P/8, root flat P/4
1054
+ const pitch = 1.5, majorR = 5;
1055
+ const rootR = majorR - (5 / 8) * (Math.sqrt(3) / 2) * pitch;
1056
+ const crest = pitch / 8, root = pitch / 4;
1057
+ const rise = (pitch - crest - root) / 2;
1058
+ const rod = k.screwSweep({
1059
+ profile: [
1060
+ [rootR, 0],
1061
+ [rootR, root], // root flat
1062
+ [majorR, root + rise], // up the flank
1063
+ [majorR, root + rise + crest], // crest flat
1064
+ [rootR, pitch], // down the flank, back to the start radius
1065
+ ],
1066
+ pitch, turns: 6,
1067
+ });
1068
+ ```
1069
+
1070
+ The ends are flat z-planes, which is what a threaded rod wants; intersect a cone
1071
+ for a lead-in chamfer. For a bolt, build the head as its own solid — that is what
1072
+ **`src/parts/screw.js`** does, the worked example for this recipe: an ISO-style
1073
+ metric bolt, periodic thread plus a hex head, presets and all.
1074
+
1075
+ Cost scales with `turns` (= `length / pitch`), and steeply: the section is
1076
+ resampled every 5° of the twist, so an M10×1.5 shank costs ~10.5k triangles per
1077
+ turn. A 30 mm shank is 20 turns and about half a second on Manifold; hundreds of
1078
+ turns is millions of triangles and minutes behind the STEP button. Bound the
1079
+ `length` and `pitch` your schema exposes accordingly.
1080
+
1081
+ The hand-rolled equivalent, for the record: `screwSweep` is
1082
+ `k.extrude({ profile, h, twist })` with the axial profile remapped to polar
1083
+ (`ψ = −360·z/pitch`) and `twist = 360 · turns` — one full turn of twist per pitch
1084
+ of height *is* screw motion. The op exists because that identity is easy to
1085
+ want and hard to find, and because the remap must be densified (see
1086
+ `geometry/screw-profile.js`) or the chords between profile points cut deep into
1087
+ the tooth.
1088
+
1039
1089
  ## 2-D booleans
1040
1090
 
1041
1091
  `k.shape2d(profile)` lifts a point list, arc profile, or region into a `Shape2D` — an opaque 2-D boolean value. You can then compose booleans, and feed the result directly to `extrude` or `revolve` without materializing intermediate regions. The same `content-hash caching` discipline applies: identical arguments produce identical geometry.
@@ -1200,9 +1250,14 @@ instead of (or in addition to) the built-in `#part` bar:
1200
1250
  `viewName` rendered offscreen (falling back to the resolved default view — see
1201
1251
  `resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
1202
1252
  part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
1203
- scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
1204
- failure rather than throwing (a build error, a part with no sub-parts in that view, a
1205
- disposed runtime).
1253
+ scene; `opts` forwards to the underlying render (size, quality, angle, background).
1254
+ Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
1255
+ in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
1256
+ no colour from the viewer's light/dark theme: it gets a fixed neutral grey, on the
1257
+ reasoning that a thumbnail is captured once and then displayed under host chrome
1258
+ partforge cannot see. Pass `background` (any `THREE.Color`-compatible value) to choose
1259
+ your own, or `background: null` for no background at all — which clears to opaque black
1260
+ unless the embedder has set a clear colour.
1206
1261
 
1207
1262
  Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
1208
1263
  synchronously during mount with the initial resolved view (before `runtime.ready` settles),
@@ -361,6 +361,52 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
361
361
  - **Cause:** `defaults[key]` is not among the control's `options` values — often a value-type mismatch (`12` is not `"12"`).
362
362
  - **Fix:** Add the default to `options`, or change the default to one of the existing options; `npx partforge lint` errors via `select-default-not-in-options`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
363
363
 
364
+ ## screw-thread-vanishes-on-occt
365
+
366
+ - **Symptom:** a threaded part previews correctly but its STEP export is a plain
367
+ cylinder, or a valid-looking but implausibly small STEP file (~2 KB, a few
368
+ dozen entities, where a real thread is megabytes) that opens with no solid
369
+ geometry; on the OCCT backend the union of a thread with a core returns
370
+ exactly the core's volume, or `0`, with no error thrown.
371
+ - **Cause:** the thread was built as a thin sub-pitch helical sliver and unioned
372
+ onto a core. OCCT's boolean fails on a near-self-touching swept operand and
373
+ silently returns the other operand — or nothing — rather than throwing.
374
+ - **Fix:** build the thread in the **periodic** form instead — a profile spanning
375
+ exactly one `pitch` with equal first and last radius encloses the axis, so
376
+ `k.screwSweep` yields the whole threaded body with no boolean at all. See
377
+ [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Helical & threaded features".
378
+
379
+ The hazard is specific to that sliver-riding-a-core shape, not to unions
380
+ involving screw geometry in general: a filled periodic `screwSweep` rod
381
+ unioned with an unrelated solid — a bolt head, say — booleans correctly. A
382
+ measured rod (585.545) unioned with a head (804.248) returned 1324.732 —
383
+ inside the geometrically expected range, not the bare-rod or empty-solid
384
+ signature above. It's the thin near-self-touching sliver that OCCT's boolean
385
+ mishandles, not screw geometry as such.
386
+
387
+ ## occt-bbox-too-large-on-twist
388
+
389
+ - **Symptom:** `solid.boundingBox()` inside a `build()` reports a solid far larger
390
+ than it is — on a twisted solid (`extrude`/`prism` with `twist`, or
391
+ `k.screwSweep`) whose true max radius is 5, OCCT reports **7.209** where
392
+ Manifold reports **5.000** — so anything placed off that query lands ~44% too
393
+ far out in the STEP export while the preview looks right. The axial extent is
394
+ exact; it is the twisted directions that inflate.
395
+ - **Cause:** OCCT derives the bounding box of a twisted B-spline surface from its
396
+ **control hull**, not from the surface. The control points of a twisted section
397
+ bow outward, so the box is a valid outer bound but a loose one. Volume and the
398
+ meshed surface are exact; only the bbox query is loose.
399
+ - **Fix:** don't place geometry off `solid.boundingBox()` on a twisted solid —
400
+ compute the extent from the parameters that built it (they are right there in
401
+ `p`/`d`), or bound the twisted part with an untwisted proxy solid.
402
+
403
+ **The `measure` / `verify` gate is not affected**: `src/framework/oracle/measure.js`
404
+ takes its bbox from `bounds(mesh.positions)` — the meshed surface — never from
405
+ `solid.boundingBox()`, so `bbox` assertions read 5.000 on both backends. The
406
+ exposure is a `build()` that queries a twisted solid's box itself, which is the
407
+ normal idiom for placing something relative to a solid and now silently disagrees
408
+ between the Manifold preview and the OCCT STEP export.
409
+
364
410
  # Hardware library
365
411
 
366
412
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -131,9 +131,9 @@ for free.
131
131
  | `loft` | `{rings, ruled?, closed?}` | `(rings, {ruled?,closed?})` |
132
132
  | `sweep` | `{profile, path, closed?, cornerRadius?, ruled?, smooth?}` | `(profile2D, path3D, opts?)` |
133
133
 
134
- `boredCylinder` and `helixSweptTube` were always options-only (no positional
135
- legacy form exists); they get the same unknown-key / required-key validation as
136
- the ops above.
134
+ `boredCylinder`, `helixSweptTube` and `screwSweep` were always options-only (no
135
+ positional legacy form exists); they get the same unknown-key / required-key
136
+ validation as the ops above.
137
137
  `union(solids[])` and `toSTEP(named[])` take a single array — unchanged.
138
138
 
139
139
  ### Solid ops
@@ -186,7 +186,8 @@ above. All ops return a `Solid`.
186
186
  | `revolve({profile, degrees?})` | Revolve a lathe profile `[[r, z], …]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. |
187
187
  | `loft({rings, ruled?, closed?})` | Stack polygon cross-sections (per-ring `z`/`rotate`/`scale`, equal vertex counts) with ruled walls and capped ends. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
188
188
  | `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. |
189
- | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). |
189
+ | `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`. |
190
+ | `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). |
190
191
  | `union(solids[])` | Boolean union of one or more solids. |
191
192
  | `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. |
192
193
  | `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.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,16 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
6
+ import screwPart from "./parts/screw.js";
7
+ import { mount } from "./framework/index.js";
8
+
9
+ // Dev-only example app for the screw reference part. `npm run dev`, then open /screw.html.
10
+ // The `new Worker(new URL(...))` call must stay inline here or Vite will not bundle it.
11
+ // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
12
+ // the embedding contract (runtime.captureCurrent) the way an embedder would.
13
+ window.__pfRuntime = mount(screwPart, {
14
+ createWorker: (name) =>
15
+ new Worker(new URL("./screw-worker.js", import.meta.url), { type: "module", name }),
16
+ });
@@ -29,6 +29,7 @@ import { beveledExtrude } from "./rim-bevel.js";
29
29
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
30
30
  import { convexHull, hullPoints } from "./hull.js";
31
31
  import { latheRoundedRect, torusContour } from "./rounded-solids.js";
32
+ import { screwCrossSection } from "./screw-profile.js";
32
33
 
33
34
  export function finishKernel(k) {
34
35
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -47,6 +48,18 @@ export function finishKernel(k) {
47
48
  k.torus ??= ({ rMajor, rMinor }) =>
48
49
  k.revolve({ profile: k.shape2d(torusContour(rMajor, rMinor)) });
49
50
 
51
+ // Compound default: a screw-motion sweep of an axial [[r, z]] profile. Exactly
52
+ // k.extrude with a polar-remapped section and one full turn of twist per pitch
53
+ // (see screw-profile.js for why that identity holds, and why the profile must be
54
+ // densified first). No backend override: both backends twist natively, so this
55
+ // is one implementation and STEP gets a real twisted B-rep rather than a loft.
56
+ k.screwSweep ??= ({ profile, pitch, turns, lefthand = false }) =>
57
+ k.extrude({
58
+ profile: screwCrossSection(profile, pitch, { lefthand }),
59
+ h: pitch * turns,
60
+ twist: (lefthand ? -360 : 360) * turns,
61
+ });
62
+
50
63
  for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
51
64
  const raw = k[op];
52
65
  if (!raw) continue;
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
19
19
  // Ops every backend kernel must implement.
20
20
  export const KERNEL_OPS = [
21
21
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
22
- "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
22
+ "loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
23
  "roundedCylinder", "torus", "roundedBox",
24
24
  ];
25
25
 
@@ -110,6 +110,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
110
110
  * @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
111
111
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
112
112
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
113
+ * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
113
114
  * @property {(solids:Solid[]) => Solid} union
114
115
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
115
116
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
@@ -253,6 +253,13 @@ export const KERNEL_OP_SPECS = {
253
253
  boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
254
254
  helixSweptTube: { toArgs: passThrough("helixSweptTube",
255
255
  ["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
256
+ screwSweep: {
257
+ toArgs: passThrough("screwSweep", ["profile", "pitch", "turns", "lefthand"], ["profile", "pitch", "turns"]),
258
+ check: (o) => {
259
+ if (!(o.pitch > 0)) throw new Error("screwSweep: pitch must be > 0");
260
+ if (!(o.turns > 0)) throw new Error("screwSweep: turns must be > 0");
261
+ },
262
+ },
256
263
  roundedBox: { toArgs: roundedBoxArgs },
257
264
  roundedCylinder: { toArgs: roundedCylinderArgs },
258
265
  torus: { toArgs: torusArgs },
@@ -0,0 +1,88 @@
1
+ // Screw motion as a transverse cross-section. A profile point (r, z) in the axial
2
+ // half-plane travels to (r·cos θ, r·sin θ, z + pitch·θ/2π) under screw motion, and
3
+ // that whole solid is reproduced EXACTLY by extruding a polar-remapped section with
4
+ // twist = 360°·turns — one full turn of twist per pitch of height. So screwSweep
5
+ // needs no backend op: it is k.extrude in disguise (see kernel-front.js).
6
+ //
7
+ // The subtlety that makes this correct rather than nearly-correct: the map sends
8
+ // profile POINTS to polar, but the EDGES between them become straight chords where
9
+ // the true surface needs spiral arcs. Undensified, an ISO tooth loses ~42% of its
10
+ // volume. So every segment is subdivided to a fixed 5° polar step — fixed, not a
11
+ // per-call tolerance, so both backends see the identical polygon and the solid
12
+ // cache keys stay stable. "Every segment" includes the contour's implicit closing
13
+ // edge, except in the periodic case where that edge is a single polar point.
14
+
15
+ // Degrees of polar sweep per emitted point. Matches Manifold's twist division
16
+ // resolution (nDiv = ceil(|twist|/5) in manifold-backend.js), so the angular and
17
+ // axial sampling of the same solid agree. Converges to 0.03% of the exact volume.
18
+ export const SCREW_STEP_DEG = 5;
19
+
20
+ const EPS = 1e-9;
21
+
22
+ export function screwCrossSection(profile, pitch, { lefthand = false } = {}) {
23
+ if (!Array.isArray(profile) || profile.length < 2)
24
+ throw new Error("screwSweep: profile must be an array of at least 2 [r, z] points");
25
+ if (!(pitch > 0)) throw new Error("screwSweep: pitch must be > 0");
26
+ for (const p of profile) {
27
+ if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
28
+ throw new Error("screwSweep: every profile point must be a finite [r, z]");
29
+ if (p[0] < 0) throw new Error("screwSweep: profile radius must be ≥ 0");
30
+ }
31
+
32
+ const zs = profile.map(([, z]) => z);
33
+ const extent = Math.max(...zs) - Math.min(...zs);
34
+ if (extent > pitch + EPS)
35
+ throw new Error(
36
+ `screwSweep: profile axial extent ${extent} exceeds pitch ${pitch} — consecutive ` +
37
+ "turns would interpenetrate; reduce the profile height or increase pitch");
38
+
39
+ const n = profile.length;
40
+ const first = profile[0], last = profile[n - 1];
41
+ const periodic = extent > pitch - EPS;
42
+
43
+ // Subdivide by POLAR span, not by length: a segment with no z change sweeps no
44
+ // angle and needs no extra points.
45
+ const dense = [];
46
+ const densify = ([r0, z0], [r1, z1], { includeStart }) => {
47
+ const span = Math.abs((360 * (z1 - z0)) / pitch);
48
+ const steps = Math.max(1, Math.ceil(span / SCREW_STEP_DEG));
49
+ for (let j = includeStart ? 0 : 1; j < steps; j++)
50
+ dense.push([r0 + ((r1 - r0) * j) / steps, z0 + ((z1 - z0) * j) / steps]);
51
+ };
52
+ for (let i = 0; i < n - 1; i++) densify(profile[i], profile[i + 1], { includeStart: true });
53
+ dense.push(last);
54
+
55
+ // A profile spanning exactly one pitch closes on itself by periodicity: its last
56
+ // point maps to the same polar angle as its first, so it must agree in radius and
57
+ // the duplicate is dropped (a zero-length edge would otherwise reach the backend).
58
+ // Nothing is densified between them — they ARE the same polar point, and a
59
+ // densified edge would trace a spurious full circle back around the axis.
60
+ if (periodic) {
61
+ if (Math.abs(last[1] - first[1]) < pitch - EPS)
62
+ throw new Error(
63
+ `screwSweep: a full-pitch profile must start and end at its extreme z values — ` +
64
+ `the first and last points span ${Math.abs(last[1] - first[1])}, not the full ` +
65
+ `pitch ${pitch}; reorder the profile so it opens and closes on the wrap`);
66
+ if (Math.abs(first[0] - last[0]) > 1e-6)
67
+ throw new Error(
68
+ `screwSweep: a full-pitch profile must be periodic — first radius ${first[0]} ` +
69
+ `must equal last radius ${last[0]}`);
70
+ dense.pop();
71
+ } else {
72
+ // Sub-pitch: the contour's implicit closing edge (last → first) is a real edge
73
+ // spanning real polar angle, so it needs the same treatment as every other one.
74
+ // Undensified it is a straight chord across the unused part of the pitch, which
75
+ // turns a slim ridge into a twisted half-disc. `first` already opens the
76
+ // contour, so only the intermediate points are appended.
77
+ densify(last, first, { includeStart: false });
78
+ }
79
+
80
+ const sign = lefthand ? 1 : -1;
81
+ return dense.map(([r, z]) => {
82
+ const psi = (sign * 2 * Math.PI * z) / pitch;
83
+ const x = r * Math.cos(psi);
84
+ let y = r * Math.sin(psi);
85
+ if (y === 0) y = 0; // Normalize -0 to 0
86
+ return [x, y];
87
+ });
88
+ }
@@ -644,7 +644,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
644
644
  // renders it in a throwaway scene via viewer.renderMeshPayloads. Never
645
645
  // touches the active tab, getView(), or the live scene — best-effort: any
646
646
  // failure, including a resolved-null from a worker build failure (4A
647
- // settles rather than throwing), returns null.
647
+ // settles rather than throwing), returns null. `opts` is spread last, so
648
+ // renderMeshPayloads' own options (including `background`) pass straight
649
+ // through from the caller.
648
650
  const captureView = async (viewName, opts = {}) => {
649
651
  try {
650
652
  const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
@@ -57,6 +57,36 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
57
57
  }
58
58
  }
59
59
 
60
+ // The off-loop thumbnail capture (renderMeshPayloads, behind the handle's
61
+ // captureView) renders a THROWAWAY scene, so it gets no background from the
62
+ // live scene's theme — and before this constant existed it set none at all,
63
+ // which meant every thumbnail came back on the renderer's default opaque
64
+ // black, in light mode as much as dark. One deliberately theme-INDEPENDENT
65
+ // colour is the right answer rather than either THEME entry below: a thumbnail
66
+ // is baked at capture time and displayed later under host chrome this renderer
67
+ // cannot know (partforge-cloud's card grid draws them on both). Near the
68
+ // perceptual midpoint of THEME.light.bg / THEME.dark.bg, so it commits to
69
+ // neither, and clear of both the part material (0x9fb4cc, lighter) and the
70
+ // feature-edge lines (0x1c232d, much darker).
71
+ //
72
+ // The near-ZERO chroma is the part that looks arbitrary and isn't: the default
73
+ // part material is blue-grey, so a blue-grey background of the same value
74
+ // (0x6b7280 was the first try) competes with it and the shaded side of a part
75
+ // half-disappears into the plate. A neutral grey separates by hue as well as
76
+ // value. Judged on real captures of demo.js and hinged-box.js — if this is
77
+ // ever retuned, retune it the same way and not by eye on the hex.
78
+ export const THUMBNAIL_BG = 0x6e6e73;
79
+
80
+ // Resolve renderMeshPayloads' `background` option to what Scene.background
81
+ // wants. Exported for its own sake: renderMeshPayloads needs a GL context and
82
+ // so is untestable directly, and this is the whole of the decision. `null` is
83
+ // a real escape hatch — the pre-existing no-background behaviour, clearing to
84
+ // the renderer's clear colour — so it is passed through rather than treated as
85
+ // "unset"; only `undefined` (an absent option) takes the default.
86
+ export function thumbnailBackground(background = THUMBNAIL_BG) {
87
+ return background === null ? null : new THREE.Color(background);
88
+ }
89
+
60
90
  // Render the LIVE camera's current framing offscreen, once, at a caller-chosen
61
91
  // resolution — the showcase capture behind the runtime handle's captureCurrent.
62
92
  // Same injected-renderer split as captureViewsFromScene so it runs without a GL
@@ -646,12 +676,18 @@ export function createViewer(container, part) {
646
676
  // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
647
677
  // Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
648
678
  // canonical angle, renders through the parameterized renderOffscreen, and disposes
649
- // everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
650
- // is the worker's [{name, positions, normals, indices, …}] array placement is
651
- // already baked into shared-frame coords, so meshes are NOT recentred.
652
- function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
679
+ // everything. Never touches the live scene, camera, subMesh, or subCache. The scene
680
+ // gets THUMBNAIL_BG unless `background` says otherwise (`null` = no background, the
681
+ // renderer's clear colour). `payloads` is the worker's [{name, positions, normals,
682
+ // indices, …}] array placement is already baked into shared-frame coords, so
683
+ // meshes are NOT recentred.
684
+ function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8, background } = {}) {
653
685
  if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
654
686
  const tmpScene = new THREE.Scene();
687
+ // Deliberately the throwaway scene's own background, never the live one's:
688
+ // this must not follow the viewer theme (see THUMBNAIL_BG) and must not
689
+ // reach the live-scene captures, which correctly do follow it.
690
+ tmpScene.background = thumbnailBackground(background);
655
691
  const tmpPivot = new THREE.Group();
656
692
  tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
657
693
  tmpScene.add(tmpPivot);
@@ -0,0 +1,82 @@
1
+ // Reference part for k.screwSweep — an ISO-style metric bolt. The thread uses the
2
+ // PERIODIC profile form (spans exactly one pitch, first radius == last radius), so
3
+ // one screwSweep call yields the whole threaded shank with no boolean against a
4
+ // core. See docs/AUTHORING-PARTS.md "Helical & threaded features".
5
+ export default {
6
+ meta: { title: "Screw", units: "mm", background: 0x15181d },
7
+ parameters: [
8
+ {
9
+ id: "thread",
10
+ title: "Thread",
11
+ description: "Nominal thread size. Pick a preset, or open **Advanced** for exact dimensions.",
12
+ presets: { M6: { major: 6, pitch: 1.0, length: 20 }, M10: { major: 10, pitch: 1.5, length: 30 } },
13
+ advanced: [
14
+ { key: "major", label: "Major diameter", unit: "mm", min: 4, max: 24, step: 0.5,
15
+ description: "Outside diameter measured across the thread crests." },
16
+ { key: "pitch", label: "Pitch", unit: "mm", min: 0.5, max: 3, step: 0.05, control: "number",
17
+ description: "Axial rise per turn. A coarse pitch on a small major diameter runs the root radius down toward zero, which is why Major diameter starts at 4 mm. The 0.5 mm floor is the ISO fine pitch for the smallest diameter offered here — and a floor is needed, because cost scales with turns = length / pitch." },
18
+ { key: "length", label: "Threaded length", unit: "mm", min: 5, max: 40, step: 1,
19
+ description: "Length of the threaded shank, excluding the head. Capped at 40 mm so the worst case reachable from these sliders — 40 mm at a 0.5 mm pitch, 80 turns — stays a couple of seconds of preview rather than minutes." },
20
+ { key: "lefthand", label: "Left-hand thread", control: "toggle",
21
+ description: "Reverses the helix. Rare outside gas fittings and bicycle pedals." },
22
+ ],
23
+ },
24
+ {
25
+ id: "head",
26
+ title: "Head",
27
+ description: "The hex head at the top of the shank.",
28
+ advanced: [
29
+ { key: "headAcross", label: "Head width across flats", unit: "mm", min: 0, max: 40, step: 0.5,
30
+ description: "Spanner size. Zero gives a headless threaded rod." },
31
+ { key: "headH", label: "Head height", unit: "mm", min: 1, max: 20, step: 0.5,
32
+ description: "Head thickness along the axis." },
33
+ ],
34
+ },
35
+ ],
36
+ defaults: { major: 10, pitch: 1.5, length: 30, lefthand: false, headAcross: 17, headH: 6.4 },
37
+ // derive(): the ISO 60-degree tooth, expressed as radii the build consumes directly.
38
+ derive: (p) => {
39
+ const H = (Math.sqrt(3) / 2) * p.pitch; // sharp-V height
40
+ const majorR = p.major / 2;
41
+ const rootR = majorR - (5 / 8) * H;
42
+ const rootFlat = p.pitch / 4, crestFlat = p.pitch / 8;
43
+ return {
44
+ majorR, rootR, rootFlat, crestFlat,
45
+ rise: (p.pitch - crestFlat - rootFlat) / 2,
46
+ turns: p.length / p.pitch,
47
+ headR: p.headAcross / Math.sqrt(3), // circumradius of a hex across flats
48
+ };
49
+ },
50
+ parts: {
51
+ screw: {
52
+ label: "Screw",
53
+ views: ["screw"],
54
+ export: { name: "screw" },
55
+ build: (k, p, d) => {
56
+ // Periodic profile: exactly one pitch tall, first radius == last radius.
57
+ const shank = k.screwSweep({
58
+ profile: [
59
+ [d.rootR, 0],
60
+ [d.rootR, d.rootFlat],
61
+ [d.majorR, d.rootFlat + d.rise],
62
+ [d.majorR, d.rootFlat + d.rise + d.crestFlat],
63
+ [d.rootR, p.pitch],
64
+ ],
65
+ pitch: p.pitch,
66
+ turns: d.turns,
67
+ lefthand: p.lefthand,
68
+ });
69
+ if (p.headAcross <= 0) return shank;
70
+ const head = k.prism({ points: hexPoints(d.headR), h: p.headH }).at([0, 0, p.length]);
71
+ return shank.union(head);
72
+ },
73
+ },
74
+ },
75
+ views: { screw: { label: "Screw" } },
76
+ };
77
+
78
+ const hexPoints = (r) =>
79
+ Array.from({ length: 6 }, (_, i) => {
80
+ const a = (Math.PI / 3) * i;
81
+ return [r * Math.cos(a), r * Math.sin(a)];
82
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/screw.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
package/types/kernel.d.ts CHANGED
@@ -298,6 +298,17 @@ export interface HelixSweptTubeOptions {
298
298
  lefthand: boolean;
299
299
  }
300
300
 
301
+ /** `k.screwSweep` — an axial lathe profile `[[r, z], …]` swept by screw motion. */
302
+ export interface ScrewSweepOptions {
303
+ /** Closed axial contour; axial extent must not exceed `pitch`. */
304
+ profile: number[][];
305
+ /** Axial rise per turn, mm. */
306
+ pitch: number;
307
+ /** Number of turns swept; total height is `pitch * turns`. Cost scales with it. */
308
+ turns: number;
309
+ lefthand?: boolean;
310
+ }
311
+
301
312
  export interface RoundedCylinderOptions {
302
313
  r?: number;
303
314
  d?: number;
@@ -376,6 +387,8 @@ export interface GeometryKernel {
376
387
  /** Sweep a 2-D profile along a 3-D polyline. */
377
388
  sweep(o: SweepOptions): Solid;
378
389
  helixSweptTube(o: HelixSweptTubeOptions): Solid;
390
+ /** Sweep an axial lathe profile by screw motion — threads. */
391
+ screwSweep(o: ScrewSweepOptions): Solid;
379
392
  /** Rim round-overs via one lathe revolve; curve-exact in STEP. */
380
393
  roundedCylinder(o: RoundedCylinderOptions): Solid;
381
394
  torus(o: TorusOptions): Solid;