partforge 0.8.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -18,12 +18,14 @@
18
18
  "bin",
19
19
  "skills/partforge/SKILL.md",
20
20
  "docs/AUTHORING-PARTS.md",
21
+ "docs/ERROR-PATTERNS.md",
21
22
  "README.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": "./src/index.js",
25
26
  "./worker": "./src/framework/worker.js",
26
27
  "./geometry": "./src/framework/geometry/polygon.js",
28
+ "./derive": "./src/framework/derive.js",
27
29
  "./testing": "./src/testing.js"
28
30
  },
29
31
  "bin": {
@@ -56,3 +56,8 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
56
56
 
57
57
  - This only *reads* a click — it never edits files. You make the edits yourself after.
58
58
  - The server is localhost-only and holds one request at a time.
59
+
60
+ ## Related: debugging failures
61
+
62
+ If anything fails while you're editing a part, grep `docs/ERROR-PATTERNS.md` for the
63
+ symptom first — its preamble states the full grep-first rule.
@@ -6,7 +6,7 @@ import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
6
6
  // Parts meant to fit together (e.g. a block seated in a pocket void) read ~0 and
7
7
  // don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
8
8
  // part tests so an author/LLM editing a part sees collisions fail.
9
- // → [{ a, b, volume }] for each offending pair (empty = no collisions)
9
+ // → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
10
10
  export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
11
11
  const { p, d } = resolveParams(part, params);
12
12
  const posed = viewSubParts(part, view, p).map((name) => ({
@@ -17,8 +17,14 @@ export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance =
17
17
  const overlaps = [];
18
18
  for (let i = 0; i < posed.length; i++) {
19
19
  for (let j = i + 1; j < posed.length; j++) {
20
- const volume = posed[i].solid.intersect(posed[j].solid).volume();
21
- if (volume > tolerance) overlaps.push({ a: posed[i].name, b: posed[j].name, volume });
20
+ const inter = posed[i].solid.intersect(posed[j].solid);
21
+ const volume = inter.volume();
22
+ if (volume > tolerance) {
23
+ // location = the intersection's bounding-box center — a nearby indicator, not
24
+ // an exact contact point: for a disjoint (multi-region) intersection it is the
25
+ // midpoint of those regions and can land in the empty space between them.
26
+ overlaps.push({ a: posed[i].name, b: posed[j].name, volume, location: inter.boundingBox().center });
27
+ }
22
28
  }
23
29
  }
24
30
  kernel.cleanup?.(); // free the per-check WASM objects
@@ -0,0 +1,28 @@
1
+ // Resolve a part's `derive` into the derived-values object `d` that builds receive.
2
+ //
3
+ // Two authoring forms:
4
+ // derive: (p) => d — one function, computed in a single pass.
5
+ // derive: { name: (p, d) => {...}, … } — named GROUPS, run in declaration order;
6
+ // each group gets the params plus the merged outputs of the groups before it.
7
+ // The grouped form exists so the relevance layer (param-deps.js) can attribute each
8
+ // derived value to just its own group's inputs instead of every param derive touches.
9
+ export function resolveDerived(part, p) {
10
+ const derive = part.derive;
11
+ if (!derive) return {};
12
+ if (typeof derive === "function") return derive(p) ?? {};
13
+ const d = {};
14
+ // Groups read earlier groups' outputs through this guard: a key nothing has
15
+ // produced yet is a wiring mistake (group order / typo), and silently reading
16
+ // undefined would surface as NaN geometry far downstream — throw here instead.
17
+ // (Builds still receive the plain merged object, unguarded.)
18
+ const guard = new Proxy(d, {
19
+ get(t, key) {
20
+ if (typeof key === "string" && key !== "then" && !(key in t)) {
21
+ throw new Error(`derive: group read "${key}" before any earlier group produced it`);
22
+ }
23
+ return Reflect.get(t, key);
24
+ },
25
+ });
26
+ for (const fn of Object.values(derive)) Object.assign(d, fn(p, guard) ?? {});
27
+ return d;
28
+ }
@@ -3,7 +3,14 @@
3
3
  // test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
4
4
  // contract can't silently drift from the implementations — the drift class that
5
5
  // once broke the probe kernel (see probe.js). The @typedefs document signatures.
6
- // (2-D polygon helpers live in ./polygon.js.)
6
+ // The prose half of the contract — conventions, value semantics, conformance
7
+ // classes, versioning policy — is docs/KERNEL-CONTRACT.md; change either side and
8
+ // you must update the other. (2-D polygon helpers live in ./polygon.js.)
9
+
10
+ // The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
11
+ // must match this number (asserted in kernel-contract.test.js). Bump only on a
12
+ // breaking contract change — see the doc's Versioning section.
13
+ export const CONTRACT_VERSION = 1;
7
14
 
8
15
  // Ops every backend kernel must implement.
9
16
  export const KERNEL_OPS = [
@@ -3,6 +3,7 @@
3
3
  // if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
4
4
  // in kernel.js — the same list generates the Manifold backend's throwing stubs.
5
5
  import { OCCT_ONLY_OPS } from "./kernel.js";
6
+ import { resolveDerived } from "../derive.js";
6
7
 
7
8
  const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
8
9
 
@@ -49,7 +50,11 @@ export function createProbeKernel() {
49
50
  export function detectBackend(part, params = {}) {
50
51
  if (part.meta?.backend) return part.meta.backend;
51
52
  const p = { ...part.defaults, ...params };
52
- const d = part.derive ? part.derive(p) : {};
53
+ let d = {};
54
+ // A throwing derive must not escape here — this runs on the main thread mid
55
+ // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
56
+ // build hits the same throw and posts a proper error for the UI.
57
+ try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
53
58
  const { kernel, used } = createProbeKernel();
54
59
  for (const name of Object.keys(part.parts)) {
55
60
  try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
@@ -1,4 +1,5 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
+ import { resolveDerived } from "./derive.js";
2
3
 
3
4
  // Names of the sub-parts a view shows: declared in the view and enabled for these
4
5
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -23,8 +24,7 @@ export function exportSubParts(part, view, params) {
23
24
  // layered over the part defaults, and derive() run once over the result.
24
25
  export function resolveParams(part, params) {
25
26
  const p = { ...part.defaults, ...params };
26
- const d = part.derive ? part.derive(p) : {};
27
- return { p, d };
27
+ return { p, d: resolveDerived(part, p) };
28
28
  }
29
29
 
30
30
  // Build one sub-part and apply its optional place() for the given purpose/view.
@@ -52,13 +52,16 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
52
52
 
53
53
  export async function handle(kernel, part, msg, post) {
54
54
  const onProgress = (phase) => post({ type: "progress", phase });
55
- const { p, d } = resolveParams(part, msg.params);
56
55
  const label = (name) => part.parts[name].label ?? name;
57
56
  const exportName = (name) => part.parts[name].export?.name ?? name;
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 });
60
57
 
61
58
  try {
59
+ // Inside the try so a throwing derive posts an error the UI can show,
60
+ // instead of killing the worker turn silently (an endless spinner).
61
+ const { p, d } = resolveParams(part, msg.params);
62
+ // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
63
+ const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
64
+
62
65
  if (msg.type === "generate") {
63
66
  const t0 = Date.now();
64
67
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
@@ -8,6 +8,7 @@ import { relevantParamKeys } from "./param-deps.js";
8
8
  import { createMeshCache } from "./mesh-cache.js";
9
9
  import { createGeometryService } from "./geometry-service.js";
10
10
  import { viewSubParts } from "./jobs.js";
11
+ import { resolveDerived } from "./derive.js";
11
12
  import { detectBackend } from "./geometry/probe.js";
12
13
  import { createDebugOverlay } from "./debug-overlay.js";
13
14
  import { createRegenLoop } from "./regen-loop.js";
@@ -58,7 +59,12 @@ export function mount(part, { createWorker, container = document.getElementById(
58
59
 
59
60
  // Current selection context for the pickers: the active view + live params +
60
61
  // derived values. Shared by both ?pick modes below.
61
- const getContext = () => ({ view: view(), params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} });
62
+ const getContext = () => {
63
+ let derived = {};
64
+ // A throwing derive must not crash the pick flow — proceed without derived context.
65
+ try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
66
+ return { view: view(), params, derived };
67
+ };
62
68
 
63
69
  // ?pick enables click-to-select: a toggle button + a transient toast. Off by
64
70
  // default — no button, no listener, no behavior change. Deleting this block and
@@ -4,6 +4,7 @@
4
4
  // probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { viewSubParts } from "./jobs.js";
7
+ import { resolveDerived } from "./derive.js";
7
8
 
8
9
  export const RELEVANT_ALL = Symbol("relevant-all");
9
10
 
@@ -19,9 +20,56 @@ function recorder(obj, seen) {
19
20
  });
20
21
  }
21
22
 
23
+ // Run derive with recorders. `allInputs` is every raw param derive reads.
24
+ // For the grouped form (derive as an object of group functions — see derive.js),
25
+ // `depsOf` maps each derived key to the raw params of just its own group,
26
+ // transitively including the groups whose outputs it read. For the single-function
27
+ // form there is no per-key attribution, so depsOf is null and callers fall back to
28
+ // treating every derive input as feeding every derived key.
29
+ function analyzeDerive(part, params) {
30
+ const allInputs = new Set();
31
+ if (!part.derive) return { derived: {}, allInputs, depsOf: null };
32
+ if (typeof part.derive === "function") {
33
+ const derived = part.derive(recorder(params, allInputs)) ?? {};
34
+ return { derived, allInputs, depsOf: null };
35
+ }
36
+ const derived = {};
37
+ const depsOf = new Map();
38
+ for (const fn of Object.values(part.derive)) {
39
+ const raw = new Set();
40
+ const fromEarlier = new Set();
41
+ const written = new Set();
42
+ // Reads are recorded (and guarded against not-yet-produced keys, matching
43
+ // resolveDerived); writes pass THROUGH to the real accumulator so a group
44
+ // that mutates `d` in place analyzes exactly like it runs in production.
45
+ const dProxy = new Proxy(derived, {
46
+ get(t, key) {
47
+ if (typeof key === "string" && key !== "then") {
48
+ if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
49
+ fromEarlier.add(key);
50
+ }
51
+ return Reflect.get(t, key);
52
+ },
53
+ set(t, key, v) {
54
+ if (typeof key === "string") written.add(key);
55
+ return Reflect.set(t, key, v);
56
+ },
57
+ });
58
+ const out = fn(recorder(params, raw), dProxy) ?? {};
59
+ const deps = new Set(raw);
60
+ for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
61
+ for (const r of raw) allInputs.add(r);
62
+ for (const key of [...Object.keys(out), ...written]) {
63
+ depsOf.set(key, deps);
64
+ if (Object.hasOwn(out, key)) derived[key] = out[key];
65
+ }
66
+ }
67
+ return { derived, allInputs, depsOf };
68
+ }
69
+
22
70
  export function relevantParamKeys(part, view, params) {
23
71
  // The union of every on-screen sub-part's read set (that's exactly what
24
- // subPartReadKeys computes, derive-input folding included)...
72
+ // subPartReadKeys computes, derive attribution included)...
25
73
  const reads = subPartReadKeys(part, view, params);
26
74
  if (reads === RELEVANT_ALL) return RELEVANT_ALL; // analysis failed → everything relevant
27
75
  try {
@@ -46,8 +94,7 @@ export function relevantParamKeys(part, view, params) {
46
94
  // analysis failure (caller then treats every param as relevant — safe, just slower).
47
95
  export function subPartReadKeys(part, view, params) {
48
96
  try {
49
- const deriveInputs = new Set();
50
- const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
97
+ const { derived, allInputs, depsOf } = analyzeDerive(part, params);
51
98
  const { kernel } = createProbeKernel();
52
99
  const map = new Map();
53
100
  for (const name of viewSubParts(part, view, params)) {
@@ -55,8 +102,20 @@ export function subPartReadKeys(part, view, params) {
55
102
  const reads = new Set();
56
103
  const dSeen = new Set();
57
104
  if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
58
- sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
59
- if (dSeen.size > 0) for (const k of deriveInputs) reads.add(k);
105
+ const built = sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
106
+ // place() shapes what's on screen too (display pose is baked into the cached
107
+ // mesh), so its reads count — without this, a param consumed only by place()
108
+ // would let the mesh cache skip a rebuild and leave the sub-part misplaced.
109
+ if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
110
+ if (dSeen.size > 0) {
111
+ if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
112
+ for (const k of dSeen) for (const dep of depsOf.get(k)) reads.add(dep);
113
+ } else {
114
+ // single-function derive, or a derived key no group produced: no
115
+ // attribution possible — fold every derive input in (safe, coarser).
116
+ for (const dep of allInputs) reads.add(dep);
117
+ }
118
+ }
60
119
  map.set(name, reads);
61
120
  }
62
121
  return map;
@@ -109,12 +109,14 @@ export default {
109
109
  views: { planter: { label: "Planter" } },
110
110
  // Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
111
111
  // FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
112
- // — one drainage hole through the base, fits the bed, no interpenetration.
112
+ // — fits the bed, no interpenetration, and the RIGHT genus per case: verify runs
113
+ // across every preset, and "Pen cup"/"Vase" turn the drain off, so `expect` is a
114
+ // function of the case's params rather than one static hole count.
113
115
  verify: {
114
116
  process: "fdm-pla",
115
- expect: {
116
- planter: { holes: 1 /* drain=8 at defaults 1 hole; adjust if running verify with a non-default drain */, bbox: "<=[220,220,250]" },
117
+ expect: (p) => ({
118
+ planter: { holes: p.drain > 0 ? 1 : 0, bbox: "<=[220,220,250]" },
117
119
  _view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
118
- },
120
+ }),
119
121
  },
120
122
  };
@@ -2,8 +2,9 @@
2
2
  // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
3
  // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
4
  // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
5
- // (raycast) and nearest surface point (closestPoint, added alongside). AABB tree,
6
- // median split on the widest centroid axis, slab ray–box test with pruning.
5
+ // (raycast), nearest surface point (closestPoint), and exact mesh-to-mesh distance
6
+ // (distanceTo). AABB tree, median split on the widest centroid axis, slab ray–box
7
+ // test with pruning.
7
8
 
8
9
  const LEAF = 4; // max triangles per leaf
9
10
 
@@ -104,6 +105,82 @@ function distSqBox(p, min, max) {
104
105
  return s;
105
106
  }
106
107
 
108
+ // summed extent of a node's AABB — the "which node is larger" heuristic for dual traversal
109
+ const nodeExtent = (n) => (n.max[0] - n.min[0]) + (n.max[1] - n.min[1]) + (n.max[2] - n.min[2]);
110
+
111
+ // squared distance between two AABBs (0 when they overlap)
112
+ function boxBoxDistSq(a, b) {
113
+ let s = 0;
114
+ for (let ax = 0; ax < 3; ax++) {
115
+ const v = a.min[ax] > b.max[ax] ? a.min[ax] - b.max[ax] : b.min[ax] > a.max[ax] ? b.min[ax] - a.max[ax] : 0;
116
+ s += v * v;
117
+ }
118
+ return s;
119
+ }
120
+
121
+ // closest points between segments P1→Q1 and P2→Q2 (Ericson 5.1.9), → { a, b, d2 }
122
+ function closestSegSeg(P1, Q1, P2, Q2) {
123
+ const sub = (p, q) => [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
124
+ const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
125
+ const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
126
+ const d1 = sub(Q1, P1), d2v = sub(Q2, P2), r = sub(P1, P2);
127
+ const a = dot(d1, d1), e = dot(d2v, d2v), f = dot(d2v, r);
128
+ const EPS = 1e-12;
129
+ let s, t;
130
+ if (a <= EPS && e <= EPS) { s = 0; t = 0; }
131
+ else if (a <= EPS) { s = 0; t = clamp01(f / e); }
132
+ else {
133
+ const c = dot(d1, r);
134
+ if (e <= EPS) { t = 0; s = clamp01(-c / a); }
135
+ else {
136
+ const b = dot(d1, d2v), denom = a * e - b * b;
137
+ s = denom !== 0 ? clamp01((b * f - c * e) / denom) : 0;
138
+ t = (b * s + f) / e;
139
+ if (t < 0) { t = 0; s = clamp01(-c / a); }
140
+ else if (t > 1) { t = 1; s = clamp01((b - c) / a); }
141
+ }
142
+ }
143
+ const A = [P1[0] + d1[0] * s, P1[1] + d1[1] * s, P1[2] + d1[2] * s];
144
+ const B = [P2[0] + d2v[0] * t, P2[1] + d2v[1] * t, P2[2] + d2v[2] * t];
145
+ const pq = sub(A, B);
146
+ return { a: A, b: B, d2: dot(pq, pq) };
147
+ }
148
+
149
+ // exact min distance between two triangles → { d2, a, b } (a on t1, b on t2).
150
+ // Non-intersecting triangles realize their minimum at a vertex-face or edge-edge
151
+ // feature pair; a piercing edge (interior×interior crossing) is caught first with
152
+ // rayTri, since feature distances alone would miss it. rayTri's t is in units of
153
+ // the unnormalized edge direction, so 0 < t <= 1 means the segment itself pierces;
154
+ // parallel/grazing edges return Infinity and the coplanar cases fall to the
155
+ // feature distances.
156
+ function triTriDist(t1, t2) {
157
+ const edges = (t) => [[t.v0, t.v1], [t.v1, t.v2], [t.v2, t.v0]];
158
+ for (const [p, q] of edges(t1)) {
159
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
160
+ const t = rayTri(p, d, t2, 0);
161
+ if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
162
+ }
163
+ for (const [p, q] of edges(t2)) {
164
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
165
+ const t = rayTri(p, d, t1, 0);
166
+ if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
167
+ }
168
+ let best = { d2: Infinity, a: null, b: null };
169
+ for (const v of [t2.v0, t2.v1, t2.v2]) {
170
+ const r = closestOnTri(v, t1);
171
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.point, b: v };
172
+ }
173
+ for (const v of [t1.v0, t1.v1, t1.v2]) {
174
+ const r = closestOnTri(v, t2);
175
+ if (r.d2 < best.d2) best = { d2: r.d2, a: v, b: r.point };
176
+ }
177
+ for (const [p1, q1] of edges(t1)) for (const [p2, q2] of edges(t2)) {
178
+ const r = closestSegSeg(p1, q1, p2, q2);
179
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.a, b: r.b };
180
+ }
181
+ return best;
182
+ }
183
+
107
184
  // Möller–Trumbore; returns t>tMin or Infinity
108
185
  function rayTri(o, d, tri, tMin) {
109
186
  const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
@@ -144,7 +221,6 @@ export function buildBVH(mesh) {
144
221
  return bestTri === -1 ? null : { t: best, tri: bestTri };
145
222
  }
146
223
 
147
- // No production consumer yet — pre-built + tested as the reusable primitive for the deferred clearance/min-feature gate.
148
224
  function closestPoint(p) {
149
225
  let best2 = Infinity, bestPt = null, bestTri = -1;
150
226
  const stack = [root];
@@ -162,5 +238,36 @@ export function buildBVH(mesh) {
162
238
  return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
163
239
  }
164
240
 
165
- return { raycast, closestPoint };
241
+ // Exact minimum surface-to-surface distance to another buildBVH result.
242
+ // Dual traversal pruned by AABB–AABB distance; exact triangle–triangle
243
+ // distance at leaf pairs; early-exits at 0 (touching/intersecting).
244
+ function distanceTo(other) {
245
+ let best = { d2: Infinity, a: null, b: null };
246
+ const stack = [[root, other._root]];
247
+ while (stack.length && best.d2 > 0) {
248
+ const [na, nb] = stack.pop();
249
+ if (boxBoxDistSq(na, nb) >= best.d2) continue;
250
+ const aLeaf = !!na.tris, bLeaf = !!nb.tris;
251
+ if (aLeaf && bLeaf) {
252
+ for (const ta of na.tris) for (const tb of nb.tris) {
253
+ const r = triTriDist(ta, tb);
254
+ if (r.d2 < best.d2) best = r;
255
+ }
256
+ } else if (!aLeaf && (bLeaf || nodeExtent(na) >= nodeExtent(nb))) {
257
+ // descend the larger node; push the nearer child last so it pops first
258
+ const dl = boxBoxDistSq(na.left, nb), dr = boxBoxDistSq(na.right, nb);
259
+ if (dl < dr) stack.push([na.right, nb], [na.left, nb]);
260
+ else stack.push([na.left, nb], [na.right, nb]);
261
+ } else {
262
+ const dl = boxBoxDistSq(na, nb.left), dr = boxBoxDistSq(na, nb.right);
263
+ if (dl < dr) stack.push([na, nb.right], [na, nb.left]);
264
+ else stack.push([na, nb.left], [na, nb.right]);
265
+ }
266
+ }
267
+ if (best.a === null) return { distance: Infinity, at: null, pointA: null, pointB: null }; // empty mesh
268
+ const at = [(best.a[0] + best.b[0]) / 2, (best.a[1] + best.b[1]) / 2, (best.a[2] + best.b[2]) / 2];
269
+ return { distance: Math.sqrt(best.d2), at, pointA: best.a, pointB: best.b };
270
+ }
271
+
272
+ return { raycast, closestPoint, distanceTo, _root: root };
166
273
  }
@@ -0,0 +1,78 @@
1
+ // Shared parser + matcher for docs/ERROR-PATTERNS.md — the symptom-indexed
2
+ // error→pattern library (issue #28). The parser here is the single source of
3
+ // truth: the format lint (test/error-patterns.test.js) and the CLI crash-path
4
+ // matcher (issue #27) both import it. Contract: partforge code that throws must
5
+ // throw strings appearing verbatim, in a backtick literal AT THE START of some
6
+ // entry's Symptom line — only a leading literal participates in matching, so
7
+ // backticks used for prose mid-sentence never mis-attribute an unrelated crash.
8
+ import { readFileSync } from "node:fs";
9
+
10
+ // Single-pass, fence-aware parse: a heading inside a ``` / ~~~ fence is quoted
11
+ // content, not structure. Each `## <id>` entry records the `# <section>` it sits
12
+ // under; its body runs to the next h1/h2 heading. (Moved verbatim from the lint
13
+ // test, then enriched with symptom/cause/fix extraction.)
14
+ export function parsePatterns(md) {
15
+ const entries = [];
16
+ let section = null;
17
+ let entry = null;
18
+ let inFence = false;
19
+ for (const line of md.split("\n")) {
20
+ if (/^\s*(```|~~~)/.test(line)) {
21
+ inFence = !inFence;
22
+ if (entry) entry.body += line + "\n";
23
+ continue;
24
+ }
25
+ if (!inFence) {
26
+ const h1 = line.match(/^# (.+)$/);
27
+ const h2 = line.match(/^## (.+)$/);
28
+ if (h1) { section = h1[1]; entry = null; continue; }
29
+ if (h2) { entry = { id: h2[1], section, body: "" }; entries.push(entry); continue; }
30
+ }
31
+ if (entry) entry.body += line + "\n";
32
+ }
33
+ const field = (body, label) => {
34
+ const i = body.indexOf(`- **${label}:**`);
35
+ return i < 0 ? null : body.slice(i).split("\n")[0].replace(`- **${label}:**`, "").trim();
36
+ };
37
+ return entries.map((e) => {
38
+ const symptom = field(e.body, "Symptom");
39
+ // Leading-literal convention: only a backtick literal at the very START of the
40
+ // Symptom text is a match literal (kept as a ≤1-element array for compatibility
41
+ // — tests and the matcher read symptomStrings). Mid-line backticks are prose.
42
+ const leading = symptom?.match(/^`([^`]+)`/);
43
+ return {
44
+ ...e,
45
+ symptom,
46
+ cause: field(e.body, "Cause"),
47
+ fix: field(e.body, "Fix"),
48
+ symptomStrings: leading ? [leading[1]] : [],
49
+ };
50
+ });
51
+ }
52
+
53
+ // Cached read of the live doc, resolved relative to this module so it works from
54
+ // a consuming app's node_modules too. Any read/parse error → null (callers treat
55
+ // that as "no patterns available", never an error).
56
+ let cached;
57
+ export function loadPatterns() {
58
+ if (cached !== undefined) return cached;
59
+ try {
60
+ cached = parsePatterns(readFileSync(new URL("../../docs/ERROR-PATTERNS.md", import.meta.url), "utf8"));
61
+ } catch {
62
+ cached = null;
63
+ }
64
+ return cached;
65
+ }
66
+
67
+ // Leading symptom literals ≥ 6 chars, longest match wins. Never throws.
68
+ export function matchPattern(message, patterns = loadPatterns()) {
69
+ if (!patterns || typeof message !== "string") return null;
70
+ let best = null;
71
+ let bestLen = 5;
72
+ for (const p of patterns) {
73
+ for (const s of p.symptomStrings) {
74
+ if (s.length > bestLen && message.includes(s)) { best = p; bestLen = s.length; }
75
+ }
76
+ }
77
+ return best ? { id: best.id, fix: best.fix } : null;
78
+ }
@@ -0,0 +1,48 @@
1
+ import { buildView } from "./build.js";
2
+ import { buildBVH } from "./bvh.js";
3
+
4
+ // A measured pair distance at or below this (mm) counts as touching — absorbs
5
+ // posing float error while staying far below any real print clearance.
6
+ export const CONTACT_EPS = 1e-3;
7
+
8
+ // Default near-miss threshold (mm): pairs closer than this without touching are
9
+ // the "did you mean these to touch?" signal.
10
+ export const GAP_THRESHOLD = 0.5;
11
+
12
+ // Canonical order-insensitive pair identity — the one rule for "the same pair"
13
+ // shared by measure's overlap exclusion and verify's declared-pair matching.
14
+ export const pairKey = (a, b) => [a, b].sort().join("×");
15
+
16
+ // Minimum surface-to-surface distance for every sub-part pair of pre-built posed
17
+ // meshes ([{ name, mesh }] — buildView output). Distance 0 = touching or
18
+ // interpenetrating surfaces; callers filter. Pairs involving an empty mesh are
19
+ // skipped (the watertight gate owns that failure). Pure mesh math — both backends.
20
+ // → [{ a, b, distance, at: [x,y,z] }]
21
+ export function meshGaps(built) {
22
+ const hasTris = (m) => (m.indices ? m.indices.length > 0 : m.positions.length > 0);
23
+ const bvhs = built
24
+ .filter(({ mesh }) => hasTris(mesh))
25
+ .map(({ name, mesh }) => ({ name, bvh: buildBVH(mesh) }));
26
+ const out = [];
27
+ for (let i = 0; i < bvhs.length; i++) {
28
+ for (let j = i + 1; j < bvhs.length; j++) {
29
+ const { distance, at } = bvhs[i].bvh.distanceTo(bvhs[j].bvh);
30
+ out.push({ a: bvhs[i].name, b: bvhs[j].name, distance, at });
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // Near-miss check for an assembled view — the complement of assemblyOverlaps:
37
+ // sub-part pairs that *almost* touch (0 < distance < threshold mm) in the display
38
+ // pose. Same posing path as assemblyOverlaps; no kernel booleans, so it runs on
39
+ // Manifold and OCCT alike.
40
+ // → [{ a, b, distance, at }] (empty = no near misses)
41
+ export function assemblyGaps(kernel, part, view, params = {}, { threshold = GAP_THRESHOLD } = {}) {
42
+ if (!(threshold > CONTACT_EPS)) {
43
+ throw new Error(`assemblyGaps: threshold must exceed CONTACT_EPS (${CONTACT_EPS} mm), got ${threshold}`);
44
+ }
45
+ const gaps = meshGaps(buildView(kernel, part, view, params));
46
+ kernel.cleanup?.(); // free the per-check WASM objects (meshes are JS-owned copies)
47
+ return gaps.filter((g) => g.distance > CONTACT_EPS && g.distance < threshold);
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { assemblyOverlaps } from "../framework/assembly.js";
3
+ import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
3
4
  import { bounds, meshArea } from "./mesh.js";
4
5
  import { minWall } from "./min-wall.js";
5
6
 
@@ -11,15 +12,17 @@ const unionBounds = (list) => list.reduce(
11
12
 
12
13
  // Headless geometric report for one view of a part (Manifold-only). Reads exact
13
14
  // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
14
- // the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
15
+ // the assembly overlap check plus pair gap distances (near misses are reported,
16
+ // never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
15
17
  // which frees the shared kernel's objects at its end.
16
- // → { part, view, subparts[], aggregate, overlaps[], ok }
18
+ // → { part, view, subparts[], aggregate, overlaps[], gaps[], nearMisses[], ok }
17
19
  export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
18
20
  const built = buildView(kernel, part, view, params);
19
21
  const subBounds = [];
20
22
  const subparts = built.map(({ name, solid, mesh }) => {
21
23
  const b = bounds(mesh.positions);
22
24
  subBounds.push(b);
25
+ const mw = opts.minWall ? minWall(mesh) : null;
23
26
  return {
24
27
  name,
25
28
  bbox: size(b),
@@ -28,16 +31,29 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
28
31
  triangleCount: mesh.triangles,
29
32
  watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
30
33
  holes: typeof solid.genus === "function" ? solid.genus() : null,
31
- minWall: opts.minWall ? (minWall(mesh)?.value ?? null) : null,
34
+ minWall: mw?.value ?? null,
35
+ minWallAt: mw?.location ?? null,
32
36
  };
33
37
  });
34
38
 
39
+ // Pair surface distances from the meshes already built — no kernel dependency,
40
+ // so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
41
+ // *almost* touch; overlapping pairs are excluded by name (a fully-contained
42
+ // sub-part has surface distance > 0 but is the overlap gate's business).
43
+ const gaps = built.length > 1 ? meshGaps(built) : [];
44
+
35
45
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
36
46
  // above is already read, so this is safe.
37
47
  const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
38
48
  const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
39
49
  kernel.cleanup?.();
40
50
 
51
+ const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
52
+ const gapThreshold = opts.gapThreshold ?? GAP_THRESHOLD;
53
+ const nearMisses = gaps.filter(
54
+ (g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
55
+ );
56
+
41
57
  const aggregate = {
42
58
  bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
43
59
  volume: subparts.reduce((a, s) => a + s.volume, 0),
@@ -50,6 +66,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
50
66
  subparts,
51
67
  aggregate,
52
68
  overlaps,
69
+ gaps,
70
+ nearMisses,
53
71
  ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
54
72
  };
55
73
  }