partforge 0.35.0 → 0.36.1

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.
@@ -705,6 +705,24 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
705
705
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
706
706
  `"occt"` for STEP — handled for you).
707
707
 
708
+ **Headless export (the mount handle).** The `#download*` buttons above are the built-in,
709
+ view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
710
+ pick a format" modal) can skip those buttons and drive export off the handle `mount()`
711
+ returns instead:
712
+
713
+ - `runtime.listExportableParts() → [{ name, label }]` — every exportable sub-part
714
+ (excludes any `exportable: false` part, respects each part's `enabled(params)`),
715
+ **independent of the active view**. Use it to populate an export checklist.
716
+ - `runtime.exportParts({ parts, format, quality?, onProgress }) → Promise<void>` — build
717
+ the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf"`), streaming
718
+ phase strings to `onProgress(phase)`. Resolves once the file is written (handed to your
719
+ `onDownload` sink, or downloaded directly if you don't supply one); rejects on
720
+ build/export failure or an empty selection. Placement uses the current
721
+ view. STEP is routed to OCCT automatically.
722
+
723
+ Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
724
+ yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
725
+
708
726
  **The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
709
727
  `class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
710
728
  (`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
@@ -222,7 +222,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
222
222
  | `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
223
223
  | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` may be empty (`length 0`) to delegate creasing to the viewer; `edges` (feature-line segments) and the feature fields are optional metadata. |
224
224
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
225
- | `toIndexedMesh()` | `{positions, indices}` indexed mesh (3MF path). |
225
+ | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
226
226
  | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
227
227
 
228
228
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
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,54 @@
1
+ // src/framework/export-controller.js
2
+ // Owns the jobId correlation for headless exportParts(): matches worker
3
+ // replies (progress/download/error) back to the Promise that started them.
4
+ // Pure — no DOM, no worker; `send` and the sink are injected.
5
+ import { triggerDownload, downloadParts } from "./download.js";
6
+
7
+ export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
8
+ const pending = new Map(); // jobId -> { resolve, reject, onProgress }
9
+ let nextId = 1;
10
+
11
+ function exportParts({ parts, format, quality = "print", onProgress } = {}) {
12
+ const jobId = nextId++;
13
+ const type = `export-${format}`;
14
+ const backend = format === "step" ? "occt" : defaultBackend();
15
+ return new Promise((resolve, reject) => {
16
+ pending.set(jobId, { resolve, reject, onProgress });
17
+ send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality }, backend);
18
+ });
19
+ }
20
+
21
+ // Returns true iff this message belonged to a pending export (so the caller
22
+ // can skip legacy handling). `sink` is partforge's onDownload.
23
+ function handleMessage(m, sink) {
24
+ const entry = m && m.jobId != null ? pending.get(m.jobId) : undefined;
25
+ if (!entry) return false;
26
+ if (m.type === "progress") { entry.onProgress?.(m.phase); return true; }
27
+ if (m.type === "download") {
28
+ pending.delete(m.jobId);
29
+ triggerDownload(m.data, m.filename, m.mime, sink);
30
+ entry.resolve();
31
+ return true;
32
+ }
33
+ if (m.type === "download-parts") {
34
+ pending.delete(m.jobId);
35
+ const zipName = `${title() ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
36
+ downloadParts(m, zipName, sink);
37
+ entry.resolve();
38
+ return true;
39
+ }
40
+ if (m.type === "error") { pending.delete(m.jobId); entry.reject(new Error(m.message)); return true; }
41
+ if (m.type === "needs-occt") { pending.delete(m.jobId); entry.reject(new Error("needs OCCT backend")); return true; }
42
+ return false;
43
+ }
44
+
45
+ // Reject every in-flight export and clear the map — for teardown / worker
46
+ // death, where worker replies will never arrive to settle these Promises.
47
+ function dispose(reason) {
48
+ const err = new Error(reason ?? "export cancelled");
49
+ for (const entry of pending.values()) entry.reject(err);
50
+ pending.clear();
51
+ }
52
+
53
+ return { exportParts, handleMessage, dispose };
54
+ }
@@ -0,0 +1,15 @@
1
+ // Pure selection helpers for headless export. No kernel, no DOM.
2
+
3
+ export function partLabel(part, name) {
4
+ return part.parts[name]?.label ?? name;
5
+ }
6
+
7
+ // The union of sub-parts eligible for export, independent of the active view:
8
+ // exportable (not exportable:false) AND enabled under the current params.
9
+ export function exportablePartNames(part, params) {
10
+ return Object.keys(part.parts).filter((name) => {
11
+ const sp = part.parts[name];
12
+ if (sp.exportable === false) return false;
13
+ return sp.enabled ? !!sp.enabled(params) : true;
14
+ });
15
+ }
@@ -34,7 +34,7 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tole
34
34
 
35
35
  export function createOcctKernel(replicad) {
36
36
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
37
- makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
37
+ loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
38
38
 
39
39
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
40
40
  // see occt-repair.js for the policies and why they differ per op.
@@ -127,8 +127,12 @@ export function createOcctKernel(replicad) {
127
127
  const key = h("cutAll", hash, tools.map((t) => t._hash));
128
128
  return cached(key, () => {
129
129
  const a = mat(), bs = tools.map((t) => t._mat());
130
+ if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
131
+ const fusedTools = bs
132
+ .slice(1)
133
+ .reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
130
134
  return wrap(
131
- a._s.clone().cut(makeCompound(bs.map((b) => b._s.clone()))),
135
+ a._s.clone().cut(fusedTools),
132
136
  [...cloneLabels(a._labels), ...bs.flatMap((b) => cloneLabels(b._labels))],
133
137
  key,
134
138
  );
@@ -210,8 +214,10 @@ export function createOcctKernel(replicad) {
210
214
  });
211
215
  },
212
216
  volume: () => measureVolume(mat()._s),
213
- toIndexedMesh: () => {
214
- const base = baseMesh("preview");
217
+ // Same default as toSTL: an export is an export, so a .3mf must not ship a
218
+ // coarser tessellation than the .stl of the same solid would.
219
+ toIndexedMesh: ({ quality = "print" } = {}) => {
220
+ const base = baseMesh(quality);
215
221
  return { positions: posedPositions(base), indices: Uint32Array.from(base.indices) };
216
222
  },
217
223
  });
@@ -19,6 +19,54 @@ const RELS =
19
19
  const xmlEsc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c]));
20
20
  const r = (x) => +x.toFixed(4); // 0.1 µm precision — finer than any printer, much smaller XML
21
21
 
22
+ // Weld coincident vertices and drop triangles that collapse to zero area.
23
+ //
24
+ // This is what keeps a .3mf manifold, and it has no STL equivalent: STL is vertex
25
+ // soup, so a consumer re-stitches triangles by POSITION and never sees how the
26
+ // mesh was indexed. 3MF reads topology from the indices instead, so two triangles
27
+ // that meet along an edge must literally cite the same two vertex ids — otherwise
28
+ // each counts the shared edge as its own boundary and slicers report the solid as
29
+ // non-manifold. The OCCT backend triangulates every B-rep face independently and
30
+ // concatenates the results, so it hands us one copy of each seam vertex PER
31
+ // adjacent face; welding here is what closes that mesh. (Manifold's own output is
32
+ // already welded, so for that backend this is a no-op beyond the copy.)
33
+ //
34
+ // Welding uses `r` — exactly the precision the file is written at — so any two
35
+ // vertices that would print identical coordinates always collapse to one id. A
36
+ // looser tolerance would move geometry; a tighter one would leave split vertices
37
+ // sitting at coordinates the file cannot tell apart.
38
+ function weld(positions, indices) {
39
+ const idOf = new Map(); // "x,y,z" → canonical vertex id
40
+ const coord = []; // canonical id → rounded x,y,z (flat)
41
+ const canon = new Uint32Array(positions.length / 3);
42
+ for (let i = 0, v = 0; i < positions.length; i += 3, v++) {
43
+ const x = r(positions[i]), y = r(positions[i + 1]), z = r(positions[i + 2]);
44
+ const key = `${x},${y},${z}`;
45
+ let id = idOf.get(key);
46
+ if (id === undefined) { id = coord.length / 3; idOf.set(key, id); coord.push(x, y, z); }
47
+ canon[v] = id;
48
+ }
49
+ // Emit vertices in order of first use by a surviving triangle, so a vertex left
50
+ // behind by a dropped degenerate never ships as an unreferenced <vertex>.
51
+ const emitted = new Map(); // canonical id → written vertex index
52
+ const verts = [], tris = [];
53
+ const emit = (id) => {
54
+ let at = emitted.get(id);
55
+ if (at === undefined) {
56
+ at = verts.length / 3;
57
+ emitted.set(id, at);
58
+ verts.push(coord[id * 3], coord[id * 3 + 1], coord[id * 3 + 2]);
59
+ }
60
+ return at;
61
+ };
62
+ for (let k = 0; k < indices.length; k += 3) {
63
+ const a = canon[indices[k]], b = canon[indices[k + 1]], c = canon[indices[k + 2]];
64
+ if (a === b || b === c || a === c) continue; // zero area at print precision — carries no surface
65
+ tris.push(emit(a), emit(b), emit(c));
66
+ }
67
+ return { verts, tris };
68
+ }
69
+
22
70
  // parts: [{ name, positions: Float32Array (x,y,z per vertex), indices: Uint32Array (3 per triangle) }]
23
71
  // → ArrayBuffer of the .3mf zip (millimetre units; one <object> + <build> item per part).
24
72
  export function meshTo3MF(parts) {
@@ -29,10 +77,9 @@ export function meshTo3MF(parts) {
29
77
  ];
30
78
  parts.forEach((p, i) => {
31
79
  out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"><mesh><vertices>`);
32
- const v = p.positions;
33
- for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${r(v[k])}" y="${r(v[k + 1])}" z="${r(v[k + 2])}"/>`);
80
+ const { verts: v, tris: t } = weld(p.positions, p.indices);
81
+ for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${v[k]}" y="${v[k + 1]}" z="${v[k + 2]}"/>`);
34
82
  out.push("</vertices><triangles>");
35
- const t = p.indices;
36
83
  for (let k = 0; k < t.length; k += 3) out.push(`<triangle v1="${t[k]}" v2="${t[k + 1]}" v3="${t[k + 2]}"/>`);
37
84
  out.push("</triangles></mesh></object>");
38
85
  });
