partforge 0.20.2 → 0.23.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.
@@ -504,10 +504,15 @@ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes o
504
504
  // Keyhole plate: union a disc onto a rect, punch a slot, extrude.
505
505
  const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
506
506
  .union(circleProfile(8))
507
- .cut(slotPolygon(16, 3));
508
- 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
509
509
  ```
510
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
+
511
516
  ```js
512
517
  // A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
513
518
  const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
@@ -518,6 +523,19 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
518
523
 
519
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.)
520
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
+
521
539
  ## Text (`text2d`)
522
540
 
523
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.2",
3
+ "version": "0.23.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
+ });
@@ -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
  }
@@ -1,6 +1,8 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
2
  import { resolveDerived } from "./derive.js";
3
3
  import { resolveFonts } from "./fonts.js";
4
+ import { measure } from "../testing/measure.js";
5
+ import { verify } from "../testing/verify.js";
4
6
 
5
7
  // Names of the sub-parts a view shows: declared in the view and enabled for these
6
8
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -113,6 +115,18 @@ export async function handle(kernel, part, msg, post) {
113
115
  onProgress("writing 3MF file");
114
116
  const data = meshTo3MF(meshes);
115
117
  post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
118
+ } else if (msg.type === "inspect") {
119
+ // Full geometric oracle for the current view: solid facts (volume/genus/
120
+ // watertight), mesh facts, overlaps, and gap distances, plus the part's
121
+ // structural + declared verify gates. Runs against the worker's live kernel
122
+ // — the main thread only has mesh arrays, so this can only happen here.
123
+ // measure/verify build their own solids via buildView and are cleaned up by
124
+ // the `finally` below.
125
+ const report = {
126
+ measure: measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true }),
127
+ verify: verify(kernel, part, { view: msg.view }),
128
+ };
129
+ post({ type: "report", ...report });
116
130
  }
