partforge 0.6.1 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +61 -15
  2. package/bin/cli.js +82 -58
  3. package/docs/AUTHORING-PARTS.md +48 -23
  4. package/package.json +1 -1
  5. package/src/framework/app.css +26 -0
  6. package/src/framework/assembly.js +6 -9
  7. package/src/framework/download.js +23 -0
  8. package/src/framework/geometry/feature-attribution.js +102 -0
  9. package/src/framework/geometry/kernel-front.js +31 -0
  10. package/src/framework/geometry/kernel.js +53 -10
  11. package/src/framework/geometry/manifold-backend.js +45 -24
  12. package/src/framework/geometry/occt-backend.js +47 -96
  13. package/src/framework/geometry/occt-repair.js +83 -0
  14. package/src/framework/geometry/probe.js +37 -30
  15. package/src/framework/geometry/solid-sugar.js +32 -5
  16. package/src/framework/geometry-service.js +4 -6
  17. package/src/framework/jobs.js +40 -18
  18. package/src/framework/mesh-cache.js +41 -0
  19. package/src/framework/mount.js +103 -240
  20. package/src/framework/param-deps.js +9 -18
  21. package/src/framework/pick-request/server.js +10 -0
  22. package/src/framework/regen-loop.js +45 -0
  23. package/src/framework/selection/format.js +2 -6
  24. package/src/framework/selection/hover.js +128 -0
  25. package/src/framework/selection/index.js +3 -0
  26. package/src/framework/selection/pick-toggle.js +34 -0
  27. package/src/framework/selection/pick.js +7 -30
  28. package/src/framework/selection/raycast.js +43 -0
  29. package/src/framework/selection/resolve.js +3 -8
  30. package/src/framework/status-ui.js +18 -0
  31. package/src/framework/view-state.js +11 -1
  32. package/src/framework/view-tabs.js +33 -0
  33. package/src/framework/viewer-controls.js +48 -0
  34. package/src/framework/viewer.js +9 -9
  35. package/src/framework/worker.js +12 -20
  36. package/src/parts/filleted-box.js +1 -1
  37. package/src/parts/planter.js +4 -3
  38. package/src/testing/build.js +3 -6
  39. package/src/testing/manifold.js +11 -0
  40. package/src/testing.js +1 -0
  41. package/src/framework/geometry/fuzzy-cut.js +0 -32
@@ -1,9 +1,16 @@
1
1
  // src/framework/geometry/solid-sugar.js
2
- // Self-describing build-step vocabulary, defined ONCE over both geometry backends.
3
- // Every Solid a backend's wrap() returns is passed through addSugar(), which attaches
4
- // readable transform/placement methods composed purely from the solid's existing
5
- // rotate()/translate() primitives so the sugar is geometry-identical to the
6
- // hand-written primitive calls, on Manifold and OCCT alike.
2
+ // The backend-shared Solid front, defined ONCE over both geometry backends. Every
3
+ // Solid a backend's wrap() returns is passed through addSugar(), which:
4
+ // - attaches the readable transform/placement vocabulary (rotateX/along/at/…),
5
+ // composed purely from the solid's own rotate()/translate() primitives, so the
6
+ // sugar is geometry-identical on Manifold and OCCT alike;
7
+ // - validates arguments the backends would otherwise each check (scale factor);
8
+ // - derives boundingBox center/size from the backend's raw {min,max};
9
+ // - stubs any OCCT-only op the backend lacks with a KernelCapabilityError, so
10
+ // the needs-occt reroute works without hand-written per-backend stubs.
11
+ import { KernelCapabilityError } from "./errors.js";
12
+ import { OCCT_ONLY_OPS } from "./kernel.js";
13
+
7
14
  const ORIGIN = [0, 0, 0];
8
15
  const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
9
16
 
@@ -31,5 +38,25 @@ const SUGAR = {
31
38
  };
32
39
 
