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 CHANGED
@@ -222,11 +222,17 @@ const commands = {
222
222
  };
223
223
  if (flags.out) writeOut();
224
224
  let vok = true;
225
- if ((part.verify || flags.process) && !flags["no-verify"]) {
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
- vok = v.ok;
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
- console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
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) {
@@ -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, attached
2997
- whatever the verdict. Today only `minWall` sets one, when the reading came
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) and `overlaps` (the center of the first offending intersection's
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
- and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
3115
- facts `measure` already reports `holes` (through-bores / genus), `volume`,
3116
- `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `boundsMin` / `boundsMax`
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 assertions **skip** on OCCT parts
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 —
@@ -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` *warnings*, which
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.110.0",
3
+ "version": "0.111.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
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
- const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
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. `bed` is the build volume
2
- // [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `clearance` mm is
3
- // carried for a future gap check (not enforced yet).
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 { partGatesMinWall } from "./gates.js";
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
- export function evaluateCase(facts, { profile, expect, subPartNames }) {
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
- if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
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
- ok: failures.length ? false : unevaluated.length ? null : true,
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: all.filter((c) => c.status === "warn"),
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
- if (globalThis.matchMedia && !matchMedia("(hover: hover)").matches) return { detach: () => {} };
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
  /**
@@ -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
- scope: "view" | "subpart";
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 about HOW the value was measured today only `minWall` sets one. */
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
- /** True when no check has status `"fail"`. */
357
- ok: boolean;
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
  /**