partforge 0.20.1 → 0.22.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.
@@ -316,24 +316,36 @@ changing any of them is a fresh cache node while an identical rebuild is a hit.
316
316
  `parameters` is an **array of sections**; the framework builds the panel from it and
317
317
  binds each control to a key in `defaults`. Two section kinds:
318
318
 
319
- **Preset + sliders section:**
319
+ **Preset + controls section:**
320
320
 
321
321
  ```js
322
322
  {
323
323
  id: "body",
324
324
  title: "Body",
325
325
  presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } }, // name → param overrides
326
- advanced: [ // sliders revealed under "Advanced"
326
+ advanced: [ // controls revealed under "Advanced"
327
327
  { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
328
328
  { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
329
+ { key: "title", label: "Title", control: "text" },
330
+ { key: "label", label: "Label", control: "textarea" },
329
331
  ],
330
332
  }
331
333
  ```
332
334
 
333
- Each slider/feature control shows an **editable number box** beside it — drag the
335
+ Numeric slider/feature controls show an **editable number box** beside them — drag the
334
336
  slider or type an exact value (finer than `step` is allowed; typed values clamp to
335
- `[min, max]`). Optional `control` per parameter: omit it (or `"slider"`) for a slider
336
- + box; `"number"` for a box only (no slider — handy for precise or wide-range values).
337
+ `[min, max]`). Optional `control` per parameter chooses the input:
338
+
339
+ - omit it (or use `"slider"`) for a slider + number box;
340
+ - `"number"` for a number box only (handy for precise or wide-range values);
341
+ - `"text"` for a single-line string field;
342
+ - `"textarea"` for a multiline string field whose line breaks are preserved.
343
+
344
+ Text fields update `params` live on every edit, so the existing rebuild loop previews
345
+ the new string immediately. Give every text key a string value in `defaults`; empty
346
+ strings are valid control values, while the part's build function decides whether its
347
+ geometry supports them. Editing any control in a preset section selects `Custom`, and
348
+ choosing a preset updates both numeric and text fields.
337
349
 
338
350
  **Feature-toggle section** (checkbox enables a feature + reveals its sliders; `0` = off):
339
351
 
@@ -492,10 +504,15 @@ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes o
492
504
  // Keyhole plate: union a disc onto a rect, punch a slot, extrude.
493
505
  const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
494
506
  .union(circleProfile(8))
495
- .cut(slotPolygon(16, 3));
496
- k.extrude({ profile: plate, h: 3 });
507
+ .cut(slotPolygon(16, 3))
508
+ .extrude({ h: 3 }); // sugar for k.extrude({ profile: …, h: 3 }); .revolve({ degrees }) too
497
509
  ```
498
510
 
511
+ A `Shape2D` also carries `.extrude({ h, twist?, scaleTop? })` and `.revolve({ degrees? })`
512
+ sugar (equivalent to the `k.extrude`/`k.revolve` forms), and `.regions()` — scission, which
513
+ returns each disjoint region as its own live `Shape2D` (vs `.toRegions()`, which returns raw
514
+ `{outer, holes}` data).
515
+
499
516
  ```js
500
517
  // A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
501
518
  const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
@@ -506,6 +523,19 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
506
523
 
507
524
  `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
508
525
 
526
+ ## Convex hull
527
+
528
+ `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
529
+ convex `Shape2D`. `k.hullChain([a, b, c, …])` sweeps the hull along an ordered sequence
530
+ (≥2 inputs) — the union of `hull([a,b])`, `hull([b,c])`, … — for capsules, rounded slots,
531
+ and organic tapers. Faceted (curved inputs facet at mesh LOD): the hull is a pure-JS
532
+ monotone-chain computation, never a native backend op.
533
+
534
+ ```js
535
+ const capsule = k.hull([circleProfile(4, [0, 0]), circleProfile(4, [20, 0])]); // a stadium
536
+ const slot = k.hullChain([circleProfile(3, [0, 0]), circleProfile(3, [15, 0]), circleProfile(2, [25, 5])]);
537
+ ```
538
+
509
539
  ## Text (`text2d`)
510
540
 
511
541
  `k.text2d(string, { size, font?, align?, valign?, lineHeight?, tracking?, kerning? })` renders outline-font text as a `Shape2D` — a 2-D boolean you can compose with other shapes (union / cut / offset) and extrude into 3-D geometry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.20.1",
