partforge 0.119.0 → 0.121.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 +14 -2
- package/docs/AUTHORING-PARTS.md +72 -5
- package/package.json +1 -1
- package/src/framework/lint/rules-shape.js +2 -2
- package/src/framework/lint/rules-verify.js +16 -1
- package/src/framework/oracle/gates.js +46 -5
- package/src/framework/oracle/measure.js +36 -7
- package/src/framework/oracle/min-wall.js +30 -2
- package/src/framework/oracle/shape-probe.js +146 -0
- package/src/framework/oracle/verify.js +17 -2
- package/src/framework/verify-metrics.js +14 -0
- package/types/part.d.ts +7 -0
- package/types/testing.d.ts +66 -0
package/bin/cli.js
CHANGED
|
@@ -13,8 +13,6 @@ import { fontsFor } from "../src/framework/fonts.js";
|
|
|
13
13
|
import { imagesFor } from "../src/framework/images.js";
|
|
14
14
|
import { isNoImageSource } from "../src/framework/image-source.js";
|
|
15
15
|
import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
|
|
16
|
-
import { measure } from "../src/framework/oracle/measure.js";
|
|
17
|
-
import { verify } from "../src/framework/oracle/verify.js";
|
|
18
16
|
import { renderViews } from "../src/testing/render.js";
|
|
19
17
|
import {
|
|
20
18
|
createPickServer, requestPicks, formatPickResult,
|
|
@@ -114,7 +112,20 @@ const readSources = (partPath) => {
|
|
|
114
112
|
// `ingest` itself never touches these three. Deferring the import to the
|
|
115
113
|
// commands that actually need them keeps every other verb's behavior
|
|
116
114
|
// unchanged and keeps paper-core.js's first load in `ingest`'s hands.
|
|
115
|
+
//
|
|
116
|
+
// oracle/measure.js and oracle/verify.js join that list for the same reason,
|
|
117
|
+
// since measure.js now statically imports oracle/shape-probe.js, which reaches
|
|
118
|
+
// geometry/contour-ops.js -> geometry/paper-bridge.js -> paper-core just like
|
|
119
|
+
// vectors.js does (verify.js reaches the same path through its own static
|
|
120
|
+
// import of measure.js). A top-level `import { measure } ...`/`import { verify }
|
|
121
|
+
// ...` here would load paper-core before `ingest`'s installNodeDom() runs on
|
|
122
|
+
// every invocation, not only `measure`'s — deferring both to the one verb that
|
|
123
|
+
// calls them keeps `ingest` first in line for paper-core's environment probe.
|
|
117
124
|
const importVectors = () => import("../src/framework/vectors.js");
|
|
125
|
+
const importOracle = () => Promise.all([
|
|
126
|
+
import("../src/framework/oracle/measure.js"),
|
|
127
|
+
import("../src/framework/oracle/verify.js"),
|
|
128
|
+
]);
|
|
118
129
|
|
|
119
130
|
// Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
|
|
120
131
|
// otherwise a part using a named font builds in the browser but dies headlessly
|
|
@@ -210,6 +221,7 @@ const commands = {
|
|
|
210
221
|
}
|
|
211
222
|
}
|
|
212
223
|
const kernel = await bootKernel(part);
|
|
224
|
+
const [{ measure }, { verify }] = await importOracle();
|
|
213
225
|
const report = measure(kernel, part, view);
|
|
214
226
|
if (!flags.json) printMeasure(report);
|
|
215
227
|
// Write --out right after measure succeeds, then re-write once verify has
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -89,7 +89,7 @@ export default {
|
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
91
|
views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
|
|
92
|
-
probes?, // { name: (k, p, d) => Solid | plain JSON } — measurements reported by
|
|
92
|
+
probes?, // { name: (k, p, d) => Solid | Shape2D | plain JSON } — measurements reported by
|
|
93
93
|
// measure/inspect, never rendered or exported (see "Probes" below)
|
|
94
94
|
};
|
|
95
95
|
```
|
|
@@ -2419,6 +2419,45 @@ declare a probe that reads the value, run `measure`, and bake the reported
|
|
|
2419
2419
|
number into `defaults` — the probe then keeps watching it on every regen, so a
|
|
2420
2420
|
swapped import shows up as a probe delta instead of silently stale defaults.
|
|
2421
2421
|
|
|
2422
|
+
**Reading a 2-D shape's arcs and corners.** Return the `Shape2D` itself and the
|
|
2423
|
+
report carries a summary instead of the object: a flat `rings` list — one entry
|
|
2424
|
+
per ring, each naming where it came from (`region`, `ring: "outer" | "hole"`,
|
|
2425
|
+
plus `hole` on a hole ring) — every arc as `{ center, r, from, to, sweepDeg }`
|
|
2426
|
+
(exact for a `{to, via}` arc; a cubic is fitted through its start, midpoint and
|
|
2427
|
+
end and tagged `fit: "cubic"`), every corner as `{ position, point,
|
|
2428
|
+
interiorAngleDeg, convex }`, plus `area`, `bbox`, and a straight-segment count
|
|
2429
|
+
per ring. `position` is a running count across every ring in that same order —
|
|
2430
|
+
region by region, outer then holes — which is exactly the positional index
|
|
2431
|
+
`fillet({corners: {indices}})` and `shape.corners()` select on, so a corner read
|
|
2432
|
+
here can be fed straight back into a fillet call.
|
|
2433
|
+
|
|
2434
|
+
Fillet itself never emits a cubic — paper.js has no arc primitive, so an
|
|
2435
|
+
exactly-constructed `{to, via}` arc only becomes a cubic once the shape has been
|
|
2436
|
+
through a **boolean** (union/cut/intersect), a non-uniform transform, or
|
|
2437
|
+
`simplify`. So after any of those, every arc in the summary is `fit: "cubic"`,
|
|
2438
|
+
including ones a fillet placed exactly. For an unclipped arc that cubic fit is
|
|
2439
|
+
still exact to the reporting grid; when a boolean clips an arc **mid-sweep**,
|
|
2440
|
+
the fit's centre and radius drift, and the drift grows as the kept fragment
|
|
2441
|
+
shortens. So when you need an exact centre, read it off the **unclipped**
|
|
2442
|
+
shape and use a clip only to find which arc to look at — don't trust the centre
|
|
2443
|
+
reported on the clipped fragment itself:
|
|
2444
|
+
|
|
2445
|
+
```js
|
|
2446
|
+
probes: {
|
|
2447
|
+
fullBend: (k, p, d) => trayPocket(k, p, d),
|
|
2448
|
+
// Clip only to locate the arc; read its numbers off fullBend above, not here.
|
|
2449
|
+
bendNear: (k, p, d) => trayPocket(k, p, d).intersect(k.shape2d([[18, -6], [28, -6], [28, 4], [18, 4]])),
|
|
2450
|
+
}
|
|
2451
|
+
// → probes.fullBend.rings[0].arcs: [{ center: [23.75, -3], r: 4, … }, …]
|
|
2452
|
+
```
|
|
2453
|
+
|
|
2454
|
+
This is the instrument for any question a render cannot settle to a fraction of
|
|
2455
|
+
a millimetre: whether two arcs share a centre (a bend's inner and outer radii),
|
|
2456
|
+
what radius a `fillet` actually took after clamping, whether a corner is still
|
|
2457
|
+
a corner. The summary lists at most 64 arcs and 64 corners **in total**, across
|
|
2458
|
+
every ring — not 64 each per ring — and says `truncated` when it had to cut, so
|
|
2459
|
+
keep the probe to the region in question.
|
|
2460
|
+
|
|
2422
2461
|
**Cost.** Probes run on every `measure`/`inspect` (including quick checks — the
|
|
2423
2462
|
agent loop is exactly who reads them), so keep them proportionate: a handful of
|
|
2424
2463
|
thin-slab booleans is cheap; a dense sweep of whole-part XORs is not. `verify`'s
|
|
@@ -3208,11 +3247,18 @@ A part that opted into the overhang check (see the `verify` block) also carries
|
|
|
3208
3247
|
checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
|
|
3209
3248
|
face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
|
|
3210
3249
|
`measuredOverhang` stamps the angle the pass ran against, or `null`.
|
|
3250
|
+
A sub-part that declares `wall` (see the `verify` section below) also carries
|
|
3251
|
+
`wall` — `{ value, location, band, members }` or `null`: the declared band's
|
|
3252
|
+
worst member, located, with the band it was measured against and how many rays
|
|
3253
|
+
fell inside it. `null` unless the sub-part declares `wall` *and* min wall was
|
|
3254
|
+
measured for this run — the same "declared but not measured" gap `minWall`
|
|
3255
|
+
itself has.
|
|
3211
3256
|
**The budget depends on whether the reading is checked against anything**: a part
|
|
3212
3257
|
that declares a min-wall gate — a `verify.process` profile, or an `expect`
|
|
3213
|
-
mentioning `minWall` — gets 50,000, because a gate's verdict rides on
|
|
3214
|
-
that declares neither gets 5,000, because there the number is a
|
|
3215
|
-
reader rather than an assertion. Declaring the gate is what
|
|
3258
|
+
mentioning `minWall` or `wall` — gets 50,000, because a gate's verdict rides on
|
|
3259
|
+
it; a part that declares neither gets 5,000, because there the number is a
|
|
3260
|
+
diagnostic for a reader rather than an assertion. Declaring the gate is what
|
|
3261
|
+
buys the resolution.
|
|
3216
3262
|
`sampled` is how many triangles the walk *selected*, not how many rays were
|
|
3217
3263
|
cast: a degenerate (zero-area) triangle has no normal to cast along and is
|
|
3218
3264
|
skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
|
|
@@ -3333,7 +3379,7 @@ are skipped. Switch it off under an FDM profile with an inline
|
|
|
3333
3379
|
`{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
|
|
3334
3380
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
3335
3381
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
3336
|
-
`overhangArea`, `boundsMin` / `boundsMax`
|
|
3382
|
+
`overhangArea`, `wall` (a range — see below), `boundsMin` / `boundsMax`
|
|
3337
3383
|
(the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
|
|
3338
3384
|
`bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
3339
3385
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
@@ -3374,6 +3420,27 @@ verify: { expect: {
|
|
|
3374
3420
|
and so is `overhangArea` (see above). `holes`/`watertight` are Manifold-only, so those
|
|
3375
3421
|
assertions **skip** on OCCT parts rather than fail.
|
|
3376
3422
|
|
|
3423
|
+
**A wall that must stay one thickness: `wall`.** `minWall` answers "is anything too
|
|
3424
|
+
thin"; `wall` answers "does this wall stay what I declared" — the question a bend, a
|
|
3425
|
+
fillet or an offset silently breaks. Declare it as a range in mm, per sub-part:
|
|
3426
|
+
|
|
3427
|
+
```js
|
|
3428
|
+
verify: { expect: { tray: { wall: "1.8..2.2" } } }
|
|
3429
|
+
```
|
|
3430
|
+
|
|
3431
|
+
It rides the same inward rays as `minWall`. A ray reading inside `[0.75 × min,
|
|
3432
|
+
1.5 × max]` counts as this wall (a 1.2 mm floor under a 2 mm wall is another
|
|
3433
|
+
feature and is ignored) — but the window discriminates by **thickness alone, not
|
|
3434
|
+
intent**: anything else on that sub-part whose thickness falls in the window is
|
|
3435
|
+
counted too, so declare `wall` on a sub-part that is mostly this one wall; a rib
|
|
3436
|
+
or boss inside the window reads as a deviation, and a part that needs two
|
|
3437
|
+
thicknesses declares two sub-parts. The check reports the member farthest from
|
|
3438
|
+
the band — or, when every member is inside it, the one farthest from its
|
|
3439
|
+
midpoint — with its location, and warns when it lies outside — `wall 2.62 out of
|
|
3440
|
+
1.8..2.2 at (22.8, -2.0, 15.1)` is a bend whose outer arc is not concentric with
|
|
3441
|
+
its inner one. Range form only (lint refuses `"<=2"`), a warning like `minWall`
|
|
3442
|
+
because it is a sampled reading.
|
|
3443
|
+
|
|
3377
3444
|
**A verify block that declares nothing verifies nothing.** `verify.ok` is tri-state:
|
|
3378
3445
|
`true` when every declared check passed, `false` on any gate failure, and `null` when
|
|
3379
3446
|
no verdict can be given — a quick lap that could not measure a gate, or a part with
|
package/package.json
CHANGED
|
@@ -49,13 +49,13 @@ export const SHAPE_RULES = [
|
|
|
49
49
|
if (part?.probes === undefined) return [];
|
|
50
50
|
if (!isPlainObject(part.probes)) {
|
|
51
51
|
return [err("invalid-probes", "`probes` must be an object mapping names to functions",
|
|
52
|
-
"Declare probes as `probes: { name: (k, p, d) => Solid | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
|
|
52
|
+
"Declare probes as `probes: { name: (k, p, d) => Solid | Shape2D | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
|
|
53
53
|
"probes")];
|
|
54
54
|
}
|
|
55
55
|
return Object.entries(part.probes)
|
|
56
56
|
.filter(([, fn]) => typeof fn !== "function")
|
|
57
57
|
.map(([name]) => err("invalid-probes", `probe "${name}" is not a function`,
|
|
58
|
-
"Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts) or plain JSON (reported verbatim).",
|
|
58
|
+
"Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts), a Shape2D (summarised into its arcs and corners), or plain JSON (reported verbatim).",
|
|
59
59
|
`probes.${name}`));
|
|
60
60
|
},
|
|
61
61
|
},
|
|
@@ -245,13 +245,28 @@ export const VERIFY_RULES = [
|
|
|
245
245
|
// from this scalar-metric loop. `clearance`'s values are still assertions
|
|
246
246
|
// though, so they get their own pass (with a pair-shaped path) below.
|
|
247
247
|
const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
|
|
248
|
+
const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
|
|
248
249
|
for (const [metric, spec] of Object.entries(metrics)) {
|
|
249
|
-
|
|
250
|
+
let parsed;
|
|
251
|
+
try { parsed = parseAssertion(exprOf(spec)); }
|
|
250
252
|
catch (e) {
|
|
251
253
|
out.push(err("verify-bad-expr",
|
|
252
254
|
`the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
|
|
253
255
|
"Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
|
|
254
256
|
`verify.expect.${target}.${metric}`));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const form = registry[metric]?.form;
|
|
260
|
+
if (form === "range" && parsed.op !== "range") {
|
|
261
|
+
out.push(err("verify-bad-expr",
|
|
262
|
+
`the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
|
|
263
|
+
`Write ${metric} as \`"<min>..<max>"\` in mm.`,
|
|
264
|
+
`verify.expect.${target}.${metric}`));
|
|
265
|
+
} else if (form === "range" && parsed.op === "range" && parsed.min > parsed.max) {
|
|
266
|
+
out.push(err("verify-bad-expr",
|
|
267
|
+
`the expectation for ${target}.${metric} must be a range with min <= max, got "${exprOf(spec)}"`,
|
|
268
|
+
`Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
|
|
269
|
+
`verify.expect.${target}.${metric}`));
|
|
255
270
|
}
|
|
256
271
|
}
|
|
257
272
|
}
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// would put a coarse reading behind a real gate, which is exactly the trap verify's
|
|
7
7
|
// seeding block guards against.
|
|
8
8
|
//
|
|
9
|
-
// Every function here is TOTAL
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Every function here is TOTAL, with one stated exception: `partWallBands` throws
|
|
10
|
+
// on a malformed `wall` range (see there). Elsewhere, a part whose profile name is
|
|
11
|
+
// unknown, or whose `expect` function throws, is reported as GATED — the
|
|
12
|
+
// conservative direction, since that is the full-resolution behaviour every part
|
|
13
|
+
// had before budgets existed. The real error surfaces from verify, which is where
|
|
14
|
+
// a reader can act on it.
|
|
13
15
|
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
14
16
|
import { expandCases } from "./cases.js";
|
|
15
17
|
import { resolveParams } from "../part-model.js";
|
|
18
|
+
import { parseAssertion } from "./assert-dsl.js";
|
|
16
19
|
|
|
17
20
|
// `expect` may be a function of a case's resolved params, so the answer is a property
|
|
18
21
|
// of the EXPANDED cases, not of the raw spec — which means answering it costs a call
|
|
@@ -52,8 +55,46 @@ export function partGatesMinWall(part, { process, expanded } = {}) {
|
|
|
52
55
|
const spec = process ?? part?.verify?.process;
|
|
53
56
|
if (spec && resolveProfile(spec)?.minWall != null) return true;
|
|
54
57
|
return (expanded ?? expandExpectations(part)).some(({ expect }) =>
|
|
55
|
-
Object.values(expect ?? {}).some((o) => o && typeof o === "object" && "minWall" in o));
|
|
58
|
+
Object.values(expect ?? {}).some((o) => o && typeof o === "object" && ("minWall" in o || "wall" in o)));
|
|
56
59
|
} catch {
|
|
57
60
|
return true; // unresolvable → measure it properly and let verify report the error
|
|
58
61
|
}
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
// The wall bands a measurement of `params` must track, per sub-part, from the part's
|
|
65
|
+
// own `verify.expect` — resolved for exactly these params (a function expect may
|
|
66
|
+
// change the band per case). Range form only: the membership window needs both ends.
|
|
67
|
+
// Throws on a bad form (a non-range expression, or min > max) so verify reports it
|
|
68
|
+
// where a reader can act — measure's caller (Task 6) lets that throw surface as the
|
|
69
|
+
// measure error it is; this is the one place in this file that is not total.
|
|
70
|
+
// A throw from `spec(p, d)` ITSELF is a different, incidental case — the part's own
|
|
71
|
+
// `expect` function failed for a reason that has nothing to do with wall bands (it
|
|
72
|
+
// read a param that does not exist for this preset, say). That is not this
|
|
73
|
+
// function's throw to report: verify's own `expandExpectations` path calls the same
|
|
74
|
+
// spec and already lets that throw surface from `verify()` — reporting it a second
|
|
75
|
+
// time here, mid-measure, would be a confusing duplicate. Swallow it and answer "no
|
|
76
|
+
// bands", the same as a part that declares none.
|
|
77
|
+
export function partWallBands(part, params = {}) {
|
|
78
|
+
const spec = part?.verify?.expect;
|
|
79
|
+
if (!spec) return {};
|
|
80
|
+
let expect;
|
|
81
|
+
if (typeof spec === "function") {
|
|
82
|
+
const { p, d } = resolveParams(part, params);
|
|
83
|
+
try { expect = spec(p, d) ?? {}; } catch { return {}; }
|
|
84
|
+
} else {
|
|
85
|
+
expect = spec;
|
|
86
|
+
}
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [name, metrics] of Object.entries(expect)) {
|
|
89
|
+
if (name === "_view" || !metrics || typeof metrics !== "object" || !("wall" in metrics)) continue;
|
|
90
|
+
const raw = metrics.wall;
|
|
91
|
+
const expr = raw && typeof raw === "object" && "expr" in raw ? raw.expr : raw;
|
|
92
|
+
let parsed = null;
|
|
93
|
+
try { parsed = parseAssertion(expr); } catch { parsed = null; }
|
|
94
|
+
if (!parsed || parsed.op !== "range" || parsed.min > parsed.max) {
|
|
95
|
+
throw new Error(`wall expectation for "${name}" must be a range like "1.8..2.2" with min <= max`);
|
|
96
|
+
}
|
|
97
|
+
out[name] = { min: parsed.min, max: parsed.max };
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -6,7 +6,8 @@ import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
|
|
|
6
6
|
import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
7
7
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
8
8
|
import { overhang } from "./overhang.js";
|
|
9
|
-
import { partGatesMinWall, partOverhangAngle } from "./gates.js";
|
|
9
|
+
import { partGatesMinWall, partOverhangAngle, partWallBands } from "./gates.js";
|
|
10
|
+
import { summarizeContours } from "./shape-probe.js";
|
|
10
11
|
|
|
11
12
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
12
13
|
const unionBounds = (list) => list.reduce(
|
|
@@ -15,20 +16,37 @@ const unionBounds = (list) => list.reduce(
|
|
|
15
16
|
);
|
|
16
17
|
|
|
17
18
|
// ── probes ──────────────────────────────────────────────────────────────────
|
|
18
|
-
// Part-declared measurements: `probes: { name: (k, p, d) => Solid | JSON }`,
|
|
19
|
+
// Part-declared measurements: `probes: { name: (k, p, d) => Solid | Shape2D | JSON }`,
|
|
19
20
|
// pure functions with build's exact contract but whose result lands in the
|
|
20
21
|
// REPORT instead of the scene. The instrument a rebuild-against-reference
|
|
21
22
|
// workflow needs — before this, getting a cross-section's numbers out of the
|
|
22
23
|
// pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
|
|
23
24
|
// their facts out of the sub-part list (the "Probes" feedback report).
|
|
24
25
|
// A Solid anywhere in the return value (duck-typed on volume+toMesh, the two
|
|
25
|
-
// queries the facts need) is replaced by a fact object;
|
|
26
|
-
// pass through; a throw becomes
|
|
27
|
-
// they never crash the measurement and
|
|
26
|
+
// queries the facts need) is replaced by a fact object; a Shape2D by its arc/corner
|
|
27
|
+
// summary (shape-probe.js); scalars/arrays/objects pass through; a throw becomes
|
|
28
|
+
// `{ error }` — probes are instrumentation, so they never crash the measurement and
|
|
29
|
+
// never gate `ok`.
|
|
28
30
|
|
|
29
31
|
const isSolid = (v) => v !== null && typeof v === "object"
|
|
30
32
|
&& typeof v.volume === "function" && typeof v.toMesh === "function";
|
|
31
33
|
|
|
34
|
+
// A Shape2D is a plain object carrying a `_shape2d` marker, not a class instance, but
|
|
35
|
+
// walking it as generic JSON would still leak its storage fields (`_regions`, `_hash`)
|
|
36
|
+
// and turn its methods into `{error}` entries. Duck-typed on the two reads the summary
|
|
37
|
+
// needs, so a foreign shape-like value (same two methods, no marker) is still summarised.
|
|
38
|
+
const isShape2D = (v) => v !== null && typeof v === "object"
|
|
39
|
+
&& typeof v.toContours === "function" && typeof v.area === "function";
|
|
40
|
+
|
|
41
|
+
const shapeProbeFacts = (shape) => {
|
|
42
|
+
const empty = typeof shape.isEmpty === "function" ? shape.isEmpty() : false;
|
|
43
|
+
return summarizeContours(shape.toContours(), {
|
|
44
|
+
isEmpty: empty,
|
|
45
|
+
area: empty ? 0 : shape.area(),
|
|
46
|
+
bbox: empty ? null : (typeof shape.boundingBox === "function" ? shape.boundingBox() : null),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
32
50
|
function solidProbeFacts(solid) {
|
|
33
51
|
const mesh = solid.toMesh();
|
|
34
52
|
// Empty = the probe's boolean found nothing (a slab that misses the part).
|
|
@@ -60,8 +78,9 @@ function solidProbeFacts(solid) {
|
|
|
60
78
|
const MAX_PROBE_VALUE_DEPTH = 4;
|
|
61
79
|
function resolveProbeValue(v, depth = 0) {
|
|
62
80
|
if (isSolid(v)) return solidProbeFacts(v);
|
|
81
|
+
if (isShape2D(v)) return shapeProbeFacts(v);
|
|
63
82
|
if (v === null || typeof v !== "object") {
|
|
64
|
-
return typeof v === "function" ? { error: "probe returned a function — return a Solid or plain JSON" } : v;
|
|
83
|
+
return typeof v === "function" ? { error: "probe returned a function — return a Solid, a Shape2D or plain JSON" } : v;
|
|
65
84
|
}
|
|
66
85
|
if (depth >= MAX_PROBE_VALUE_DEPTH) return { error: `probe value deeper than ${MAX_PROBE_VALUE_DEPTH} levels` };
|
|
67
86
|
if (Array.isArray(v)) return v.map((x) => resolveProbeValue(x, depth + 1));
|
|
@@ -124,6 +143,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
124
143
|
// inward ray per sampled triangle plus the BVH those rays need — and on an
|
|
125
144
|
// ungated part it buys a fact nobody checks, at full price, on every agent edit.
|
|
126
145
|
const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
|
|
146
|
+
// Declared wall bands, per sub-part, for exactly these params (Task 5). Resolved
|
|
147
|
+
// once per measure; a bad declaration throws here, which is a measure error.
|
|
148
|
+
const wallBands = opts.minWall ? partWallBands(part, params) : {};
|
|
127
149
|
// Overhang is measured only for a part that opted in (dfm-profiles.js
|
|
128
150
|
// overhangAngleFor) — everything else reads null. verify hands the angle it
|
|
129
151
|
// resolved in (a `process` override changes it); `null` there is an explicit
|
|
@@ -138,7 +160,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
138
160
|
subBounds.push(b);
|
|
139
161
|
// Resolved lazily and only when asked for: without min-wall, a single-sub-part
|
|
140
162
|
// view (no meshGaps) must still build no index at all.
|
|
141
|
-
const mw = opts.minWall
|
|
163
|
+
const mw = opts.minWall
|
|
164
|
+
? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
|
|
165
|
+
: null;
|
|
142
166
|
// One pass over the triangles, no index — cheap enough for every lap. The bed
|
|
143
167
|
// is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
|
|
144
168
|
// that is never printed (`exportable: false` — a reference ghost, a probe
|
|
@@ -189,6 +213,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
189
213
|
// `measuredMinWall` false is "never looked".
|
|
190
214
|
minWallSampled: mw?.sampled ?? false,
|
|
191
215
|
minWallSamples: mw ? { sampled: mw.sampledTriangles, total: mw.totalTriangles } : null,
|
|
216
|
+
// The declared wall band's worst member (min-wall.js): null unless this
|
|
217
|
+
// sub-part declared `wall` and the pass ran.
|
|
218
|
+
wall: mw?.band && wallBands[name]
|
|
219
|
+
? { value: mw.band.value, location: mw.band.location, band: wallBands[name], members: mw.band.members }
|
|
220
|
+
: null,
|
|
192
221
|
// Unsupported downward-facing area in mm² (overhang.js), null when the part
|
|
193
222
|
// is not laid out for a bed: bridges and bore ceilings count, by design.
|
|
194
223
|
overhangArea: oh ? oh.area : null,
|
|
@@ -65,12 +65,19 @@ function sampleStride(n) {
|
|
|
65
65
|
return s;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Membership window for a declared wall band: a ray reading inside
|
|
69
|
+
// [BAND_FLOOR × min, BAND_CEIL × max] belongs to that wall; anything outside is another
|
|
70
|
+
// feature (a thinner floor, a solid boss) and is ignored. Constants, not knobs: a part
|
|
71
|
+
// that needs two bands declares two sub-parts.
|
|
72
|
+
export const BAND_FLOOR = 0.75;
|
|
73
|
+
export const BAND_CEIL = 1.5;
|
|
74
|
+
|
|
68
75
|
// `bvh` is an already-built index for THIS mesh — one mesh, one index, so there is
|
|
69
76
|
// nothing to key here: measure() resolves it out of the Map it shares with meshGaps
|
|
70
77
|
// (see cachedBVH for why that Map is the caller's) and passes the value. Omit it and
|
|
71
78
|
// one is built. It does not interact with sampling — sampling picks WHICH rays to
|
|
72
79
|
// cast, not how the index is built, so a shared BVH is equally valid sampled or exact.
|
|
73
|
-
export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh) } = {}) {
|
|
80
|
+
export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh), band = null } = {}) {
|
|
74
81
|
const n = bvh.triangleCount;
|
|
75
82
|
if (n === 0) return null;
|
|
76
83
|
const V = bvh.vertices;
|
|
@@ -91,6 +98,19 @@ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = bu
|
|
|
91
98
|
const sampled = budget < n;
|
|
92
99
|
const stride = sampled ? sampleStride(n) : 1; // stride 1 = every triangle, in mesh order
|
|
93
100
|
|
|
101
|
+
// Band tracking: the member farthest from the band, or — while every member is
|
|
102
|
+
// inside it — farthest from its midpoint. One comparison per ray, no new rays.
|
|
103
|
+
// All four locals below are meaningless without a band, so they stay undefined
|
|
104
|
+
// on the no-band path rather than holding dead zeroes nothing reads.
|
|
105
|
+
let bandLo, bandHi, bandMid, bandScore;
|
|
106
|
+
if (band) {
|
|
107
|
+
bandLo = BAND_FLOOR * band.min;
|
|
108
|
+
bandHi = BAND_CEIL * band.max;
|
|
109
|
+
bandMid = (band.min + band.max) / 2;
|
|
110
|
+
bandScore = (x) => x > band.max ? 1 + (x - band.max) : x < band.min ? 1 + (band.min - x) : Math.abs(x - bandMid) / (band.max - band.min + 1e-9);
|
|
111
|
+
}
|
|
112
|
+
let bandWorst = -1, bandValue = null, bandLoc = null, members = 0;
|
|
113
|
+
|
|
94
114
|
let best = Infinity, loc = null, t = 0;
|
|
95
115
|
const tri = new Float64Array(9); // reused per triangle; no per-ray garbage
|
|
96
116
|
for (let s = 0; s < budget; s++, t = t + stride < n ? t + stride : t + stride - n) {
|
|
@@ -107,9 +127,17 @@ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = bu
|
|
|
107
127
|
const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
|
|
108
128
|
const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
|
|
109
129
|
if (hit && hit.t < best) { best = hit.t; loc = c; }
|
|
130
|
+
if (band && hit && hit.t >= bandLo && hit.t <= bandHi) {
|
|
131
|
+
members++;
|
|
132
|
+
const score = bandScore(hit.t);
|
|
133
|
+
if (score > bandWorst) { bandWorst = score; bandValue = hit.t; bandLoc = c; }
|
|
134
|
+
}
|
|
110
135
|
}
|
|
111
136
|
// No hit anywhere still reports HOW it looked (see the header): a `value: null`
|
|
112
137
|
// with the sampling accounting intact, never a bare null that reads downstream as
|
|
113
138
|
// "min wall was never measured".
|
|
114
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
value: best === Infinity ? null : best, location: loc, sampled, sampledTriangles: budget, totalTriangles: n,
|
|
141
|
+
band: band ? { value: bandValue, location: bandLoc, members } : null,
|
|
142
|
+
};
|
|
115
143
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The probe-report summary of a Shape2D: its arcs as centre + radius, its corners as
|
|
2
|
+
// point + angle. A reader asking "do these two arcs share a centre?" or "what radius
|
|
3
|
+
// did that fillet actually take?" reads the numbers here instead of a render.
|
|
4
|
+
//
|
|
5
|
+
// Arcs come from the contour IR, never from tessellation: a {to, via} segment is exact
|
|
6
|
+
// (circumcircle of from/via/to). A cubic is FITTED through its start, midpoint and end
|
|
7
|
+
// and tagged `fit: "cubic"` so the reader knows it is an approximation. Fillet itself
|
|
8
|
+
// never emits a cubic — paper.js has no arc primitive, so a shape stores one only once
|
|
9
|
+
// it has been through a BOOLEAN (union/cut/intersect), a non-uniform transform, or
|
|
10
|
+
// simplify. `fit: "cubic"` therefore means "this shape passed through one of those",
|
|
11
|
+
// not "this arc came from a curve-adjacent fillet". For an unclipped arc the cubic fit
|
|
12
|
+
// is exact to the reporting grid; when a boolean clips an arc mid-sweep the fit's error
|
|
13
|
+
// grows as the kept fragment shortens (measured: a r=4 fillet corner clipped at x=18
|
|
14
|
+
// read back centre [16.0063, 16.0184] r 3.9816; a r=100 arc clipped to a 20 mm band read
|
|
15
|
+
// back centre off by 0.59 mm, r 100.586) — compare centres on the UNCLIPPED shape and
|
|
16
|
+
// use a clip only to find which arcs to look at, never to read an exact centre off the
|
|
17
|
+
// clipped fragment itself. See docs/AUTHORING-PARTS.md's probes section.
|
|
18
|
+
//
|
|
19
|
+
// Either recovery (via or cubic) can also produce a near-infinite-radius circle from
|
|
20
|
+
// residual curvature on a segment that reads as straight — most visibly after
|
|
21
|
+
// `simplify`, which can leave a nominally-straight edge with a fraction of a micron
|
|
22
|
+
// of real (not floating-point-noise) bow. A segment whose sagitta (the arc's peak
|
|
23
|
+
// deviation from its own chord, r·(1 − cos(Δ/2))) falls below STRAIGHT_SAGITTA_MM
|
|
24
|
+
// (1e-3 mm, a micron — below anything a print or a reader can act on; an r=100 arc on
|
|
25
|
+
// a 20 mm chord has 0.5 mm of sagitta and an r=1000 one 0.05 mm, so a real bend is
|
|
26
|
+
// nowhere near this floor) is reported as a line instead of a kilometre-radius arc.
|
|
27
|
+
//
|
|
28
|
+
// Corners are profileCorners' reader-facing fields, plus `position`: a running count
|
|
29
|
+
// across every ring in the summary's own order (region by region, outer then holes) —
|
|
30
|
+
// exactly the positional index `fillet({corners: {indices}})` and `shape.corners()`
|
|
31
|
+
// use (both resolve against profileCorners' own flattened order), computed by that same
|
|
32
|
+
// function here rather than re-derived. The `index` field (profileCorners' vertex
|
|
33
|
+
// number within its own contour, not a position into this list) is deliberately
|
|
34
|
+
// dropped — carrying it here is what invites the {indices} mistake.
|
|
35
|
+
//
|
|
36
|
+
// The summary is a FLAT list of rings (each one names its own `region`/`ring`/`hole?`)
|
|
37
|
+
// rather than nested `{outer, holes}` objects, and the two caps below are counted
|
|
38
|
+
// across the WHOLE summary, not per ring: partforge-cloud drops a probe value that
|
|
39
|
+
// grows past its JSON budget or nests past its depth cap. Flat, the summary bottoms
|
|
40
|
+
// out at rings[] → ring → arcs[] → arc → center[] — 4 containers below the summary
|
|
41
|
+
// object itself (5 including it), against the cloud's `DESCRIBE_MAX_DEPTH = 8`, with
|
|
42
|
+
// headroom even wrapped in the paired-probe `{pair: {mine, ref}}` form.
|
|
43
|
+
import { arcCenterAndSweep } from "../geometry/paper-bridge.js";
|
|
44
|
+
import { cubicAt, profileCorners } from "../geometry/contour-ops.js";
|
|
45
|
+
|
|
46
|
+
export const MAX_ARCS = 64;
|
|
47
|
+
export const MAX_CORNERS = 64;
|
|
48
|
+
// A micron: below anything a print or a reader can act on. An r=100 arc on a 20 mm
|
|
49
|
+
// chord has 0.5 mm of sagitta and an r=1000 one 0.05 mm, so a real bend is nowhere
|
|
50
|
+
// near this floor — only residual curvature left by simplify/boolean noise is.
|
|
51
|
+
export const STRAIGHT_SAGITTA_MM = 1e-3;
|
|
52
|
+
|
|
53
|
+
const round = (x) => {
|
|
54
|
+
const v = Math.round(x * 1e4) / 1e4;
|
|
55
|
+
return v === 0 ? 0 : v; // normalize -0 so the report never prints a bare minus sign
|
|
56
|
+
};
|
|
57
|
+
const pt = ([x, y]) => [round(x), round(y)];
|
|
58
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
59
|
+
|
|
60
|
+
// A recovered circle → an arc record, or null when its sagitta (peak deviation from
|
|
61
|
+
// its own chord) is below STRAIGHT_SAGITTA_MM — a near-straight segment reads as a
|
|
62
|
+
// line rather than a many-kilometre arc.
|
|
63
|
+
function arcFromCircle(c, from, to, fit) {
|
|
64
|
+
const sagitta = c.r * (1 - Math.cos(Math.abs(c.dA) / 2));
|
|
65
|
+
if (sagitta < STRAIGHT_SAGITTA_MM) return null;
|
|
66
|
+
const rec = { center: pt(c.center), r: round(c.r), from: pt(from), to: pt(to), sweepDeg: round(toDeg(c.dA)) };
|
|
67
|
+
if (fit) rec.fit = fit;
|
|
68
|
+
return rec;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One segment → an arc record, or null for a straight (degenerate or near-straight) segment.
|
|
72
|
+
function arcOf(from, seg) {
|
|
73
|
+
if (seg.c1 && seg.c2) {
|
|
74
|
+
const mid = cubicAt(from, seg.c1, seg.c2, seg.to, 0.5);
|
|
75
|
+
const c = arcCenterAndSweep(from, mid, seg.to);
|
|
76
|
+
return c ? arcFromCircle(c, from, seg.to, "cubic") : null;
|
|
77
|
+
}
|
|
78
|
+
if (seg.via) {
|
|
79
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
80
|
+
return c ? arcFromCircle(c, from, seg.to, null) : null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One ring's segments → { segments, arcs, lines }. `state.arcsUsed` is the
|
|
86
|
+
// WHOLE-SUMMARY arc budget (MAX_ARCS), shared across every ring's call so the cap is
|
|
87
|
+
// counted across the whole summary rather than restarting per ring; `segments`/`lines`
|
|
88
|
+
// stay exact counts regardless of truncation.
|
|
89
|
+
function ringArcs(contour, state) {
|
|
90
|
+
const arcs = [];
|
|
91
|
+
let lines = 0;
|
|
92
|
+
let from = contour.start;
|
|
93
|
+
for (const seg of contour.segments) {
|
|
94
|
+
const a = arcOf(from, seg);
|
|
95
|
+
if (a) {
|
|
96
|
+
if (state.arcsUsed < MAX_ARCS) { arcs.push(a); state.arcsUsed++; } else state.truncated = true;
|
|
97
|
+
} else lines++;
|
|
98
|
+
from = seg.to;
|
|
99
|
+
}
|
|
100
|
+
return { segments: contour.segments.length, arcs, lines };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The key a ring is filed under, shared between the rings[] list this function builds
|
|
104
|
+
// and the bucketing of profileCorners' flattened output back onto the ring it came from.
|
|
105
|
+
const ringKey = (region, kind, hole) => (kind === "outer" ? `${region}:outer` : `${region}:hole:${hole}`);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Summarise a Shape2D's stored contour regions (the `toContours()` value) for the
|
|
109
|
+
* probe report. `isEmpty`, `area` and `bbox` are read by the caller from the shape
|
|
110
|
+
* itself so this stays a pure function of plain data.
|
|
111
|
+
*/
|
|
112
|
+
export function summarizeContours(regions, { isEmpty, area, bbox }) {
|
|
113
|
+
if (isEmpty || !regions?.length) return { kind: "shape2d", empty: true, area: 0, bbox: null, rings: [], truncated: false };
|
|
114
|
+
const state = { arcsUsed: 0, truncated: false };
|
|
115
|
+
|
|
116
|
+
// profileCorners(regions) walks every ring in the SAME region-by-region,
|
|
117
|
+
// outer-then-holes order this function does, and its `position` field is already
|
|
118
|
+
// the flattened index {corners: {indices}} and shape.corners() agree on — computed
|
|
119
|
+
// once here, over the whole shape, rather than restarting the count per ring.
|
|
120
|
+
const allCorners = profileCorners(regions);
|
|
121
|
+
if (allCorners.length > MAX_CORNERS) state.truncated = true;
|
|
122
|
+
const cornersByRing = new Map();
|
|
123
|
+
for (let i = 0; i < allCorners.length && i < MAX_CORNERS; i++) {
|
|
124
|
+
const c = allCorners[i];
|
|
125
|
+
const key = c.ring === "outer" ? ringKey(c.regionIndex, "outer") : ringKey(c.regionIndex, "hole", c.ring.hole);
|
|
126
|
+
const bucket = cornersByRing.get(key) ?? [];
|
|
127
|
+
bucket.push({ position: c.position, point: pt(c.point), interiorAngleDeg: round(c.interiorAngleDeg), convex: c.convex });
|
|
128
|
+
cornersByRing.set(key, bucket);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rings = [];
|
|
132
|
+
regions.forEach((rg, region) => {
|
|
133
|
+
const outerFacts = ringArcs(rg.outer, state);
|
|
134
|
+
rings.push({ region, ring: "outer", ...outerFacts, corners: cornersByRing.get(ringKey(region, "outer")) ?? [] });
|
|
135
|
+
(rg.holes ?? []).forEach((h, hole) => {
|
|
136
|
+
const holeFacts = ringArcs(h, state);
|
|
137
|
+
rings.push({ region, ring: "hole", hole, ...holeFacts, corners: cornersByRing.get(ringKey(region, "hole", hole)) ?? [] });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: "shape2d", empty: false, area: round(area),
|
|
143
|
+
bbox: bbox ? { min: pt(bbox.min), max: pt(bbox.max) } : null,
|
|
144
|
+
rings, truncated: state.truncated,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -134,6 +134,21 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
134
134
|
if (note) out.note = note;
|
|
135
135
|
return out;
|
|
136
136
|
}
|
|
137
|
+
// `reg.unavailable` marks a metric whose "no reading" is a real, actionable
|
|
138
|
+
// finding rather than an inert skip — today only `wall`: a declared band with
|
|
139
|
+
// zero members means the wall the part is tracking may have drifted away from
|
|
140
|
+
// the window entirely, which is exactly the case a silent skip would hide (a
|
|
141
|
+
// 2 mm wall collapsed to 0.9 mm under "1.8..2.2" has no members, and `ok` must
|
|
142
|
+
// not stay true for that). Data-driven on the registry entry, not a second
|
|
143
|
+
// `metric === "wall"` literal, so a future metric opts in the same way minWall
|
|
144
|
+
// did before it had its own branch.
|
|
145
|
+
if (reg.unavailable) {
|
|
146
|
+
const out = { ...base, actual, status: "warn", pass: null, message: reg.unavailable,
|
|
147
|
+
hint: partHint ?? "no ray read as this wall — the declared band may not match the geometry (check minWall for the real thickness), or a sampled run missed it" };
|
|
148
|
+
const note = reg.note?.(factsObj);
|
|
149
|
+
if (note) out.note = note;
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
137
152
|
return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
|
|
138
153
|
}
|
|
139
154
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
@@ -185,9 +200,9 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
|
|
|
185
200
|
};
|
|
186
201
|
for (const [metric, expr] of Object.entries(merged)) {
|
|
187
202
|
const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
|
|
188
|
-
if (minWallSkipped && metric === "minWall" && c.actual == null) {
|
|
203
|
+
if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
|
|
189
204
|
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
190
|
-
hint:
|
|
205
|
+
hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
|
|
191
206
|
continue;
|
|
192
207
|
}
|
|
193
208
|
if (overhangSkipped && metric === "overhangArea" && c.actual == null) {
|
|
@@ -58,6 +58,20 @@ export const SUBPART_METRICS = {
|
|
|
58
58
|
// "<import name>"`; on every other sub-part `extract` returns null, which
|
|
59
59
|
// `check()` already reports as status "skip" rather than a fail — the
|
|
60
60
|
// no-reference path needs nothing here.
|
|
61
|
+
// The declared wall band's worst member (min-wall.js `band`): the member thickness
|
|
62
|
+
// farthest from the range the part declared, located. Range form only — `form` is
|
|
63
|
+
// read by the linter's verify-bad-expr rule only. A warning like minWall,
|
|
64
|
+
// because it is a sampled ray reading: a sample can miss the widest spot, never
|
|
65
|
+
// invent one. `unavailable` is the skip message when no ray fell in the window.
|
|
66
|
+
wall: { kind: "warn", form: "range", extract: (s) => s.wall?.value ?? null,
|
|
67
|
+
hint: "wall thickness drifts from the declared band at the reported location — a fillet radius, an offset or a boolean tool there is not tracking the wall",
|
|
68
|
+
locate: (s) => s.wall?.location ?? null,
|
|
69
|
+
unavailable: "no wall in band",
|
|
70
|
+
note: (s) => {
|
|
71
|
+
if (!s.wall) return null;
|
|
72
|
+
const sampled = s.minWallSampled && s.minWallSamples ? ` (sampled ${s.minWallSamples.sampled} of ${s.minWallSamples.total} triangles)` : "";
|
|
73
|
+
return `${s.wall.members} rays read as this wall${sampled}`;
|
|
74
|
+
} },
|
|
61
75
|
refXorVolume: { kind: "gate", extract: (s) => s.deviation?.xorVolume ?? null,
|
|
62
76
|
hint: "the rebuild's symmetric difference vs its reference import is too large — compare the ghost overlay, then adjust the governing dimensions toward the measured reference" },
|
|
63
77
|
refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
|
package/types/part.d.ts
CHANGED
|
@@ -457,6 +457,13 @@ export interface SubPartExpectations {
|
|
|
457
457
|
boundsMin?: Expectation;
|
|
458
458
|
boundsMax?: Expectation;
|
|
459
459
|
minWall?: Expectation;
|
|
460
|
+
/**
|
|
461
|
+
* A wall that must stay one thickness, as a range in mm (`"1.8..2.2"`; range
|
|
462
|
+
* form only). Rides the same inward rays as `minWall` — a ray reading inside
|
|
463
|
+
* `[0.75 × min, 1.5 × max]` counts as this wall — and warns when the member
|
|
464
|
+
* farthest from the band lies outside it. A warning, never a gate.
|
|
465
|
+
*/
|
|
466
|
+
wall?: Expectation;
|
|
460
467
|
/**
|
|
461
468
|
* Unsupported downward-facing surface in mm² (faces steeper than the profile's
|
|
462
469
|
* `overhang` angle, bed at the sub-part's own lowest Z). Measured only under
|
package/types/testing.d.ts
CHANGED
|
@@ -283,6 +283,15 @@ export interface SubPartFacts {
|
|
|
283
283
|
minWallAt: number[] | null;
|
|
284
284
|
minWallSampled: boolean;
|
|
285
285
|
minWallSamples: { sampled: number; total: number } | null;
|
|
286
|
+
/**
|
|
287
|
+
* The declared wall band's worst member: the sampled thickness farthest from the
|
|
288
|
+
* range the part's `verify.expect.<name>.wall` declared (or, when every member is
|
|
289
|
+
* inside it, farthest from its midpoint), where it was read, the band, and how many
|
|
290
|
+
* rays fell in the membership window `[0.75 × min, 1.5 × max]`. `null` when the
|
|
291
|
+
* sub-part declares no band or min wall was not measured; `value` null when no ray
|
|
292
|
+
* read as this wall.
|
|
293
|
+
*/
|
|
294
|
+
wall: { value: number | null; location: number[] | null; band: { min: number; max: number }; members: number } | null;
|
|
286
295
|
/**
|
|
287
296
|
* Unsupported downward-facing area in mm² (oracle/overhang.js), `null` when the
|
|
288
297
|
* part is not laid out for a bed (`verify.orientation: "print"` under a
|
|
@@ -304,6 +313,57 @@ export interface AggregateFacts {
|
|
|
304
313
|
triangleCount: number;
|
|
305
314
|
}
|
|
306
315
|
|
|
316
|
+
/** One arc of a probed `Shape2D` ring: centre and radius from the contour IR. */
|
|
317
|
+
export interface ProbeArc {
|
|
318
|
+
center: [number, number];
|
|
319
|
+
r: number;
|
|
320
|
+
from: [number, number];
|
|
321
|
+
to: [number, number];
|
|
322
|
+
/** Signed; positive counter-clockwise. */
|
|
323
|
+
sweepDeg: number;
|
|
324
|
+
/** Present when the segment was a cubic and the circle is a fit, not a construction. */
|
|
325
|
+
fit?: "cubic";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface ProbeRing {
|
|
329
|
+
/** 0-based index into the shape's `regions` — which region this ring belongs to. */
|
|
330
|
+
region: number;
|
|
331
|
+
/** Whether this is the region's outer boundary or one of its holes. */
|
|
332
|
+
ring: "outer" | "hole";
|
|
333
|
+
/** 0-based hole index within its region; present only when `ring === "hole"`. */
|
|
334
|
+
hole?: number;
|
|
335
|
+
/** Total segment count, before any cap. */
|
|
336
|
+
segments: number;
|
|
337
|
+
/** At most 64 across the WHOLE summary (all rings combined), not per ring;
|
|
338
|
+
* `truncated` on the summary says when the list was cut. */
|
|
339
|
+
arcs: ProbeArc[];
|
|
340
|
+
/** Straight segments, count only. */
|
|
341
|
+
lines: number;
|
|
342
|
+
corners: Array<{
|
|
343
|
+
/** Running index across every ring in the summary's own order (region by region,
|
|
344
|
+
* outer then holes) — the positional index `fillet({corners: {indices}})` and
|
|
345
|
+
* `shape.corners()` select on. */
|
|
346
|
+
position: number;
|
|
347
|
+
point: [number, number];
|
|
348
|
+
interiorAngleDeg: number;
|
|
349
|
+
convex: boolean;
|
|
350
|
+
}>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** What a `Shape2D` returned from a probe becomes in the report. */
|
|
354
|
+
export interface ShapeProbeFacts {
|
|
355
|
+
kind: "shape2d";
|
|
356
|
+
empty: boolean;
|
|
357
|
+
area: number;
|
|
358
|
+
bbox: { min: [number, number]; max: [number, number] } | null;
|
|
359
|
+
/** A flat list of every ring across every region — region by region, outer then
|
|
360
|
+
* holes — never nested `{outer, holes}` objects. */
|
|
361
|
+
rings: ProbeRing[];
|
|
362
|
+
/** At most 64 arcs and 64 corners total, across every ring; true when either
|
|
363
|
+
* budget was spent and something was cut. */
|
|
364
|
+
truncated: boolean;
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
export interface MeasureReport {
|
|
308
368
|
/** `part.meta.title`, falling back to the view name. */
|
|
309
369
|
part: string;
|
|
@@ -319,6 +379,12 @@ export interface MeasureReport {
|
|
|
319
379
|
gaps: Gap[];
|
|
320
380
|
/** The pairs with an unintended-looking gap under the threshold. */
|
|
321
381
|
nearMisses: Gap[];
|
|
382
|
+
/**
|
|
383
|
+
* Declared probes' values, present only when the part declares probes and this
|
|
384
|
+
* run evaluated them. A Solid in a probe's return value becomes a fact object, a
|
|
385
|
+
* Shape2D becomes a `ShapeProbeFacts`, plain JSON passes through, a throw is `{error}`.
|
|
386
|
+
*/
|
|
387
|
+
probes?: Record<string, unknown>;
|
|
322
388
|
/** Every sub-part watertight and nothing interpenetrating. Near misses never affect it. */
|
|
323
389
|
ok: boolean;
|
|
324
390
|
}
|