33
40
  export function addSugar(s) {
41
+ const rawScale = s.scale;
42
+ s.scale = (factor, center = ORIGIN) => {
43
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
44
+ return rawScale(factor, center);
45
+ };
46
+
47
+ const rawBoundingBox = s.boundingBox;
48
+ s.boundingBox = () => {
49
+ const { min, max } = rawBoundingBox();
50
+ return {
51
+ min, max,
52
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
53
+ size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
54
+ };
55
+ };
56
+
57
+ for (const op of OCCT_ONLY_OPS) {
58
+ s[op] ??= () => { throw new KernelCapabilityError(`${op} requires the OCCT backend`); };
59
+ }
60
+
34
61
  return Object.assign(s, SUGAR);
35
62
  }
@@ -9,10 +9,8 @@ export function createGeometryService({ createWorker, onMessage }) {
9
9
  const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
10
10
  workers.manifold.onmessage = onMessage;
11
11
  workers.occt.onmessage = onMessage;
12
- return {
13
- generate: (msg, backend = "manifold") => workers[backend].postMessage(msg),
14
- exportStl: (msg, backend = "manifold") => workers[backend].postMessage(msg),
15
- export3mf: (msg, backend = "manifold") => workers[backend].postMessage(msg),
16
- exportStep: (msg) => workers.occt.postMessage(msg), // STEP is always OCCT
17
- };
12
+ // Post a job to the chosen backend's worker. The message's own `type` says what to
13
+ // do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
14
+ // — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
15
+ return { send: (msg, backend = "manifold") => workers[backend].postMessage(msg) };
18
16
  }
@@ -19,25 +19,44 @@ export function exportSubParts(part, view, params) {
19
19
  return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
20
20
  }
21
21
 
22
- // Handle one geometry job, posting results/progress via `post`. Backend-agnostic
23
- // and part-agnostic: every part specific comes through `part`.
22
+ // Resolve a part's effective params + derived values for a build: the user's params
23
+ // layered over the part defaults, and derive() run once over the result.
24
+ export function resolveParams(part, params) {
25
+ const p = { ...part.defaults, ...params };
26
+ const d = part.derive ? part.derive(p) : {};
27
+ return { p, d };
28
+ }
29
+
30
+ // Build one sub-part and apply its optional place() for the given purpose/view.
31
+ // `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
32
+ // sub-part solid" — the worker, the collision check, and the test harness all call
33
+ // it, so display/export poses can never drift between the app and its tests.
34
+ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
35
+ const sp = part.parts[name];
36
+ const solid = sp.build(kernel, p, d, onProgress);
37
+ return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
38
+ }
39
+
40
+ // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
41
+ // Backend-agnostic and part-agnostic: every part specific comes through `part`.
24
42
  // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
25
43
  // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
26
44
  // { type:"export-step", view, params } → { type:"download", data, filename, mime }
45
+ // Each result branch declares its own transferables (the big binary buffers,
46
+ // zero-copy across the worker boundary) right where the buffers are created —
47
+ // so a new job type can't silently regress to structured-cloning its payload.
27
48
  // Progress is posted as { type:"progress", phase }. Export builds thread the
28
49
  // progress callback into build() so a part's own per-feature progress surfaces;
29
50
  // preview generates stay quiet (no callback) to avoid flicker during slider drags.
51
+ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
52
+
30
53
  export async function handle(kernel, part, msg, post) {
31
54
  const onProgress = (phase) => post({ type: "progress", phase });
32
- const p = { ...part.defaults, ...msg.params };
33
- const d = part.derive ? part.derive(p) : {};
55
+ const { p, d } = resolveParams(part, msg.params);
34
56
  const label = (name) => part.parts[name].label ?? name;
35
57
  const exportName = (name) => part.parts[name].export?.name ?? name;
36
- const buildPosed = (name, purpose, view, prog) => {
37
- const sp = part.parts[name];
38
- const solid = sp.build(kernel, p, d, prog);
39
- return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
40
- };
58
+ // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
59
+ const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
41
60
 
42
61
  try {
43
62
  if (msg.type === "generate") {
@@ -48,37 +67,40 @@ export async function handle(kernel, part, msg, post) {
48
67
  for (const name of msg.subparts) {
49
68
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
50
69
  try {
51
- const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
52
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
70
+ const m = posed(name, "display").toMesh({ quality: "preview" });
71
+ meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
53
72
  } finally {
54
73
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
55
74
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
56
75
  }
57
76
  }
58
- post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() });
77
+ const transfer = meshes.flatMap((m) =>
78
+ [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
79
+ post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
59
80
  } else if (msg.type === "export-stl") {
60
81
  const out = [];
61
82
  for (const name of exportSubParts(part, msg.view, p)) {
62
83
  onProgress(`building ${label(name)}`);
63
- out.push({ name: exportName(name), data: await buildPosed(name, "export", msg.view, onProgress).toSTL({ quality: "print" }) });
84
+ out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: "print" }) });
64
85
  }