3
+ "version": "0.22.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,9 @@
1
+ import part from "./parts/hull-sweep.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev example app for the hull-sweep demo (src/parts/hull-sweep.js). The
5
+ // `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
6
+ mount(part, {
7
+ createWorker: (name) =>
8
+ new Worker(new URL("./hull-sweep-worker.js", import.meta.url), { type: "module", name }),
9
+ });
@@ -81,6 +81,14 @@ select.preset {
81
81
  .row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
82
82
  .row .num { -moz-appearance: textfield; }
83
83
  .row .unit { font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px; }
84
+ .text-input {
85
+ display: block; width: 100%; font: 12px/1.4 var(--pf-mono);
86
+ background: var(--pf-input-bg); color: var(--pf-text-strong);
87
+ border: 1px solid var(--pf-border); border-radius: 6px; padding: 6px 8px;
88
+ }
89
+ textarea.text-input { min-height: 64px; resize: vertical; }
90
+ .text-input:focus { outline: none; border-color: var(--pf-accent);
91
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
84
92
 
85
93
  /* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
86
94
  input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
@@ -113,6 +113,8 @@ function el(tag, className, text) {
113
113
  // One parameter control bound to params[def.key]. `def.control`:
114
114
  // "slider" (default) — range slider + an editable number box (drag OR type)
115
115
  // "number" — number box only (no slider)
116
+ // "text" — single-line text field
117
+ // "textarea" — multiline text field
116
118
  // The box accepts exact values (finer than `step`); typed values clamp to
117
119
  // [min, max] on commit (blur/Enter). Returns { wrap, sync }.
118
120
  function makeSlider(def, params, onChange, info) {
@@ -173,6 +175,34 @@ function makeSlider(def, params, onChange, info) {
173
175
  return { wrap, sync };
174
176
  }
175
177
 
178
+ function makeTextControl(def, params, onChange, info) {
179
+ const multiline = def.control === "textarea";
180
+ const wrap = el("div", "slider");
181
+ const row = el("div", "row");
182
+ const label = el("label", "", def.label);
183
+ attachInfo(label, def.description, info);
184
+ row.append(label);
185
+ wrap.append(row);
186
+
187
+ const field = document.createElement(multiline ? "textarea" : "input");
188
+ if (!multiline) field.type = "text";
189
+ field.className = "text-input";
190
+ field.value = String(params[def.key] ?? "");
191
+ field.addEventListener("input", () => {
192
+ params[def.key] = field.value;
193
+ onChange?.();
194
+ });
195
+ wrap.append(field);
196
+
197
+ const sync = () => { field.value = String(params[def.key] ?? ""); };
198
+ return { wrap, sync };
199
+ }
200
+
201
+ const makeParameterControl = (def, params, onChange, info) =>
202
+ def.control === "text" || def.control === "textarea"
203
+ ? makeTextControl(def, params, onChange, info)
204
+ : makeSlider(def, params, onChange, info);
205
+
176
206
  // A collapsible "Advanced ▾" block. Returns { adv, toggle }.
177
207
  function advancedBlock() {
178
208
  const adv = el("div", "adv hidden");
@@ -242,7 +272,7 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
242
272
  if (advanced.length) {
243
273
  const { adv, toggle } = advancedBlock();
244
274
  for (const def of advanced) {
245
- const s = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
275
+ const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
246
276
  adv.append(s.wrap);
247
277
  syncs[def.key] = s.sync;
248
278
  register(def.key, s.wrap);
@@ -281,7 +311,7 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
281
311
  const group = el("div", "feat-group");
282
312
  const syncs = [];
283
313
  for (const def of feat.sliders.filter((d) => !d.hidden)) {
284
- const s = makeSlider(def, params, onDirty, info);
314
+ const s = makeParameterControl(def, params, onDirty, info);
285
315
  group.append(s.wrap);
286
316
  syncs.push(s.sync);
287
317
  register(def.key, s.wrap);
@@ -287,6 +287,11 @@ export function createCutawayGizmo({
287
287
 
288
288
  function pick(event, ray) {
289
289
  if (pickHandle) return resolveHandle(pickHandle(event, handles, ray));
290
+ handleRoot.updateWorldMatrix(true, true);
291
+ const intersection = raycaster.intersectObjects(hitProxies, false)[0];
292
+ const intersectedHandle = resolveHandle(intersection);
293
+ if (intersectedHandle) return intersectedHandle;
294
+
290
295
  const center = projectToClient(group.position);
291
296
  if (center) {
292
297
  const dx = event.clientX - center.x;
@@ -296,9 +301,7 @@ export function createCutawayGizmo({
296
301
  return "translate";
297
302
  }
298
303
  }
299
- handleRoot.updateWorldMatrix(true, true);
300
- const intersection = raycaster.intersectObjects(hitProxies, false)[0];
301
- return resolveHandle(intersection);
304
+ return null;
302
305
  }
303
306
 
304
307
  function safeCapture(pointerId) {
@@ -9,12 +9,16 @@
9
9
  // 4. unite(self) to normalize overlaps and crossings into simple paths.
10
10
  import paper from "paper/dist/paper-core.js";
11
11
 
12
- // Never use paper's package-global project: another consumer in the same worker may import
13
- // paper too. This resolver owns and clears only this private, headless scope.
14
- const scope = new paper.PaperScope();
15
- scope.setup(new scope.Size(1, 1));
12
+ // Lazy, private PaperScope: built on first use (not at module load), so parts that never
13
+ // call k.text2d don't pull paper-core's setup onto the geometry worker. Never paper's
14
+ // package-global project another consumer in the same worker may import paper too.
15
+ let _scope = null;
16
+ function paperScope() {
17
+ if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
18
+ return _scope;
19
+ }
16
20
 
17
- function toPaperPath(contour) {
21
+ function toPaperPath(scope, contour) {
18
22
  const path = new scope.Path({ insert: false });
19
23
  path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
20
24
  for (const s of contour.segments) {
@@ -67,10 +71,11 @@ export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
67
71
  if (fillRule !== "nonzero" && fillRule !== "evenodd")
68
72
  throw new Error('curve-fill: fillRule must be "nonzero" or "evenodd"');
69
73
  if (!contours || contours.length === 0) return [];
74
+ const scope = paperScope();
70
75
  try {
71
76
  const simple = [];
72
77
  for (const ct of contours) {
73
- const resolved = toPaperPath(ct).resolveCrossings();
78
+ const resolved = toPaperPath(scope, ct).resolveCrossings();
74
79
  const kids = resolved.className === "CompoundPath" ? resolved.children : [resolved];
75
80
  for (const k of kids) if (k.segments && k.segments.length >= 2) simple.push(k.clone({ insert: false }));
76
81
  }
@@ -0,0 +1,50 @@
1
+ // Pure, backend-free 2-D convex hull (Andrew's monotone chain) + input sampling for
2
+ // k.hull / k.hullChain (wired in kernel-front). No WASM, no kernel — a pure function
3
+ // of its inputs, so hull output for point-list/contour inputs is backend-independent.
4
+ import { tessellateContour } from "./profile.js";
5
+
6
+ // Fixed LOD for curve-contour inputs. Sampling in pure JS (not via a backend's
7
+ // materialization) is what makes point/contour hull results bit-identical across backends.
8
+ const HULL_SEGS = 64;
9
+
10
+ // One HullInput → its contributing points.
11
+ // Shape2D → its materialized boundary rings (outer + holes; holes are interior
12
+ // to a convex hull, harmless);
13
+ // curve contour → tessellated at a fixed LOD (pure JS);
14
+ // point list → used as-is (any length ≥ 1; e.g. circleProfile's 48-gon).
15
+ export function hullPoints(input) {
16
+ if (input && input._shape2d)
17
+ return input.toRegions().flatMap((r) => [...r.outer, ...r.holes.flat()]);
18
+ if (Array.isArray(input) && input.length > 0 && Array.isArray(input[0]))
19
+ return input;
20
+ if (input && Array.isArray(input.segments))
21
+ return tessellateContour(input, HULL_SEGS);
22
+ throw new Error("hull: each input must be a Shape2D, a curve contour, or an [[x,y],…] point list");
23
+ }
24
+
25
+ // Convex hull of a point set → CCW convex polygon [[x,y],…]. Andrew's monotone chain,
26
+ // O(n log n). Drops interior and on-edge points (strict turns only), so a collinear set
27
+ // collapses to < 3 vertices → throw (it cannot bound a 2-D region).
28
+ export function convexHull(points) {
29
+ const seen = new Set();
30
+ const pts = [];
31
+ for (const p of points) {
32
+ const key = `${p[0]},${p[1]}`;
33
+ if (!seen.has(key)) { seen.add(key); pts.push([p[0], p[1]]); }
34
+ }
35
+ if (pts.length < 3) throw new Error(`hull: need ≥3 distinct points, got ${pts.length}`);
36
+ pts.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
37
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
38
+ const half = (src) => {
39
+ const out = [];
40
+ for (const p of src) {
41
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
42
+ out.push(p);
43
+ }
44
+ out.pop(); // last point is shared with the other half's first
45
+ return out;
46
+ };
47
+ const hull = half(pts).concat(half([...pts].reverse()));
48
+ if (hull.length < 3) throw new Error("hull: points are collinear — no 2-D region");
49
+ return hull;
50
+ }
@@ -17,6 +17,7 @@ import { KernelCapabilityError } from "./errors.js";
17
17
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
18
18
  import { textGlyphs } from "./text2d.js";
19
19
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
20
+ import { convexHull, hullPoints } from "./hull.js";
20
21
 
21
22
  export function finishKernel(k) {
22
23
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -88,5 +89,24 @@ export function finishKernel(k) {
88
89
  return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
89
90
  };
90
91
 
92
+ // Convex hull → Shape2D. Backend-agnostic: pure-JS monotone-chain hull of the inputs'
93
+ // sampled points, lifted via k.shape2d. Faceted (curved inputs at a fixed LOD).
94
+ k.hull = (inputs) => {
95
+ if (!Array.isArray(inputs) || inputs.length === 0)
96
+ throw new Error("hull: inputs must be a non-empty array");
97
+ return k.shape2d(convexHull(inputs.flatMap(hullPoints)));
98
+ };
99
+ // Swept hull over an ordered sequence (≥2): union of the hull of each consecutive pair.
100
+ k.hullChain = (inputs) => {
101
+ if (!Array.isArray(inputs) || inputs.length < 2)
102
+ throw new Error("hullChain: needs at least 2 inputs");
103
+ let acc = null;
104
+ for (let i = 0; i < inputs.length - 1; i++) {
105
+ const seg = k.hull([inputs[i], inputs[i + 1]]);
106
+ acc = acc ? acc.union(seg) : seg;
107
+ }
108
+ return acc;
109
+ };
110
+
91
111
  return k;
92
112
  }
@@ -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", "toSTEP",
22
+ "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
23
  ];
24
24
 
25
25
  // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
@@ -42,7 +42,8 @@ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
42
42
 
43
43
  // Public methods every Shape2D exposes (2-D boolean value; contract-linted).
44
44
  export const SHAPE2D_OPS = [
45
- "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "clone",
45
+ "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
46
+ "extrude", "revolve",
46
47
  ];
47
48
 
48
49
  // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
@@ -105,6 +106,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
105
106
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
106
107
  * @property {(solids:Solid[]) => Solid} union
107
108
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
109
+ * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
110
+ * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
108
111
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
109
112
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
110
113
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
@@ -98,7 +98,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
98
98
  boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
99
99
  toRegions: () => assembleRegions(cs.toPolygons()),
100
100
  clone: () => wrapShape2d(cs, hash),
101
- });
101
+ }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
102
102
  const shape2d = (profile) => {
103
103
  if (profile && profile._shape2d) return profile; // idempotent
104
104
  const hash = h("shape2d", profile, segs);
@@ -184,7 +184,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
184
184
  toIndexedMesh: () => indexedMeshOut(m),
185
185
  });
186
186
 
187
- return finishKernel({
187
+ const kernel = finishKernel({
188
188
  cylinder: (rb, rt, h2, { center = false } = {}) =>
189
189
  wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
190
190
  // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
@@ -251,6 +251,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
251
251
  // still pins (they must survive for the next build to resume from them).
252
252
  cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
253
253
  });
254
+ return kernel;
254
255
  }
255
256
 
256
257
  // Build a non-indexed mesh with normals that are smooth within a single original
@@ -189,7 +189,7 @@ export function createOcctKernel(replicad) {
189
189
  boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
190
190
  toRegions,
191
191
  clone: () => wrapShape2d(drawing.clone()),
192
- });
192
+ }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
193
193
  };
194
194
  const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
195
195
 
@@ -265,7 +265,7 @@ export function createOcctKernel(replicad) {
265
265
  return wrap(genericSweep(profile, spine, { frenet: true }));
266
266
  };
267
267
 
268
- return finishKernel({
268
+ const kernel = finishKernel({
269
269
  cylinder, // boredCylinder: the kernel front's default composition is exactly right here
270
270
  box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
271
271
  sphere: (r) => wrap(makeSphere(r)),
@@ -276,4 +276,5 @@ export function createOcctKernel(replicad) {
276
276
  shape2d,
277
277
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
278
278
  });
279
+ return kernel;
279
280
  }
@@ -1,11 +1,22 @@
1
- // Backend-shared Shape2D front. Like solid-sugar for Solids, but the 2-D shared
2
- // surface is small: .simple() unwraps a single-region materialization or throws.
3
- // Backends attach the geometry ops (booleans, area, boundingBox, toRegions).
4
- export function addShape2dSugar(s) {
1
+ // Backend-shared Shape2D front. Like solid-sugar for Solids: backends attach the geometry
2
+ // ops (booleans, area, boundingBox, toRegions); this layers on the backend-agnostic sugar.
3
+ // `deps` are the backend's own functions the sugar defers to: `shape2d` (lift a region back
4
+ // into a Shape2D) and `extrude`/`revolve` (build a Solid from this shape).
5
+ export function addShape2dSugar(s, { shape2d, extrude, revolve }) {
6
+ // .simple() → the single {outer,holes} region, or throw (a raw region, not a Shape2D).
5
7
  s.simple = () => {
6
8
  const regions = s.toRegions();
7
9
  if (regions.length !== 1) throw new Error(`Shape2D.simple: result has ${regions.length} regions, not 1 (use toRegions())`);
8
10
  return regions[0];
9
11
  };
12
+ // .regions() → scission: each disjoint region as its own live Shape2D (booleanable further).
13
+ s.regions = () => s.toRegions().map((r) => shape2d(r));
14
+ // .extrude({ h, twist?, scaleTop? }) / .revolve({ degrees? }) → Solid. Sugar for
15
+ // k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }). Passed as an
16
+ // options object (not positional) so the kernel op's key/required-arg validation still
17
+ // fires — e.g. a missing `h` throws "extrude: h is required" rather than silently
18
+ // producing empty geometry.
19
+ s.extrude = ({ h, twist, scaleTop } = {}) => extrude({ profile: s, h, twist, scaleTop });
20
+ s.revolve = ({ degrees } = {}) => revolve({ profile: s, degrees });
10
21
  return s;
11
22
  }
@@ -0,0 +1,3 @@
1
+ import part from "./parts/hull-sweep.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,70 @@
1
+ // Demo part — a hull sweep. Showcases k.hull / k.hullChain: a row of circles of
2
+ // varying radius along an arched spine becomes either one convex blob (k.hull) or a
3
+ // smooth swept strap/taper (k.hullChain) — the capsule/rounded-slot/organic-taper
4
+ // payoff. Optionally bores a hole at each node (Shape2D.cutAll) to make a linkage.
5
+ // Open /hull-sweep.html after `npm run dev`. Toggle "Convex wrap" to see hull vs
6
+ // hullChain side by side.
7
+ import { circleProfile } from "partforge/geometry";
8
+
9
+ export default {
10
+ meta: { title: "Hull sweep", units: "mm", background: 0x15181d },
11
+ parameters: [
12
+ {
13
+ id: "sweep",
14
+ title: "Sweep",
15
+ description: "A row of circles along an arched spine. `k.hullChain` sweeps the hull from one to the next (a strap/taper); `k.hull` wraps them all in one convex outline (see the Mode toggle).",
16
+ advanced: [
17
+ { key: "nodes", label: "Nodes", unit: "", min: 2, max: 6, step: 1,
18
+ description: "How many circles along the spine (2 = a single capsule)." },
19
+ { key: "length", label: "Length", unit: "mm", min: 20, max: 120, step: 1,
20
+ description: "Span from the first node to the last." },
21
+ { key: "r0", label: "Start radius", unit: "mm", min: 2, max: 16, step: 0.5,
22
+ description: "Radius of the first node." },
23
+ { key: "r1", label: "End radius", unit: "mm", min: 1, max: 16, step: 0.5,
24
+ description: "Radius of the last node — set it below the start for a taper." },
25
+ { key: "bow", label: "Arch", unit: "mm", min: 0, max: 30, step: 1,
26
+ description: "Vertical bow of the middle nodes. 0 = a straight strap; higher = a banana/arch." },
27
+ ],
28
+ },
29
+ {
30
+ id: "solid",
31
+ title: "Solid",
32
+ advanced: [
33
+ { key: "thickness", label: "Thickness", unit: "mm", min: 1.5, max: 10, step: 0.5,
34
+ description: "Extrude height." },
35
+ ],
36
+ },
37
+ {
38
+ id: "mode",
39
+ title: "Mode",
40
+ toggles: [
41
+ { key: "wrap", label: "Convex wrap (k.hull instead of k.hullChain)", on: 1,
42
+ description: "On: one convex hull of every node (a single convex blob). Off: the swept chain — the hull of each consecutive pair, unioned." },
43
+ { key: "holes", label: "Bore a hole at each node", on: 1,
44
+ description: "Cut a circular hole at every node (Shape2D.cutAll) — turns the strap into a linkage." },
45
+ ],
46
+ },
47
+ ],
48
+ defaults: { nodes: 3, length: 60, r0: 8, r1: 4, bow: 8, thickness: 4, wrap: 0, holes: 0 },
49
+ parts: {
50
+ sweep: {
51
+ label: "Hull sweep",
52
+ views: ["sweep"],
53
+ export: { name: "hull-sweep" },
54
+ build: (k, p) => {
55
+ const n = Math.max(2, Math.round(p.nodes));
56
+ const nodes = [];
57
+ for (let i = 0; i < n; i++) {
58
+ const t = i / (n - 1);
59
+ nodes.push({ x: -p.length / 2 + t * p.length, y: p.bow * Math.sin(Math.PI * t), r: p.r0 + (p.r1 - p.r0) * t });
60
+ }
61
+ const circles = nodes.map((nd) => circleProfile(nd.r, [nd.x, nd.y]));
62
+ let shape = p.wrap ? k.hull(circles) : k.hullChain(circles);
63
+ if (p.holes)
64
+ shape = shape.cutAll(nodes.map((nd) => circleProfile(Math.max(0.8, nd.r * 0.45), [nd.x, nd.y])));
65
+ return k.extrude({ profile: shape, h: p.thickness });
66
+ },
67
+ },
68
+ },
69
+ views: { sweep: { label: "Hull sweep" } },
70
+ };
@@ -5,16 +5,16 @@
5
5
  // exercises shape2d + the extrude/boolean path. Open /nameplate.html after `npm run dev`.
6
6
  import { roundedRectPolygon } from "partforge/geometry";
7
7
 
8
- const LABEL = "PARTFORGE\nv0.20";
9
-
10
8
  export default {
11
9
  meta: { title: "Nameplate", units: "mm", background: 0x15181d },
12
10
  parameters: [
13
11
  {
14
12
  id: "text",
15
13
  title: "Lettering",
16
- description: "The text is a two-line label (`PARTFORGE` over `v0.20`). It is resolved to exact glyph curves counters in **A R O G** and the **0** stay open — and sized by cap height.",
14
+ description: "Editable text resolved to exact glyph curves, with open counters and sizing based on cap height.",
17
15
  advanced: [
16
+ { key: "label", label: "Text", control: "textarea",
17
+ description: "The text rendered on the nameplate. Line breaks create multiple lines." },
18
18
  { key: "size", label: "Cap height", unit: "mm", min: 4, max: 16, step: 0.5,
19
19
  description: "Height of the uppercase letters. The second line scales with it." },
20
20
  { key: "depth", label: "Relief depth", unit: "mm", min: 0.4, max: 3, step: 0.1,
@@ -45,14 +45,14 @@ export default {
45
45
  ],
46
46
  },
47
47
  ],
48
- defaults: { size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
48
+ defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
49
49
  parts: {
50
50
  plate: {
51
51
  label: "Nameplate",
52
52
  views: ["plate"],
53
53
  export: { name: "nameplate" },
54
54
  build: (k, p) => {
55
- let text = k.text2d(LABEL, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
55
+ let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
56
56
  // Shape2D offset on the lettering: grow (>0, bolder) or shrink (<0, thinner). Guard
57
57
  // against a shrink that collapses thin strokes — keep the un-offset letters if so.
58
58
  if (p.stroke !== 0) {