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/bin/cli.js +50 -14
- package/docs/AUTHORING-PARTS.md +195 -33
- package/docs/ERROR-PATTERNS.md +168 -0
- package/package.json +3 -1
- package/skills/partforge/SKILL.md +5 -0
- package/src/framework/assembly.js +9 -3
- package/src/framework/derive.js +28 -0
- package/src/framework/geometry/kernel.js +8 -1
- package/src/framework/geometry/probe.js +6 -1
- package/src/framework/jobs.js +8 -5
- package/src/framework/mount.js +7 -1
- package/src/framework/param-deps.js +64 -5
- package/src/parts/planter.js +6 -4
- package/src/testing/bvh.js +111 -4
- package/src/testing/error-patterns.js +78 -0
- package/src/testing/gaps.js +48 -0
- package/src/testing/measure.js +21 -3
- package/src/testing/verify.js +167 -23
- package/src/testing.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "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
|
|
21
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 */ }
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/framework/mount.js
CHANGED
|
@@ -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 = () =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/parts/planter.js
CHANGED
|
@@ -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
|
-
// —
|
|
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:
|
|
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
|
};
|
package/src/testing/bvh.js
CHANGED
|
@@ -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)
|
|
6
|
-
// median split on the widest centroid axis, slab ray–box
|
|
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
|
-
|
|
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
|
+
}
|
package/src/testing/measure.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
}
|