65
- post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
86
+ post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out }, out.map((p) => bufferOf(p.data)));
66
87
  } else if (msg.type === "export-step") {
67
88
  const solids = exportSubParts(part, msg.view, p).map((name) => {
68
89
  onProgress(`building ${label(name)}`);
69
- return { name: exportName(name), solid: buildPosed(name, "export", msg.view, onProgress) };
90
+ return { name: exportName(name), solid: posed(name, "export", onProgress) };
70
91
  });
71
92
  onProgress("writing STEP file");
72
93
  const data = await kernel.toSTEP(solids);
73
- post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
94
+ post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" }, [bufferOf(data)]);
74
95
  } else if (msg.type === "export-3mf") {
75
96
  const meshes = exportSubParts(part, msg.view, p).map((name) => {
76
97
  onProgress(`building ${label(name)}`);
77
- const { positions, indices } = buildPosed(name, "export", msg.view, onProgress).toIndexedMesh();
98
+ const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
78
99
  return { name: exportName(name), positions, indices };
79
100
  });
80
101
  onProgress("writing 3MF file");
81
- post({ type: "download", data: meshTo3MF(meshes), filename: `${msg.view}.3mf`, mime: "model/3mf" });
102
+ const data = meshTo3MF(meshes);
103
+ post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
82
104
  }
83
105
  } catch (err) {
84
106
  if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
@@ -0,0 +1,41 @@
1
+ import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
2
+
3
+ // Tracks whether each sub-part's cached display mesh is still valid for the current
4
+ // params ("Layer 1" of the cache — skipping regeneration of sub-parts whose inputs
5
+ // didn't change). A sub-part's mesh is stamped with a relevance hash over just the
6
+ // params that sub-part reads; it's current while that hash is unchanged and the mesh
7
+ // is still in the viewer.
8
+ //
9
+ // The view/paramsVersion/caching state lives in mount and changes over time, so it's
10
+ // passed as getters. `params` is a stable object mutated in place, so it's passed by
11
+ // reference.
12
+ export function createMeshCache(part, viewer, { params, getView, getParamsVersion, isCaching }) {
13
+ const cacheHash = {}; // name -> relevance hash the cached mesh was built at
14
+
15
+ // Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
16
+ // runs probe builds, so we compute it once per change, not per sub-part.
17
+ let readsKey = null, readsMap = null;
18
+ const readsFor = () => {
19
+ const key = `${getParamsVersion()}|${getView()}`;
20
+ if (readsKey !== key) { readsKey = key; readsMap = subPartReadKeys(part, getView(), params); }
21
+ return readsMap;
22
+ };
23
+
24
+ // The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
25
+ // over ALL params, so any edit invalidates it — the safe fallback).
26
+ const hashFor = (name) => {
27
+ if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates every sub-part
28
+ const reads = readsFor();
29
+ const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(name) ?? Object.keys(params))];
30
+ return relevanceHash(keys, params);
31
+ };
32
+
33
+ return {
34
+ // A cached sub-part is current only if its relevance hash is unchanged.
35
+ isCurrent: (name) => viewer.hasSubMesh(name) && cacheHash[name] === hashFor(name),
36
+ // Stamp a freshly built mesh with the hash it was built at.
37
+ record: (name) => { cacheHash[name] = hashFor(name); },
38
+ // Drop a sub-part's stamp so it rebuilds next generate.
39
+ forget: (name) => { delete cacheHash[name]; },
40
+ };
41
+ }