partforge 0.110.0 → 0.111.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +22 -3
- package/docs/AUTHORING-PARTS.md +58 -12
- package/docs/KERNEL-CONTRACT.md +2 -2
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +9 -1
- package/src/framework/lint/rules-verify.js +17 -1
- package/src/framework/measure/measure-mode.js +9 -1
- package/src/framework/oracle/dfm-profiles.js +45 -5
- package/src/framework/oracle/gates.js +10 -1
- package/src/framework/oracle/measure.js +27 -1
- package/src/framework/oracle/overhang.js +111 -0
- package/src/framework/oracle/verify.js +73 -8
- package/src/framework/selection/hover.js +8 -2
- package/src/framework/verify-metrics.js +8 -0
- package/src/oracle.js +1 -0
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +20 -0
- package/types/testing.d.ts +46 -4
package/bin/cli.js
CHANGED
|
@@ -222,11 +222,17 @@ const commands = {
|
|
|
222
222
|
};
|
|
223
223
|
if (flags.out) writeOut();
|
|
224
224
|
let vok = true;
|
|
225
|
-
|
|
225
|
+
// Always, not only when the part has a `verify` block: a part with none is
|
|
226
|
+
// the most vacuous case of all, and the "nothing verified" verdict below
|
|
227
|
+
// exists to say so rather than let a clean exit read as verified.
|
|
228
|
+
if (!flags["no-verify"]) {
|
|
226
229
|
const v = verify(kernel, part, { process: flags.process, view });
|
|
227
230
|
if (!flags.json) printVerify(v);
|
|
228
231
|
report.verify = v;
|
|
229
|
-
|
|
232
|
+
// Tri-state: `null` is a withheld verdict (nothing declared, or a check
|
|
233
|
+
// that could not run), not a failure — the notice prints; the exit code
|
|
234
|
+
// reports only what actually failed.
|
|
235
|
+
vok = v.ok !== false;
|
|
230
236
|
if (flags.out) writeOut();
|
|
231
237
|
}
|
|
232
238
|
if (flags.out) console.log(`\nwrote ${flags.out}`);
|
|
@@ -542,8 +548,21 @@ function printVerify(v) {
|
|
|
542
548
|
}
|
|
543
549
|
}
|
|
544
550
|
}
|
|
551
|
+
// Part-level notices live only in `warnings` (they are about the part, not a
|
|
552
|
+
// case), so they print here, after the cases.
|
|
553
|
+
for (const n of v.warnings.filter((c) => c.scope === "part")) {
|
|
554
|
+
console.log(` part`);
|
|
555
|
+
console.log(` ⚠ ${n.metric} (${n.message})`);
|
|
556
|
+
if (n.hint) console.log(` hint: ${n.hint}`);
|
|
557
|
+
}
|
|
545
558
|
const f = v.failures.length, w = v.warnings.length;
|
|
546
|
-
|
|
559
|
+
// One switch on the tri-state verdict, so the line and `ok` cannot disagree.
|
|
560
|
+
const verdict = v.ok === false ? `${f} gate failure(s)`
|
|
561
|
+
: v.ok === true ? "all gates passed"
|
|
562
|
+
: v.unevaluated?.length ? `verdict withheld (${v.unevaluated.length} check(s) not evaluated)`
|
|
563
|
+
: v.declared ? "nothing verified (every declared check skipped)"
|
|
564
|
+
: "nothing verified (no expectations declared)";
|
|
565
|
+
console.log(` result: ${verdict}${w ? `, ${w} warning(s)` : ""}`);
|
|
547
566
|
}
|
|
548
567
|
|
|
549
568
|
function printLint(r) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2860,6 +2860,7 @@ probe runs).
|
|
|
2860
2860
|
|
|
2861
2861
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
2862
2862
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
2863
|
+
`verify-unknown-orientation`,
|
|
2863
2864
|
`verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
|
|
2864
2865
|
`contacts` / `clearance` keys, which are not scalar view metrics; they are
|
|
2865
2866
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
@@ -2993,11 +2994,13 @@ carries:
|
|
|
2993
2994
|
- `hint` — one self-contained corrective sentence (always present),
|
|
2994
2995
|
- `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
|
|
2995
2996
|
applies (follow it with `ERROR-PATTERNS.md#<id>`),
|
|
2996
|
-
- `note` — an optional caveat about *how* the value was measured,
|
|
2997
|
-
whatever the verdict.
|
|
2998
|
-
from a sample rather than every triangle (see below)
|
|
2997
|
+
- `note` — an optional caveat about *how* the value was measured, or a companion
|
|
2998
|
+
reading, attached whatever the verdict. `minWall` sets one when the reading came
|
|
2999
|
+
from a sample rather than every triangle (see below); `overhangArea` sets one
|
|
3000
|
+
naming the steepest unsupported face's angle,
|
|
2999
3001
|
- `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
|
|
3000
|
-
sample point)
|
|
3002
|
+
sample point), `overhangArea` (the largest unsupported face's centroid) and
|
|
3003
|
+
`overlaps` (the center of the first offending intersection's
|
|
3001
3004
|
*bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
|
|
3002
3005
|
more than one place the bbox center can fall in the empty space between regions)
|
|
3003
3006
|
and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
|
|
@@ -3010,6 +3013,11 @@ exists, e.g. the OCCT backend or min-wall measurement turned off, matching
|
|
|
3010
3013
|
per triangle, which is unbounded work on a dense mesh, so past a sample budget
|
|
3011
3014
|
it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
|
|
3012
3015
|
and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
|
|
3016
|
+
A part that opted into the overhang check (see the `verify` block) also carries
|
|
3017
|
+
`overhangArea` (mm² of unsupported downward-facing surface, `null` when not
|
|
3018
|
+
checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
|
|
3019
|
+
face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
|
|
3020
|
+
`measuredOverhang` stamps the angle the pass ran against, or `null`.
|
|
3013
3021
|
**The budget depends on whether the reading is checked against anything**: a part
|
|
3014
3022
|
that declares a min-wall gate — a `verify.process` profile, or an `expect`
|
|
3015
3023
|
mentioning `minWall` — gets 50,000, because a gate's verdict rides on it; a part
|
|
@@ -3099,7 +3107,9 @@ A part can declare how it should be checked, co-located with its schema, so
|
|
|
3099
3107
|
```js
|
|
3100
3108
|
verify: {
|
|
3101
3109
|
process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
|
|
3102
|
-
// inline { bed:[x,y,z], minWall, clearance } object
|
|
3110
|
+
// inline { bed:[x,y,z], minWall, clearance, overhang } object
|
|
3111
|
+
orientation: "print", // optional; ONLY with this is overhang checked — it says the
|
|
3112
|
+
// part is laid out for its bed (Z up, bed at the lowest Z)
|
|
3103
3113
|
cases: ["defaults", "M3"], // optional; default = defaults + every preset
|
|
3104
3114
|
expect: { // design intent, by sub-part name (+ "_view")
|
|
3105
3115
|
spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
|
|
@@ -3110,10 +3120,30 @@ verify: {
|
|
|
3110
3120
|
}
|
|
3111
3121
|
```
|
|
3112
3122
|
|
|
3113
|
-
**What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3123
|
+
**What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`),
|
|
3124
|
+
a **min-wall** warning, and — only for a part that also declares
|
|
3125
|
+
`orientation: "print"` — an **overhang** warning: `overhangArea` is the mm² of
|
|
3126
|
+
downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
|
|
3127
|
+
on the FDM profiles; resin carries none, since it prints on supports), measured per
|
|
3128
|
+
sub-part with the bed at that sub-part's own lowest Z, and warned past 1 mm². The
|
|
3129
|
+
opt-in is deliberate: a profile says what a process can print, the orientation key
|
|
3130
|
+
says this part is laid out for it, and a part still being shaped, or one bound for a
|
|
3131
|
+
different process, should not be nagged about its underside. Writing your own
|
|
3132
|
+
`overhangArea` expectation is the other way in — it arms the measurement by itself,
|
|
3133
|
+
against the profile's angle or 45° when the profile names none, so a declared
|
|
3134
|
+
expectation is never answered "unavailable". Two bands next to the bed are never
|
|
3135
|
+
counted: the footprint itself, and faces whose centroid sits within 1 mm of the bed
|
|
3136
|
+
(the lower curl of a bottom-edge fillet or chamfer, which prints fine). A bridge (a
|
|
3137
|
+
flat underside spanning two supports) and the ceiling of a horizontal bore are
|
|
3138
|
+
reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
|
|
3139
|
+
is why this is a warning and never a gate. Two more limits, stated: it is judged in
|
|
3140
|
+
the DISPLAY pose, so a sub-part whose `place` differs for export (a lid that prints
|
|
3141
|
+
flat beside its base) is measured as displayed, and `exportable: false` sub-parts
|
|
3142
|
+
are skipped. Switch it off under an FDM profile with an inline
|
|
3143
|
+
`{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
|
|
3144
|
+
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
3145
|
+
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
3146
|
+
`overhangArea`, `boundsMin` / `boundsMax`
|
|
3117
3147
|
(the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
|
|
3118
3148
|
`bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
3119
3149
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
@@ -3150,9 +3180,25 @@ verify: { expect: {
|
|
|
3150
3180
|
|
|
3151
3181
|
**Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
|
|
3152
3182
|
`minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
|
|
3153
|
-
**warning** — it flags walls below the profile's minimum but never fails the build
|
|
3154
|
-
`holes`/`watertight` are Manifold-only, so those
|
|
3155
|
-
rather than fail.
|
|
3183
|
+
**warning** — it flags walls below the profile's minimum but never fails the build —
|
|
3184
|
+
and so is `overhangArea` (see above). `holes`/`watertight` are Manifold-only, so those
|
|
3185
|
+
assertions **skip** on OCCT parts rather than fail.
|
|
3186
|
+
|
|
3187
|
+
**A verify block that declares nothing verifies nothing.** `verify.ok` is tri-state:
|
|
3188
|
+
`true` when every declared check passed, `false` on any gate failure, and `null` when
|
|
3189
|
+
no verdict can be given — a quick lap that could not measure a gate, or a part with
|
|
3190
|
+
**no declared expectations** at all (no `verify` block, an empty `expect`, no profile).
|
|
3191
|
+
That last case used to read as `ok: true` with zero checks, which every reader took as
|
|
3192
|
+
"verified". It now comes back `ok: null` with `evaluated: 0` and a `no expectations
|
|
3193
|
+
declared` warning whose hint says what to pin; the CLI runs verify on every part, block
|
|
3194
|
+
or no block, prints *nothing verified* and exits 0 (a withheld verdict is not a
|
|
3195
|
+
failure). The same `null` covers a part that declared checks none of which could be
|
|
3196
|
+
answered — every one SKIPPED (a `ref*` metric on a sub-part with no `reference`,
|
|
3197
|
+
`holes` on the OCCT backend, a pair on a disabled sub-part) — with a `no expectation
|
|
3198
|
+
could be evaluated` warning instead; `declared` and `evaluated` on the report tell
|
|
3199
|
+
the two apart. A single answerable expectation — or a process profile, which brings
|
|
3200
|
+
the bed-fit gate — is enough for a verdict. Treat `null` as "not verified", never as
|
|
3201
|
+
a pass.
|
|
3156
3202
|
|
|
3157
3203
|
**Per-case expectations.** Checks run across defaults **and every preset**, so a
|
|
3158
3204
|
static `expect` breaks the moment a preset legitimately changes an asserted fact —
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -914,8 +914,8 @@ The recurring constraint: every op here is implementable on **both** a mesh-CSG
|
|
|
914
914
|
and a B-rep kernel (see `docs/geometry-backend-strategy.md` for why that dual-backend
|
|
915
915
|
property is worth protecting — OCCT booleans are ~75–1400× slower). Generation *safety*
|
|
916
916
|
comes not from a restricted DSL but from the verify loop (`measure`/`verify` gates:
|
|
917
|
-
`bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall`
|
|
918
|
-
report but never fail) — a generator gets machine-checkable
|
|
917
|
+
`bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall` and `overhangArea`
|
|
918
|
+
*warnings*, which report but never fail) — a generator gets machine-checkable
|
|
919
919
|
pass/fail feedback per part, which a syntax could never provide.
|
|
920
920
|
|
|
921
921
|
## Conformance checklist for a new backend or host
|
package/package.json
CHANGED
|
@@ -84,7 +84,15 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
|
|
|
84
84
|
let hoverProbe = null; // hand tool: what's under the pointer right now (no gesture)
|
|
85
85
|
const modeListeners = new Set();
|
|
86
86
|
const toolListeners = new Set();
|
|
87
|
-
|
|
87
|
+
// Every listener hears every transition: the host application registers its
|
|
88
|
+
// relay AFTER mount.js's own listeners, so a throw in one of ours must not
|
|
89
|
+
// end the loop before the host is told — and must never escape setEnabled,
|
|
90
|
+
// which send() calls after the ink is already discarded.
|
|
91
|
+
const notifyMode = () => {
|
|
92
|
+
for (const cb of [...modeListeners]) {
|
|
93
|
+
try { cb(); } catch (err) { console.error("mode listener failed", err); }
|
|
94
|
+
}
|
|
95
|
+
};
|
|
88
96
|
const notifyTool = () => { for (const cb of [...toolListeners]) cb(); };
|
|
89
97
|
|
|
90
98
|
const rectOf = () => canvas.element.getBoundingClientRect();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Catching them statically removes both the wasted boot and the stdout caveat.
|
|
5
5
|
import { err } from "./finding.js";
|
|
6
6
|
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
7
|
-
import { PROFILES } from "../oracle/dfm-profiles.js";
|
|
7
|
+
import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
|
|
8
8
|
import { parseAssertion } from "../oracle/assert-dsl.js";
|
|
9
9
|
import { suggest } from "../geometry/op-options.js";
|
|
10
10
|
|
|
@@ -285,4 +285,20 @@ export const VERIFY_RULES = [
|
|
|
285
285
|
return checkProcessSpec(process, "verify.process", valid, new Set());
|
|
286
286
|
},
|
|
287
287
|
},
|
|
288
|
+
{
|
|
289
|
+
// dfm-profiles.js overhangAngleFor throws on an orientation outside
|
|
290
|
+
// ORIENTATIONS — the overhang opt-in's one legal value — with the same
|
|
291
|
+
// mid-run loudness as an unknown profile name, so it gets the same rule.
|
|
292
|
+
id: "verify-unknown-orientation",
|
|
293
|
+
run: ({ part }) => {
|
|
294
|
+
const orientation = part?.verify?.orientation;
|
|
295
|
+
if (orientation == null) return [];
|
|
296
|
+
if (typeof orientation === "string" && ORIENTATIONS.includes(orientation)) return [];
|
|
297
|
+
const hint = safeSuggest(orientation, ORIENTATIONS);
|
|
298
|
+
return [err("verify-unknown-orientation",
|
|
299
|
+
`\`verify.orientation\` is ${describe(orientation)}, which is not a known orientation`,
|
|
300
|
+
`Use ${ORIENTATIONS.map((o) => `"${o}"`).join(", ")}${hint ? ` — did you mean "${hint}"?` : ""} (declares the part is laid out for its print bed, arming the profile's overhang check), or omit the key.`,
|
|
301
|
+
"verify.orientation")];
|
|
302
|
+
},
|
|
303
|
+
},
|
|
288
304
|
];
|
|
@@ -54,7 +54,15 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
54
54
|
const pinListeners = new Set();
|
|
55
55
|
const notifyPins = () => { for (const cb of [...pinListeners]) cb(); };
|
|
56
56
|
const modeListeners = new Set();
|
|
57
|
-
|
|
57
|
+
// Every listener hears every transition: the host application registers its
|
|
58
|
+
// relay AFTER mount.js's own listeners, so a throw in one of ours must not
|
|
59
|
+
// end the loop before the host is told — and must never escape setEnabled,
|
|
60
|
+
// which send() calls after the ink is already discarded.
|
|
61
|
+
const notifyMode = () => {
|
|
62
|
+
for (const cb of [...modeListeners]) {
|
|
63
|
+
try { cb(); } catch (err) { console.error("mode listener failed", err); }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
58
66
|
|
|
59
67
|
let enabled = false;
|
|
60
68
|
let units = "mm"; // display only; values stay mm internally
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// Reusable design-for-manufacturing process profiles
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Reusable design-for-manufacturing process profiles — a manufacturing technique
|
|
2
|
+
// as DATA: which checks apply and at what thresholds. `bed` is the build volume
|
|
3
|
+
// [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `overhang` is the
|
|
4
|
+
// steepest unsupported face the process prints cleanly, in degrees from vertical
|
|
5
|
+
// (a warn, armed only by the part — see overhangAngleFor; resin prints on
|
|
6
|
+
// supports, so it carries none); `clearance` mm is carried for a future gap check
|
|
7
|
+
// (not enforced yet).
|
|
4
8
|
export const PROFILES = {
|
|
5
|
-
"fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2 },
|
|
6
|
-
"fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3 },
|
|
9
|
+
"fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45 },
|
|
10
|
+
"fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45 },
|
|
7
11
|
"resin": { bed: [120, 68, 160], minWall: 0.6, clearance: 0.1 },
|
|
8
12
|
};
|
|
9
13
|
|
|
@@ -21,3 +25,39 @@ export function resolveProfile(spec) {
|
|
|
21
25
|
}
|
|
22
26
|
throw new Error(`invalid process profile: ${JSON.stringify(spec)}`);
|
|
23
27
|
}
|
|
28
|
+
|
|
29
|
+
// The one legal `verify.orientation` value today. Z is up everywhere in partforge
|
|
30
|
+
// and the bed is a sub-part's own lowest Z, so "print" is a declaration, not a
|
|
31
|
+
// choice of axis.
|
|
32
|
+
export const ORIENTATIONS = ["print"];
|
|
33
|
+
|
|
34
|
+
// The angle an author's own `overhangArea` expectation is measured against when
|
|
35
|
+
// the profile names none (a resin part, or no profile at all): FDM's usual 45.
|
|
36
|
+
export const DEFAULT_OVERHANG_ANGLE = 45;
|
|
37
|
+
|
|
38
|
+
// Which overhang angle a part is checked against, or null when it is not checked.
|
|
39
|
+
// Two ways in, both explicit — a profile alone never arms it, because the cloud
|
|
40
|
+
// agent writes `process: "fdm-pla"` by habit and a part still being shaped should
|
|
41
|
+
// not be nagged about its underside:
|
|
42
|
+
// - `verify.orientation: "print"` (the part is laid out for its bed) under a
|
|
43
|
+
// profile carrying `overhang`; a profile without one (resin) checks nothing;
|
|
44
|
+
// - an `overhangArea` expectation the author wrote themselves, in any case's
|
|
45
|
+
// `expect` (`expanded`, from gates.js's expandExpectations) — measured against
|
|
46
|
+
// the profile's angle, or DEFAULT_OVERHANG_ANGLE when the profile has none,
|
|
47
|
+
// so a declared expectation is never answered "unavailable".
|
|
48
|
+
// Throws on an orientation value outside ORIENTATIONS — verify's callers want that
|
|
49
|
+
// loud, like an unknown profile name, and lint's `verify-unknown-orientation`
|
|
50
|
+
// catches it before a kernel boots; gates.js wraps this total for measure().
|
|
51
|
+
export function overhangAngleFor(part, process, { expanded = [] } = {}) {
|
|
52
|
+
const orientation = part?.verify?.orientation;
|
|
53
|
+
if (orientation != null && !ORIENTATIONS.includes(orientation)) {
|
|
54
|
+
throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
|
|
55
|
+
}
|
|
56
|
+
const spec = process ?? part?.verify?.process;
|
|
57
|
+
const fromProfile = spec ? resolveProfile(spec).overhang : undefined;
|
|
58
|
+
const angle = typeof fromProfile === "number" && Number.isFinite(fromProfile) ? fromProfile : null;
|
|
59
|
+
if (orientation === "print" && angle != null) return angle;
|
|
60
|
+
const asserted = expanded.some(({ expect }) =>
|
|
61
|
+
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "overhangArea" in o));
|
|
62
|
+
return asserted ? (angle ?? DEFAULT_OVERHANG_ANGLE) : null;
|
|
63
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// `expect` function throws, is reported as GATED — the conservative direction, since
|
|
11
11
|
// that is the full-resolution behaviour every part had before budgets existed. The
|
|
12
12
|
// real error surfaces from verify, which is where a reader can act on it.
|
|
13
|
-
import { resolveProfile } from "./dfm-profiles.js";
|
|
13
|
+
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
14
14
|
import { expandCases } from "./cases.js";
|
|
15
15
|
import { resolveParams } from "../part-model.js";
|
|
16
16
|
|
|
@@ -38,6 +38,15 @@ export function expandExpectations(part) {
|
|
|
38
38
|
return expanded;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The overhang angle a part is checked against, or null when it is not checked
|
|
42
|
+
// (dfm-profiles.js overhangAngleFor holds the rule). Total, like the rest of this
|
|
43
|
+
// file: a malformed orientation or profile answers null here and raises from
|
|
44
|
+
// verify, where a reader can act on it. measure() asks so the fact is computed
|
|
45
|
+
// for exactly the parts that will be judged on it.
|
|
46
|
+
export function partOverhangAngle(part, { process, expanded } = {}) {
|
|
47
|
+
try { return overhangAngleFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
export function partGatesMinWall(part, { process, expanded } = {}) {
|
|
42
51
|
try {
|
|
43
52
|
const spec = process ?? part?.verify?.process;
|
|
@@ -5,7 +5,8 @@ import { resolveParams } from "../part-model.js";
|
|
|
5
5
|
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
|
-
import {
|
|
8
|
+
import { overhang } from "./overhang.js";
|
|
9
|
+
import { partGatesMinWall, partOverhangAngle } from "./gates.js";
|
|
9
10
|
|
|
10
11
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
11
12
|
const unionBounds = (list) => list.reduce(
|
|
@@ -123,6 +124,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
123
124
|
// inward ray per sampled triangle plus the BVH those rays need — and on an
|
|
124
125
|
// ungated part it buys a fact nobody checks, at full price, on every agent edit.
|
|
125
126
|
const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
|
|
127
|
+
// Overhang is measured only for a part that opted in (dfm-profiles.js
|
|
128
|
+
// overhangAngleFor) — everything else reads null. verify hands the angle it
|
|
129
|
+
// resolved in (a `process` override changes it); `null` there is an explicit
|
|
130
|
+
// "not checked", not a fallback. The angle used is stamped on the result as
|
|
131
|
+
// `measuredOverhang`, and verify refuses a seed whose stamp disagrees with the
|
|
132
|
+
// angle it needs — the min-wall superset rule's counterpart, since a reading
|
|
133
|
+
// taken against the wrong threshold is worse than none.
|
|
134
|
+
const overhangAngle = opts.overhang !== undefined ? opts.overhang : partOverhangAngle(part);
|
|
126
135
|
const subBounds = [];
|
|
127
136
|
const subparts = built.map(({ name, solid, mesh }) => {
|
|
128
137
|
const b = bounds(mesh.positions);
|
|
@@ -130,6 +139,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
130
139
|
// Resolved lazily and only when asked for: without min-wall, a single-sub-part
|
|
131
140
|
// view (no meshGaps) must still build no index at all.
|
|
132
141
|
const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples }) : null;
|
|
142
|
+
// One pass over the triangles, no index — cheap enough for every lap. The bed
|
|
143
|
+
// is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
|
|
144
|
+
// that is never printed (`exportable: false` — a reference ghost, a probe
|
|
145
|
+
// slab, a placeholder) is not judged. Judged in the DISPLAY pose, which is
|
|
146
|
+
// what measure builds; a part whose export pose differs (a lid that prints
|
|
147
|
+
// flat beside its base) is a known gap, stated in the authoring docs.
|
|
148
|
+
const printed = part.parts[name]?.exportable !== false;
|
|
149
|
+
const oh = overhangAngle != null && printed ? overhang(mesh, { maxAngle: overhangAngle, bedZ: b.min[2] }) : null;
|
|
133
150
|
const vol = solid.volume();
|
|
134
151
|
// Deviation-from-reference: only for a sub-part that declares `reference:
|
|
135
152
|
// "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
|
|
@@ -172,6 +189,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
172
189
|
// `measuredMinWall` false is "never looked".
|
|
173
190
|
minWallSampled: mw?.sampled ?? false,
|
|
174
191
|
minWallSamples: mw ? { sampled: mw.sampledTriangles, total: mw.totalTriangles } : null,
|
|
192
|
+
// Unsupported downward-facing area in mm² (overhang.js), null when the part
|
|
193
|
+
// is not laid out for a bed: bridges and bore ceilings count, by design.
|
|
194
|
+
overhangArea: oh ? oh.area : null,
|
|
195
|
+
overhangAngle: oh?.worstAngle ?? null,
|
|
196
|
+
overhangAt: oh?.at ?? null,
|
|
175
197
|
};
|
|
176
198
|
});
|
|
177
199
|
|
|
@@ -231,6 +253,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
231
253
|
// nothing measured it, which reads identically to "no reading available";
|
|
232
254
|
// verify's seeding rule turns on exactly this distinction (see verify.js).
|
|
233
255
|
measuredMinWall: !!opts.minWall,
|
|
256
|
+
// The overhang angle every sub-part's `overhangArea` was measured against,
|
|
257
|
+
// or null when the pass did not run — read by verify's seed gate, never a
|
|
258
|
+
// caller's claim.
|
|
259
|
+
measuredOverhang: overhangAngle ?? null,
|
|
234
260
|
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
235
261
|
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
236
262
|
measuredGaps,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/framework/oracle/overhang.js
|
|
2
|
+
// Unsupported downward-facing surface, for a part oriented for a print bed.
|
|
3
|
+
//
|
|
4
|
+
// One pass over the triangles, pure, no BVH and no rays — cheap enough to run on
|
|
5
|
+
// the quick lap alongside bbox and volume, and deliberately NOT sampled the way
|
|
6
|
+
// min-wall.js is: a sampled minimum degrades to an honest upper bound, but a
|
|
7
|
+
// sampled AREA is an estimate that would flicker either side of the warning
|
|
8
|
+
// floor between identical laps. Measured at ~13 ms on a 400k-triangle soup before
|
|
9
|
+
// the prefilter below, under `meshArea`'s own cost.
|
|
10
|
+
//
|
|
11
|
+
// A face is an overhang when its outward normal points down more steeply than
|
|
12
|
+
// the process allows: the angle is measured FROM VERTICAL, so a wall reads 0°,
|
|
13
|
+
// a 45° chamfer 45°, a ceiling 90°, and a face counts when its angle exceeds
|
|
14
|
+
// `maxAngle` (FDM's usual 45) by more than ANGLE_EPS — a chamfer authored AT the
|
|
15
|
+
// angle must not warn, and float32 positions perturb a computed normal by ~1e-6,
|
|
16
|
+
// which is a thousandth of a degree, not the nanodegree a sine-space epsilon
|
|
17
|
+
// would allow (measured: a 45° underside fired on 32 of 40 Z-rotations before
|
|
18
|
+
// the tolerance moved into angle space).
|
|
19
|
+
//
|
|
20
|
+
// Two bands of faces are excluded, both against the bed — the MESH's own lowest
|
|
21
|
+
// Z rather than z = 0, so an assembly's sub-parts are each judged as printed
|
|
22
|
+
// separately on their own base:
|
|
23
|
+
// - the footprint: faces with every vertex within `bedEps` of the bed. The
|
|
24
|
+
// slack scales with the part (0.1% of its height, floored at 10 µm) because a
|
|
25
|
+
// single boolean-noise vertex a few microns low, or a 0.001° tilt, would
|
|
26
|
+
// otherwise lift the entire bottom face off the bed and report it as a 90°
|
|
27
|
+
// ceiling (measured on a 60×60×5 plate: 3600 mm² of false overhang either way).
|
|
28
|
+
// - the near-bed band: faces whose centroid sits within `bedBand` (1 mm) of the
|
|
29
|
+
// bed. That is the lower curl of a bottom-edge fillet or chamfer — a 20×20×5
|
|
30
|
+
// plate with r=1 fillets carried 58 mm² of "overhang" there, and every FDM
|
|
31
|
+
// printer lays those first layers down fine. A real ceiling under 1 mm of
|
|
32
|
+
// clearance is missed by the same rule; that gap is not printable anyway.
|
|
33
|
+
//
|
|
34
|
+
// Known limit, stated rather than hidden: a bridge (a flat underside spanning two
|
|
35
|
+
// supports) is geometrically a ceiling, and the mesh alone cannot tell the two
|
|
36
|
+
// apart, so it is reported as an overhang. That is why this fact backs a WARNING
|
|
37
|
+
// and never a gate. Likewise the ceiling of a horizontal bore counts, which is
|
|
38
|
+
// usually what a print-minded author wants told.
|
|
39
|
+
//
|
|
40
|
+
// Works on both mesh forms the oracle sees (Manifold's 9-floats-per-triangle soup
|
|
41
|
+
// and OCCT's indexed vertices), same as min-wall.js.
|
|
42
|
+
|
|
43
|
+
const DEG = 180 / Math.PI;
|
|
44
|
+
// Angle-space tolerance on the threshold (degrees): well above float32 normal
|
|
45
|
+
// noise (~1e-3°), far below any angle an author would distinguish.
|
|
46
|
+
const ANGLE_EPS = 0.01;
|
|
47
|
+
// A face too small to have a trustworthy normal contributes its (negligible) area
|
|
48
|
+
// but never the reported worst angle — a float32 sliver's normal is round-off, and
|
|
49
|
+
// one such sliver used to report a 90° ceiling on a part with none.
|
|
50
|
+
const MIN_ANGLE_AREA = 1e-6; // mm²
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, triangles: number }} mesh
|
|
54
|
+
* @param {{ maxAngle?: number, bedZ?: number, bedEps?: number, bedBand?: number }} [opts]
|
|
55
|
+
* `maxAngle` in degrees from vertical (default 45); `bedZ` the bed height if the
|
|
56
|
+
* caller already knows the mesh's lowest Z (else scanned); `bedEps` the footprint
|
|
57
|
+
* slack (default 0.1% of the mesh height, floored at 0.01 mm); `bedBand` the
|
|
58
|
+
* near-bed band (default 1 mm)
|
|
59
|
+
* @returns {{ area: number, worstAngle: number|null, at: number[]|null }|null}
|
|
60
|
+
* `area` in mm² of every offending face, `worstAngle` the steepest one found,
|
|
61
|
+
* `at` the centroid of the largest offending triangle (a place to point at);
|
|
62
|
+
* null for an empty mesh.
|
|
63
|
+
*/
|
|
64
|
+
export function overhang(mesh, { maxAngle = 45, bedZ, bedEps, bedBand = 1 } = {}) {
|
|
65
|
+
const { positions, indices } = mesh;
|
|
66
|
+
const n = indices ? indices.length : positions.length / 3;
|
|
67
|
+
if (n < 3) return null;
|
|
68
|
+
|
|
69
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
70
|
+
if (bedZ === undefined || bedEps === undefined) {
|
|
71
|
+
for (let i = 2; i < positions.length; i += 3) {
|
|
72
|
+
const z = positions[i];
|
|
73
|
+
if (z < minZ) minZ = z;
|
|
74
|
+
if (z > maxZ) maxZ = z;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const bed = bedZ ?? minZ;
|
|
78
|
+
const eps = bedEps ?? Math.max(0.01, 1e-3 * (maxZ - minZ));
|
|
79
|
+
const bandTop = bed + bedBand;
|
|
80
|
+
|
|
81
|
+
// Prefilter in sine space, generously: only faces that might exceed the angle
|
|
82
|
+
// pay for the asin. The exact comparison is in degrees.
|
|
83
|
+
const nearLimit = Math.sin(Math.max(0, maxAngle - 1) / DEG);
|
|
84
|
+
let area = 0, worst = -1, largest = 0, at = null;
|
|
85
|
+
for (let i = 0; i < n; i += 3) {
|
|
86
|
+
const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
|
|
87
|
+
const az = positions[a + 2], bz = positions[b + 2], cz = positions[c + 2];
|
|
88
|
+
if (az - bed <= eps && bz - bed <= eps && cz - bed <= eps) continue; // the footprint
|
|
89
|
+
if ((az + bz + cz) / 3 <= bandTop) continue; // the near-bed band
|
|
90
|
+
const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = bz - az;
|
|
91
|
+
const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = cz - az;
|
|
92
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
93
|
+
if (nz >= 0) continue; // not facing down
|
|
94
|
+
const len = Math.hypot(nx, ny, nz);
|
|
95
|
+
if (len < 1e-9) continue; // degenerate: no normal
|
|
96
|
+
const down = -nz / len;
|
|
97
|
+
if (down <= nearLimit) continue;
|
|
98
|
+
const angle = Math.asin(Math.min(1, down)) * DEG;
|
|
99
|
+
if (angle <= maxAngle + ANGLE_EPS) continue;
|
|
100
|
+
const triArea = len / 2;
|
|
101
|
+
area += triArea;
|
|
102
|
+
if (triArea >= MIN_ANGLE_AREA && angle > worst) worst = angle;
|
|
103
|
+
if (triArea > largest) {
|
|
104
|
+
largest = triArea;
|
|
105
|
+
at = [(positions[a] + positions[b] + positions[c]) / 3,
|
|
106
|
+
(positions[a + 1] + positions[b + 1] + positions[c + 1]) / 3,
|
|
107
|
+
(az + bz + cz) / 3];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { area, worstAngle: worst < 0 ? null : worst, at };
|
|
111
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
3
|
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
4
|
-
import { resolveProfile } from "./dfm-profiles.js";
|
|
4
|
+
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
5
5
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
|
|
7
7
|
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
@@ -153,7 +153,10 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// Pure policy: profile rules + per-part expect → checks for one case's facts.
|
|
156
|
-
|
|
156
|
+
// `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
|
|
157
|
+
// or null/undefined: only then does the profile's overhang rule apply, as a
|
|
158
|
+
// 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
|
|
159
|
+
export function evaluateCase(facts, { profile, expect, subPartNames, overhang = null }) {
|
|
157
160
|
const checks = [];
|
|
158
161
|
// contacts/clearance are per-pair, not scalar view metrics — peel them off
|
|
159
162
|
// before the registry loop and hand them to pairGapChecks.
|
|
@@ -170,9 +173,14 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
170
173
|
// rays were cast and all missed, wrong when they were never cast. The stamp is
|
|
171
174
|
// what tells those apart, and it is measure()'s own, never a caller's claim.
|
|
172
175
|
const minWallSkipped = facts.measuredMinWall === false;
|
|
176
|
+
// Same shape for overhang: the rule is armed but these facts were measured
|
|
177
|
+
// without the pass (a quick lap reusing a seed that had no angle), so the check
|
|
178
|
+
// is withheld rather than read as "unavailable" — which would count as answered.
|
|
179
|
+
const overhangSkipped = overhang != null && (facts.measuredOverhang ?? null) === null;
|
|
173
180
|
for (const s of facts.subparts) {
|
|
174
181
|
const merged = {
|
|
175
182
|
...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
|
|
183
|
+
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
176
184
|
...(expect?.[s.name] ?? {}),
|
|
177
185
|
};
|
|
178
186
|
for (const [metric, expr] of Object.entries(merged)) {
|
|
@@ -182,6 +190,11 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
182
190
|
hint: "re-run this check without `quick` to measure min wall" });
|
|
183
191
|
continue;
|
|
184
192
|
}
|
|
193
|
+
if (overhangSkipped && metric === "overhangArea" && c.actual == null) {
|
|
194
|
+
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
195
|
+
hint: "re-run this check without `quick` to measure overhang" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
185
198
|
checks.push(c);
|
|
186
199
|
}
|
|
187
200
|
}
|
|
@@ -208,6 +221,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
208
221
|
// budget by it) and must not derive it separately — see there.
|
|
209
222
|
const expanded = expandExpectations(part);
|
|
210
223
|
const needMinWall = partGatesMinWall(part, { process, expanded });
|
|
224
|
+
// Throws on a bad `verify.orientation`, the same loudness as a bad profile name.
|
|
225
|
+
const overhangAngle = overhangAngleFor(part, process, { expanded });
|
|
211
226
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
212
227
|
// byteAwareReplacer on the RELEVANT_ALL branch too: an unattributable derive()
|
|
213
228
|
// still might read a byte-valued image param, and this memo key gates whether
|
|
@@ -263,7 +278,13 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
263
278
|
// a min-wall-less seed is reused, and the min-wall gate it cannot answer becomes
|
|
264
279
|
// `unevaluated` instead of being re-measured. The rule exists to stop a coarse
|
|
265
280
|
// reading standing in for a gate's verdict, and a withheld verdict does that too.
|
|
266
|
-
|
|
281
|
+
// The overhang half of the same rule: a seed is admitted only when it was
|
|
282
|
+
// measured against the angle THIS run needs (both null when neither checks) —
|
|
283
|
+
// `measuredOverhang` is stamped by measure() itself. A `process` override that
|
|
284
|
+
// changes the angle therefore re-measures rather than reusing the inspect
|
|
285
|
+
// job's seed, which was taken against the part's own profile.
|
|
286
|
+
const overhangMatches = (seed?.result?.measuredOverhang ?? null) === (overhangAngle ?? null);
|
|
287
|
+
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && seed.result.view === view) {
|
|
267
288
|
memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
|
|
268
289
|
}
|
|
269
290
|
|
|
@@ -274,7 +295,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
274
295
|
// `probes: false` — no gate reads probe values, so re-running their booleans
|
|
275
296
|
// for every case buys nothing. (A seed measured WITH probes is a superset in
|
|
276
297
|
// the same way a min-wall seed is: the extra key is simply never read here.)
|
|
277
|
-
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false }));
|
|
298
|
+
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false, overhang: overhangAngle }));
|
|
278
299
|
return memo.get(key);
|
|
279
300
|
};
|
|
280
301
|
|
|
@@ -291,19 +312,63 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
291
312
|
}];
|
|
292
313
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
293
314
|
const facts = measureCase(params);
|
|
294
|
-
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames }) : notMeasured(name) };
|
|
315
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, overhang: overhangAngle }) : notMeasured(name) };
|
|
295
316
|
});
|
|
317
|
+
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
318
|
+
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
319
|
+
// (the CLI's exit code, the cloud agent's "verify passes" stop rule) took that
|
|
320
|
+
// as verified. Nothing was. Two counts, and the difference between them is a
|
|
321
|
+
// second thing this used to hide:
|
|
322
|
+
// `declared` — checks the part or its profile asked for, as produced: the
|
|
323
|
+
// undeclared near-miss warnings are facts the oracle volunteers
|
|
324
|
+
// and the quick-lap "not measured" marker stands for a case,
|
|
325
|
+
// so neither counts;
|
|
326
|
+
// `evaluated` — the declared checks that were actually answered. A check
|
|
327
|
+
// that SKIPPED (a `ref*` metric on a sub-part with no
|
|
328
|
+
// reference, `holes` on the OCCT backend, a pair on a disabled
|
|
329
|
+
// sub-part) was declared, but it verified nothing.
|
|
330
|
+
// Zero evaluated withholds the verdict — the same `null` a quick lap uses for
|
|
331
|
+
// "could not check", because that is what it is. Whether anything was DECLARED
|
|
332
|
+
// is decided from the declaration itself (`profile`, the expanded `expect`
|
|
333
|
+
// maps, an armed overhang angle), never from the produced checks: on a quick
|
|
334
|
+
// lap whose seed matches no case there are no checks at all, and counting
|
|
335
|
+
// those would print "no expectations declared" at a part that declares plenty.
|
|
336
|
+
// The notice rides `warnings`, the channel every host already shows, and is
|
|
337
|
+
// deliberately NOT pushed into any case's check list — it is about the part,
|
|
338
|
+
// not about `defaults`. A quick lap that withheld gates explains itself through
|
|
339
|
+
// `unevaluated` and gets no notice.
|
|
340
|
+
const isDeclared = (c) => c.scope !== "case" && c.metric !== "nearMiss";
|
|
296
341
|
const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
|
|
342
|
+
let declared = 0, evaluated = 0;
|
|
343
|
+
for (const c of all) {
|
|
344
|
+
if (!isDeclared(c)) continue;
|
|
345
|
+
declared++;
|
|
346
|
+
if (c.status !== "skip" && !c.unevaluated) evaluated++;
|
|
347
|
+
}
|
|
348
|
+
const declaresAnything = profile != null || overhangAngle != null
|
|
349
|
+
|| expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
|
|
297
350
|
const failures = all.filter((c) => c.status === "fail");
|
|
298
351
|
const unevaluated = all.filter((c) => c.unevaluated);
|
|
352
|
+
const warnings = all.filter((c) => c.status === "warn");
|
|
353
|
+
if (evaluated === 0 && unevaluated.length === 0) {
|
|
354
|
+
warnings.push({ case: null, scope: "part", subpart: null, metric: "expectations", kind: "warn", expr: "evaluated",
|
|
355
|
+
actual: 0, status: "warn", pass: null,
|
|
356
|
+
message: declaresAnything ? "no expectation could be evaluated" : "no expectations declared",
|
|
357
|
+
hint: declaresAnything
|
|
358
|
+
? "every declared check skipped — see the skip reasons above (a metric this backend cannot read, a reference the sub-part does not declare, a disabled sub-part) and declare something this run can answer"
|
|
359
|
+
: "nothing was verified — pin the dimensions and features the part is meant to have in verify.expect (and a process profile for bed fit and min wall), so every edit re-checks them" });
|
|
360
|
+
}
|
|
299
361
|
return {
|
|
300
362
|
// Tri-state, and the order matters: a real failure is still a failure even on a
|
|
301
|
-
// lap that skipped other gates, so `false` outranks the withheld `null
|
|
302
|
-
|
|
363
|
+
// lap that skipped other gates, so `false` outranks the withheld `null`; and a
|
|
364
|
+
// part on which nothing was evaluated has no verdict to give.
|
|
365
|
+
ok: failures.length ? false : unevaluated.length || evaluated === 0 ? null : true,
|
|
303
366
|
view,
|
|
304
367
|
cases: caseResults,
|
|
305
368
|
failures,
|
|
306
|
-
warnings
|
|
369
|
+
warnings,
|
|
307
370
|
unevaluated,
|
|
371
|
+
declared,
|
|
372
|
+
evaluated,
|
|
308
373
|
};
|
|
309
374
|
}
|
|
@@ -11,8 +11,14 @@ export function attachHoverLabels(
|
|
|
11
11
|
viewer,
|
|
12
12
|
{ part, schedule = (cb) => requestAnimationFrame(cb), tooltip } = {},
|
|
13
13
|
) {
|
|
14
|
-
// Hover is a mouse idiom — skip entirely on touch-only devices.
|
|
15
|
-
|
|
14
|
+
// Hover is a mouse idiom — skip entirely on touch-only devices. The stub
|
|
15
|
+
// still answers the WHOLE interface: mount.js calls setSuppressed from the
|
|
16
|
+
// measure and annotate mode-change listeners without asking which device it
|
|
17
|
+
// is on, and a missing method there threw inside notifyMode on every phone —
|
|
18
|
+
// aborting the listener loop before the host application's own relay ran.
|
|
19
|
+
if (globalThis.matchMedia && !matchMedia("(hover: hover)").matches) {
|
|
20
|
+
return { setSuppressed: () => {}, detach: () => {} };
|
|
21
|
+
}
|
|
16
22
|
|
|
17
23
|
const ownsTooltip = !tooltip;
|
|
18
24
|
const tooltipPresenter = tooltip ?? createTooltipPresenter();
|
|
@@ -46,6 +46,14 @@ export const SUBPART_METRICS = {
|
|
|
46
46
|
? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
|
|
47
47
|
: `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
|
|
48
48
|
} },
|
|
49
|
+
// Unsupported downward-facing area (oracle/overhang.js), in mm². Measured only
|
|
50
|
+
// for a part that declares `verify.orientation: "print"` under a profile with
|
|
51
|
+
// an `overhang` angle; elsewhere `extract` returns null and check() skips it.
|
|
52
|
+
// A warning, never a gate: a bridge reads as a ceiling and cannot be told apart.
|
|
53
|
+
overhangArea: { kind: "warn", extract: (s) => s.overhangArea,
|
|
54
|
+
hint: "unsupported downward-facing surface at the reported location — reorient the part so the face is vertical or on the bed, chamfer it to the process's overhang angle, split it into a bridged or supported feature, or accept supports",
|
|
55
|
+
locate: (s) => s.overhangAt,
|
|
56
|
+
note: (s) => (s.overhangAngle != null ? `steepest unsupported face ${s.overhangAngle.toFixed(1)}° from vertical` : null) },
|
|
49
57
|
// `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
|
|
50
58
|
// "<import name>"`; on every other sub-part `extract` returns null, which
|
|
51
59
|
// `check()` already reports as status "skip" rather than a fail — the
|
package/src/oracle.js
CHANGED
|
@@ -21,6 +21,7 @@ export { measure } from "./framework/oracle/measure.js";
|
|
|
21
21
|
export { verify } from "./framework/oracle/verify.js";
|
|
22
22
|
export { buildBVH, meshTriangles } from "./framework/oracle/bvh.js";
|
|
23
23
|
export { minWall } from "./framework/oracle/min-wall.js";
|
|
24
|
+
export { overhang } from "./framework/oracle/overhang.js";
|
|
24
25
|
// Mesh file parsers — the import pipeline's own readers, browser-safe pure
|
|
25
26
|
// functions; the oracle package's corpus tests read real files through them.
|
|
26
27
|
export { parseStl } from "./framework/geometry/stl-parse.js";
|
package/types/oracle.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
type MeasureReport, type SubPartFacts, type AggregateFacts, type BuiltSubPart,
|
|
13
13
|
type VerifyReport, type VerifyCaseResult, type VerifyCheck, type CheckStatus,
|
|
14
14
|
// mesh facts, gaps, BVH, min wall
|
|
15
|
-
meshVolume, bboxSize, bounds, meshArea, assemblyGaps, meshGaps, buildBVH, meshTriangles, minWall,
|
|
15
|
+
meshVolume, bboxSize, bounds, meshArea, assemblyGaps, meshGaps, buildBVH, meshTriangles, minWall, overhang,
|
|
16
16
|
type Gap, type BVH,
|
|
17
17
|
// mesh file parsers (the import pipeline's own readers)
|
|
18
18
|
parseStl, parse3MF,
|
package/types/part.d.ts
CHANGED
|
@@ -408,6 +408,13 @@ export interface DfmProfile {
|
|
|
408
408
|
bed?: [number, number, number];
|
|
409
409
|
/** Minimum wall in mm — a warning, never a gate. */
|
|
410
410
|
minWall?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Steepest unsupported face the process prints cleanly, in degrees from
|
|
413
|
+
* vertical (45 on the FDM profiles; absent on resin). Checked only for a part
|
|
414
|
+
* that also declares `verify.orientation: "print"`; `null` switches it off
|
|
415
|
+
* under a named base. A warning, never a gate.
|
|
416
|
+
*/
|
|
417
|
+
overhang?: number | null;
|
|
411
418
|
/** Carried for a future gap check; not enforced yet. */
|
|
412
419
|
clearance?: number;
|
|
413
420
|
/** Inherit from a named profile and override the rest. */
|
|
@@ -441,6 +448,12 @@ export interface SubPartExpectations {
|
|
|
441
448
|
boundsMin?: Expectation;
|
|
442
449
|
boundsMax?: Expectation;
|
|
443
450
|
minWall?: Expectation;
|
|
451
|
+
/**
|
|
452
|
+
* Unsupported downward-facing surface in mm² (faces steeper than the profile's
|
|
453
|
+
* `overhang` angle, bed at the sub-part's own lowest Z). Measured only under
|
|
454
|
+
* `verify.orientation: "print"`; a warning, never a gate.
|
|
455
|
+
*/
|
|
456
|
+
overhangArea?: Expectation;
|
|
444
457
|
/** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
|
|
445
458
|
refXorVolume?: Expectation;
|
|
446
459
|
/** Percent volume delta vs. the sub-part's declared `reference` import. */
|
|
@@ -476,6 +489,13 @@ export interface ExpectMap {
|
|
|
476
489
|
export interface VerifyBlock<P = ResolvedParams, D = Derived> {
|
|
477
490
|
/** A named DFM profile or an inline one. */
|
|
478
491
|
process?: DfmProfileName | DfmProfile;
|
|
492
|
+
/**
|
|
493
|
+
* `"print"` declares the part is laid out for its bed — Z up, each sub-part's
|
|
494
|
+
* bed at its own lowest Z — which is the only way the profile's `overhang`
|
|
495
|
+
* check is armed. Omit it while a part is still being shaped or is bound for
|
|
496
|
+
* another process.
|
|
497
|
+
*/
|
|
498
|
+
orientation?: "print";
|
|
479
499
|
/** Which cases to check; default is `"defaults"` plus every preset name. */
|
|
480
500
|
cases?: string[];
|
|
481
501
|
/**
|
package/types/testing.d.ts
CHANGED
|
@@ -250,6 +250,17 @@ export function minWall(
|
|
|
250
250
|
totalTriangles: number;
|
|
251
251
|
} | null;
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Unsupported downward-facing surface (oracle/overhang.js): the mm² of faces
|
|
255
|
+
* steeper than `maxAngle` from vertical, excluding the footprint on the bed
|
|
256
|
+
* (`bedZ`, else the mesh's lowest Z) and a near-bed band. One pass, no index;
|
|
257
|
+
* `null` only for an empty mesh. Bridges and bore ceilings count.
|
|
258
|
+
*/
|
|
259
|
+
export function overhang(
|
|
260
|
+
mesh: Mesh,
|
|
261
|
+
opts?: { maxAngle?: number; bedZ?: number; bedEps?: number; bedBand?: number },
|
|
262
|
+
): { area: number; worstAngle: number | null; at: Point3 | null } | null;
|
|
263
|
+
|
|
253
264
|
// --- measure ----------------------------------------------------------------
|
|
254
265
|
|
|
255
266
|
export interface SubPartFacts {
|
|
@@ -272,6 +283,16 @@ export interface SubPartFacts {
|
|
|
272
283
|
minWallAt: number[] | null;
|
|
273
284
|
minWallSampled: boolean;
|
|
274
285
|
minWallSamples: { sampled: number; total: number } | null;
|
|
286
|
+
/**
|
|
287
|
+
* Unsupported downward-facing area in mm² (oracle/overhang.js), `null` when the
|
|
288
|
+
* part is not laid out for a bed (`verify.orientation: "print"` under a
|
|
289
|
+
* profile with an `overhang` angle). Bridges and bore ceilings count.
|
|
290
|
+
*/
|
|
291
|
+
overhangArea: number | null;
|
|
292
|
+
/** Steepest offending face, degrees from vertical; `null` when none. */
|
|
293
|
+
overhangAngle: number | null;
|
|
294
|
+
/** Centroid of the largest offending face; `null` when none. */
|
|
295
|
+
overhangAt: number[] | null;
|
|
275
296
|
}
|
|
276
297
|
|
|
277
298
|
export interface AggregateFacts {
|
|
@@ -289,6 +310,8 @@ export interface MeasureReport {
|
|
|
289
310
|
view: string;
|
|
290
311
|
/** Whether this run cast min-wall rays at all. */
|
|
291
312
|
measuredMinWall: boolean;
|
|
313
|
+
/** The overhang angle every sub-part's `overhangArea` was measured against, `null` when the pass did not run. */
|
|
314
|
+
measuredOverhang: number | null;
|
|
292
315
|
subparts: SubPartFacts[];
|
|
293
316
|
aggregate: AggregateFacts;
|
|
294
317
|
overlaps: Overlap[];
|
|
@@ -308,6 +331,12 @@ export function measure(
|
|
|
308
331
|
params?: ResolvedParams,
|
|
309
332
|
opts?: {
|
|
310
333
|
minWall?: boolean;
|
|
334
|
+
/**
|
|
335
|
+
* The overhang angle to measure against (degrees from vertical), or `null`
|
|
336
|
+
* for "not checked". Omitted, measure derives it from the part's own
|
|
337
|
+
* `verify` block the way verify does.
|
|
338
|
+
*/
|
|
339
|
+
overhang?: number | null;
|
|
311
340
|
gapThreshold?: number;
|
|
312
341
|
/**
|
|
313
342
|
* A build of this view the caller already has, measured instead of building a
|
|
@@ -323,7 +352,8 @@ export function measure(
|
|
|
323
352
|
export type CheckStatus = "pass" | "fail" | "warn" | "skip";
|
|
324
353
|
|
|
325
354
|
export interface VerifyCheck {
|
|
326
|
-
|
|
355
|
+
/** `"part"` is the vacuous-verify notice; `"case"` the quick-lap "not measured" marker. */
|
|
356
|
+
scope: "view" | "subpart" | "part" | "case";
|
|
327
357
|
/** The sub-part name, `"a×b"` for a pair check, or `null` for a scalar view metric. */
|
|
328
358
|
subpart: string | null;
|
|
329
359
|
metric: string;
|
|
@@ -339,7 +369,7 @@ export interface VerifyCheck {
|
|
|
339
369
|
hint?: string;
|
|
340
370
|
/** A stable ERROR-PATTERNS.md entry id. */
|
|
341
371
|
pattern?: string;
|
|
342
|
-
/** A caveat
|
|
372
|
+
/** A measurement caveat or companion reading — `minWall` (sampling) and `overhangArea` (the steepest angle) set one. */
|
|
343
373
|
note?: string;
|
|
344
374
|
/** `[x, y, z]` in mm, for the metrics that have one. */
|
|
345
375
|
location?: number[] | null;
|
|
@@ -353,13 +383,25 @@ export interface VerifyCaseResult {
|
|
|
353
383
|
}
|
|
354
384
|
|
|
355
385
|
export interface VerifyReport {
|
|
356
|
-
/**
|
|
357
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Tri-state: `true` when every declared check passed, `false` on any gate
|
|
388
|
+
* failure, `null` when no verdict can be given — a quick lap that could not
|
|
389
|
+
* measure a gate, or a part that declared no expectations at all (`declared`
|
|
390
|
+
* is 0 and `warnings` carries a `no expectations declared` notice). Never read
|
|
391
|
+
* `null` as a pass.
|
|
392
|
+
*/
|
|
393
|
+
ok: boolean | null;
|
|
358
394
|
view: string;
|
|
359
395
|
cases: VerifyCaseResult[];
|
|
360
396
|
/** Every failing check, flattened, each tagged with its `case`. */
|
|
361
397
|
failures: Array<VerifyCheck & { case: string }>;
|
|
362
398
|
warnings: Array<VerifyCheck & { case: string }>;
|
|
399
|
+
/** Checks a quick lap could not evaluate. */
|
|
400
|
+
unevaluated: Array<VerifyCheck & { case: string }>;
|
|
401
|
+
/** Check instances the part or its profile declared, across cases (near-miss notices excluded). */
|
|
402
|
+
declared: number;
|
|
403
|
+
/** Of `declared`, how many were actually answered — a skipped or unevaluated check is not. Zero withholds `ok`. */
|
|
404
|
+
evaluated: number;
|
|
363
405
|
}
|
|
364
406
|
|
|
365
407
|
/**
|