117
131
  } catch (err) {
118
132
  if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
@@ -19,6 +19,12 @@ import { createViewTabs } from "./view-tabs.js";
19
19
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
20
20
  import { createPickRequestClient } from "./pick-request/index.js";
21
21
 
22
+ // The mount handle, factored out so its shape is unit-testable without booting
23
+ // the full mount() pipeline (WASM + workers + DOM).
24
+ export function makeHandle({ ready, dispose, viewer }) {
25
+ return { ready, dispose, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
26
+ }
27
+
22
28
  function createCleanupStack() {
23
29
  const cleanups = [];
24
30
  let disposed = false;
@@ -351,7 +357,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
351
357
  cleanup.dispose();
352
358
  }
353
359
 
354
- return { ready, dispose };
360
+ return makeHandle({ ready, dispose, viewer });
355
361
  } catch (error) {
356
362
  try {
357
363
  cleanup.dispose();
@@ -0,0 +1,33 @@
1
+ // Canonical camera angles for headless/offscreen captures, in the viewer's
2
+ // three.js WORLD space (Y-up). The model is authored Z-up; the viewer's pivot
3
+ // rotates it into Y-up, so these directions are expressed Y-up directly. Kept in
4
+ // one place so the cloud render tool and any future headless capture agree.
5
+ export const CANONICAL_VIEWS = ["iso", "front", "back", "top", "bottom", "left", "right"];
6
+
7
+ // Direction FROM the part centre TOWARD the camera (world Y-up), plus the up vector.
8
+ const DIRS = {
9
+ iso: { dir: [1, 1, 1], up: [0, 1, 0] },
10
+ front: { dir: [0, 0, 1], up: [0, 1, 0] },
11
+ back: { dir: [0, 0, -1], up: [0, 1, 0] },
12
+ top: { dir: [0, 1, 0], up: [0, 0, -1] },
13
+ bottom: { dir: [0, -1, 0], up: [0, 0, 1] },
14
+ left: { dir: [-1, 0, 0], up: [0, 1, 0] },
15
+ right: { dir: [1, 0, 0], up: [0, 1, 0] },
16
+ };
17
+
18
+ const norm = (v) => {
19
+ const l = Math.hypot(v[0], v[1], v[2]) || 1;
20
+ return [v[0] / l, v[1] / l, v[2] / l];
21
+ };
22
+
23
+ export function cameraPoseForView(view, { center, radius }) {
24
+ const a = DIRS[view];
25
+ if (!a) throw new Error(`unknown canonical view "${view}"`);
26
+ const d = norm(a.dir);
27
+ const dist = radius * 2.6 + 6; // matches viewer.frameTo's framing distance
28
+ return {
29
+ position: [center[0] + d[0] * dist, center[1] + d[1] * dist, center[2] + d[2] * dist],
30
+ up: a.up,
31
+ target: [...center],
32
+ };
33
+ }
@@ -6,6 +6,29 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { addViewerLights } from "./viewer-lighting.js";
9
+ import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
10
+
11
+ // Render a set of canonical views without disturbing the live camera/canvas.
12
+ // `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
13
+ // target → readback → JPEG data URL); injected so this is unit-testable without
14
+ // a GL context. The grid is hidden for the whole synchronous pass and restored.
15
+ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds }) {
16
+ const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
17
+ .filter((v) => CANONICAL_VIEWS.includes(v))
18
+ .slice(0, CANONICAL_VIEWS.length);
19
+ const before = liveCamera.position.clone();
20
+ const gridWasVisible = grid?.visible;
21
+ if (grid) grid.visible = false;
22
+ try {
23
+ return views.map((view) => ({
24
+ view,
25
+ dataUrl: renderer.renderOffscreen(cameraPoseForView(view, bounds)),
26
+ }));
27
+ } finally {
28
+ if (grid) grid.visible = gridWasVisible;
29
+ liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
30
+ }
31
+ }
9
32
 
10
33
  export function createViewer(container, part) {
11
34
  const names = Object.keys(part.parts);
@@ -261,6 +284,53 @@ export function createViewer(container, part) {
261
284
  ro.observe(container);
262
285
  resize();
263
286
 
287
+ // --- offscreen canonical-view capture -------------------------------------
288
+ // Offscreen render of the shared scene from an arbitrary pose → JPEG data URL.
289
+ // A separate WebGLRenderTarget + temp camera means the visible canvas and the
290
+ // live `camera` are never touched. WebGL pixels are bottom-up, so flip on encode.
291
+ const _rtSize = 512;
292
+ let _rt = null;
293
+ function renderOffscreen({ position, up, target }) {
294
+ _rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize);
295
+ const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
296
+ cam.position.set(position[0], position[1], position[2]);
297
+ cam.up.set(up[0], up[1], up[2]);
298
+ cam.lookAt(target[0], target[1], target[2]);
299
+ renderer.setRenderTarget(_rt);
300
+ renderer.render(scene, cam);
301
+ const buf = new Uint8Array(_rtSize * _rtSize * 4);
302
+ renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
303
+ renderer.setRenderTarget(null);
304
+ const canvas = document.createElement("canvas");
305
+ canvas.width = _rtSize; canvas.height = _rtSize;
306
+ const ctx = canvas.getContext("2d");
307
+ const img = ctx.createImageData(_rtSize, _rtSize);
308
+ // flip rows (GL origin is bottom-left)
309
+ for (let y = 0; y < _rtSize; y++) {
310
+ const src = (_rtSize - 1 - y) * _rtSize * 4;
311
+ img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
312
+ }
313
+ ctx.putImageData(img, 0, 0);
314
+ return canvas.toDataURL("image/jpeg", 0.8);
315
+ }
316
+
317
+ // Render the canonical camera angles offscreen, framed to whatever is visible,
318
+ // without disturbing the user's live view. Returns [{ view, dataUrl }].
319
+ function captureCanonicalViews(viewNames) {
320
+ if (disposed) return [];
321
+ const box = getVisibleWorldBounds();
322
+ if (!box || box.isEmpty()) return [];
323
+ const center = box.getCenter(new THREE.Vector3()).toArray();
324
+ const size = box.getSize(new THREE.Vector3());
325
+ const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
326
+ return captureViewsFromScene(viewNames, {
327
+ renderer: { renderOffscreen },
328
+ liveCamera: camera,
329
+ grid,
330
+ bounds: { center, radius },
331
+ });
332
+ }
333
+
264
334
  // --- render loop ----------------------------------------------------------
265
335
  renderer.setAnimationLoop(() => {
266
336
  controls.update();
@@ -323,6 +393,7 @@ export function createViewer(container, part) {
323
393
  lineMaterial.dispose();
324
394
  grid.geometry.dispose();
325
395
  grid.material.dispose();
396
+ _rt?.dispose();
326
397
  renderer.dispose();
327
398
  renderer.domElement.remove();
328
399
  }
@@ -334,6 +405,7 @@ export function createViewer(container, part) {
334
405
  hasSubMesh,
335
406
  subTriangles,
336
407
  frame,
408
+ captureCanonicalViews,
337
409
  setAutoRotate,
338
410
  setTheme,
339
411
  getCameraState,
@@ -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
+ };