@@ -1,4 +1,5 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
+ import { exportablePartNames } from "./export-select.js";
2
3
  import { resolveDerived } from "./derive.js";
3
4
  import { resolveFonts } from "./fonts.js";
4
5
  import { measure } from "../testing/measure.js";
@@ -58,7 +59,7 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
58
59
 
59
60
  export async function handle(kernel, part, msg, post, opts = {}) {
60
61
  const isStale = opts.isStale ?? (() => false);
61
- const onProgress = (phase) => post({ type: "progress", phase });
62
+ const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
62
63
  const label = (name) => part.parts[name].label ?? name;
63
64
  const exportName = (name) => part.parts[name].export?.name ?? name;
64
65
 
@@ -76,6 +77,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
76
77
  const { p, d } = resolveParams(part, msg.params);
77
78
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
78
79
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
80
+ // Explicit selection (headless exportParts) overrides view-derived selection.
81
+ const selected = () =>
82
+ msg.parts
83
+ ? exportablePartNames(part, p).filter((name) => msg.parts.includes(name))
84
+ : exportSubParts(part, msg.view, p);
85
+ const fileBase = msg.name ?? msg.view; // STEP/3MF single-file name base
79
86
 
80
87
  if (msg.type === "generate") {
81
88
  const t0 = Date.now();
@@ -105,29 +112,36 @@ export async function handle(kernel, part, msg, post, opts = {}) {
105
112
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
106
113
  post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
107
114
  } else if (msg.type === "export-stl") {
115
+ const names = selected();
116
+ if (names.length === 0) throw new Error("no exportable parts selected");
108
117
  const out = [];
109
- for (const name of exportSubParts(part, msg.view, p)) {
118
+ for (const name of names) {
110
119
  onProgress(`building ${label(name)}`);
111
- out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: "print" }) });
120
+ out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
112
121
  }
113
- post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out }, out.map((p) => bufferOf(p.data)));
122
+ post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
123
+ out.map((pp) => bufferOf(pp.data)));
114
124
  } else if (msg.type === "export-step") {
115
- const solids = exportSubParts(part, msg.view, p).map((name) => {
125
+ const names = selected();
126
+ if (names.length === 0) throw new Error("no exportable parts selected");
127
+ const solids = names.map((name) => {
116
128
  onProgress(`building ${label(name)}`);
117
129
  return { name: exportName(name), solid: posed(name, "export", onProgress) };
118
130
  });
119
131
  onProgress("writing STEP file");
120
132
  const data = await kernel.toSTEP(solids);
121
- post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" }, [bufferOf(data)]);
133
+ post({ type: "download", data, filename: `${fileBase}.step`, mime: "application/step", jobId: msg.jobId }, [bufferOf(data)]);
122
134
  } else if (msg.type === "export-3mf") {
123
- const meshes = exportSubParts(part, msg.view, p).map((name) => {
135
+ const names = selected();
136
+ if (names.length === 0) throw new Error("no exportable parts selected");
137
+ const meshes = names.map((name) => {
124
138
  onProgress(`building ${label(name)}`);
125
- const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
139
+ const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh({ quality: msg.quality ?? "print" });
126
140
  return { name: exportName(name), positions, indices };
127
141
  });
128
142
  onProgress("writing 3MF file");
129
143
  const data = meshTo3MF(meshes);
130
- post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
144
+ post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
131
145
  } else if (msg.type === "inspect") {
132
146
  // Full geometric oracle for the current view: solid facts (volume/genus/
133
147
  // watertight), mesh facts, overlaps, and gap distances, plus the part's
@@ -142,8 +156,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
142
156
  post({ type: "report", ...report });
143
157
  }
144
158
  } catch (err) {
145
- if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
146
- else post({ type: "error", message: String(err?.message || err) });
159
+ if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
160
+ else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
147
161
  } finally {
148
162
  kernel.cleanup?.();
149
163
  }
@@ -20,11 +20,18 @@ import { createStatusUi } from "./status-ui.js";
20
20
  import { createViewTabs } from "./view-tabs.js";
21
21
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
22
22
  import { createPickRequestClient } from "./pick-request/index.js";
23
+ import { exportablePartNames, partLabel } from "./export-select.js";
24
+ import { createExportController } from "./export-controller.js";
23
25
 
24
26
  // The mount handle, factored out so its shape is unit-testable without booting
25
27
  // the full mount() pipeline (WASM + workers + DOM).
26
- export function makeHandle({ ready, dispose, viewer, setParams }) {
27
- return { ready, dispose, setParams, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
28
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts }) {
29
+ return {
30
+ ready, dispose, setParams,
31
+ captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
32
+ listExportableParts,
33
+ exportParts,
34
+ };
28
35
  }
29
36
 
30
37
  function createCleanupStack() {
@@ -56,10 +63,16 @@ function createCleanupStack() {
56
63
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
57
64
  // so Vite can bundle the worker (see geometry-service.js).
58
65
  //
59
- // Embedding contract (0.12.0):
60
- // const runtime = mount(part, { createWorker, elements, onBuild, onPick });
66
+ // Embedding contract (0.36.0):
67
+ // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
61
68
  // await runtime.ready; // first successful build of the default view
62
69
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
70
+ // runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
71
+ // // independent of the active view (for an embedder-drawn export UI)
72
+ // await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
73
+ // // headless export of a chosen subset; resolves when the file is
74
+ // // written (handed to your onDownload sink, or downloaded directly
75
+ // // if you don't supply one), rejects on failure
63
76
  // runtime.dispose(); // full teardown
64
77
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
65
78
  // those are repaired in the viewer and produce no build at all.
@@ -279,6 +292,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
279
292
 
280
293
  // --- shared message handler ------------------------------------------------
281
294
  function onWorkerMessage({ data }) {
295
+ // Headless exportParts() correlation: consume its own replies first.
296
+ if (exportCtl.handleMessage(data, onDownload)) return;
282
297
  switch (data.type) {
283
298
  case "ready":
284
299
  loop.ready(); // auto-build the default view (keeps the busy spinner up)
@@ -338,6 +353,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
338
353
  const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
339
354
  cleanup.defer(() => service.terminate());
340
355
 
356
+ const exportCtl = createExportController({
357
+ send: (msg, backend) => service.send(msg, backend),
358
+ currentView: () => view(),
359
+ title: () => part.meta?.title ?? "parts",
360
+ defaultBackend: () => backendFor(),
361
+ currentParams: () => params,
362
+ });
363
+ cleanup.defer(() => exportCtl.dispose("viewer disposed"));
364
+
341
365
  const panel = buildControls(els.controls, part.parameters, params, onParamChange);
342
366
  cleanup.defer(() => panel.dispose());
343
367
  const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
@@ -417,7 +441,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
417
441
  cleanup.dispose();
418
442
  }
419
443
 
420
- return makeHandle({ ready, dispose, viewer, setParams });
444
+ return makeHandle({
445
+ ready, dispose, viewer, setParams,
446
+ listExportableParts: () =>
447
+ exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
448
+ exportParts: (opts) => exportCtl.exportParts(opts),
449
+ });
421
450
  } catch (error) {
422
451
  try {
423
452
  cleanup.dispose();