partforge 0.109.1 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@
16
16
  // Ops that need the real B-rep (booleans, fillet/chamfer/shell, exports,
17
17
  // volume, boundingBox) materialize the pending pose through replicad first.
18
18
  import { assertNoCoincidentBoolean } from "./occt-coincidence.js";
19
+ import { checkBooleanResult } from "./boolean-gate.js";
19
20
  import { toEdgeFinder } from "./edge-selector.js";
20
21
  import { toFaceFinder } from "./face-selector.js";
21
22
  import { addSugar } from "./solid-sugar.js";
@@ -128,6 +129,66 @@ export function createOcctKernel(replicad) {
128
129
  // see occt-repair.js for the policies and why they differ per op.
129
130
  const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
130
131
 
132
+ // Boolean result gate (boolean-gate.js): the coincidence guard above refuses the
133
+ // one degenerate CONSTRUCTION it can recognise before a boolean runs; this judges
134
+ // the RESULT afterwards, by volume, and throws on the impossible — a union
135
+ // smaller than an input, a cut that grew, a negative volume, and the documented
136
+ // silent failure where OCCT hands back one operand instead of the union.
137
+ // `judge(op, shapes, run)` owns the ordering: operand volumes are read BEFORE
138
+ // `run` (replicad consumes what it fuses/cuts, so a fused tool measured
139
+ // afterwards would be a deleted shape), the result's after; both are memoized
140
+ // per replicad shape, since a judged result is the next op's operand. A
141
+ // one-operand call runs unjudged and unmeasured (the gate has nothing to
142
+ // compare). The probes run only on the signatures the inequalities cannot
143
+ // decide: an enclosure test off bounding boxes first — a padded B-spline box
144
+ // can only make enclosure HARDER to prove, the safe direction — then one
145
+ // intersect on clones, freed at once. That intersect goes through the
146
+ // coincidence guard like every other boolean here, because it runs on exactly
147
+ // the operand pair that just misbehaved: a refused pair answers "nothing
148
+ // inside", which on the equal-volume signature is the refusal the gate was
149
+ // about to make anyway rather than a grind the WASM build cannot abort. A
150
+ // refusal is remembered by cache key so a live edit does not re-pay the failing
151
+ // boolean and its probe on every rebuild, and the refused shape is freed.
152
+ const volumes = new WeakMap();
153
+ const volumeOf = (s) => {
154
+ let v = volumes.get(s);
155
+ if (v === undefined) { v = measureVolume(s); volumes.set(s, v); }
156
+ return v;
157
+ };
158
+ const BOX_SLACK = 1e-3; // mm — an enclosure test, not a measurement
159
+ const encloses = (a, b) => {
160
+ const [amin, amax] = a.boundingBox.bounds, [bmin, bmax] = b.boundingBox.bounds;
161
+ return [0, 1, 2].every((k) => bmin[k] >= amin[k] - BOX_SLACK && bmax[k] <= amax[k] + BOX_SLACK);
162
+ };
163
+ const judge = (op, shapes, run, label = op) => {
164
+ if (shapes.length < 2) return run();
165
+ const operandVolumes = shapes.map(volumeOf);
166
+ const result = run();
167
+ const err = checkBooleanResult(op, operandVolumes, volumeOf(result), {
168
+ encloses: (i, j) => encloses(shapes[i], shapes[j]),
169
+ overlap: (i, j) => {
170
+ try { guardBoolean("intersect", [shapes[i], shapes[j]]); }
171
+ catch (e) { if (e?.code === "COINCIDENT_BOOLEAN") return 0; throw e; }
172
+ let x;
173
+ try { x = shapes[i].clone().intersect(shapes[j].clone()); return measureVolume(x); }
174
+ finally { x?.delete?.(); }
175
+ },
176
+ }, label);
177
+ if (err) { result.delete?.(); throw err; }
178
+ return result;
179
+ };
180
+ const refusals = new Map();
181
+ const judgedCache = (key, make) => {
182
+ const remembered = refusals.get(key);
183
+ if (remembered) throw remembered;
184
+ return cached(key, () => {
185
+ try { return make(); } catch (e) {
186
+ if (e?.code === "BOOLEAN_RESULT_INVALID") refusals.set(key, e);
187
+ throw e;
188
+ }
189
+ });
190
+ };
191
+
131
192
  // name -> { shape, digest } | { error, digest } — imported geometry the framework
132
193
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
133
194
  // solid cache: imports are the framework's own memo, keyed by name+digest).
@@ -228,26 +289,32 @@ export function createOcctKernel(replicad) {
228
289
  },
229
290
  cut: (t) => {
230
291
  const key = h("cut", hash, t._hash);
231
- return cached(key, () => {
292
+ return judgedCache(key, () => {
232
293
  const a = mat(), b = t._mat();
233
294
  guardBoolean("cut", [a._s, b._s]);
234
- return wrap(a._s.clone().cut(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
295
+ const result = judge("cut", [a._s, b._s], () => a._s.clone().cut(b._s.clone()));
296
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
235
297
  });
236
298
  },
237
299
  cutAll: (tools) => {
238
300
  const key = h("cutAll", hash, tools.map((t) => t._hash));
239
- return cached(key, () => {
301
+ return judgedCache(key, () => {
240
302
  const a = mat(), bs = tools.map((t) => t._mat());
241
303
  if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
304
+ const toolShapes = bs.map((b) => b._s);
242
305
  // All pairs, tools included: the cut below first fuses the tools
243
306
  // together, so tool-to-tool contact hangs exactly like target-to-tool
244
307
  // (the measured case WAS two tools — a bore and its thread).
245
- guardBoolean("cutAll", [a._s, ...bs.map((b) => b._s)]);
246
- const fusedTools = bs
247
- .slice(1)
248
- .reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
308
+ guardBoolean("cutAll", [a._s, ...toolShapes]);
309
+ // The tool fuse is a union in its own right — the bore + thread case
310
+ // fails HERE (the thread dropped, the cut then honestly removes a plain
311
+ // bore), so it is judged as one before the cut is.
312
+ const fusedTools = judge("union", toolShapes,
313
+ () => bs.slice(1).reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone()),
314
+ "cutAll (tools)");
315
+ const result = judge("cutAll", [a._s, ...toolShapes], () => a._s.clone().cut(fusedTools));
249
316
  return wrap(
250
- a._s.clone().cut(fusedTools),
317
+ result,
251
318
  [...cloneLabels(a._labels), ...bs.flatMap((b) => cloneLabels(b._labels))],
252
319
  key,
253
320
  );
@@ -255,18 +322,20 @@ export function createOcctKernel(replicad) {
255
322
  },
256
323
  intersect: (t) => {
257
324
  const key = h("intersect", hash, t._hash);
258
- return cached(key, () => {
325
+ return judgedCache(key, () => {
259
326
  const a = mat(), b = t._mat();
260
327
  guardBoolean("intersect", [a._s, b._s]);
261
- return wrap(a._s.clone().intersect(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
328
+ const result = judge("intersect", [a._s, b._s], () => a._s.clone().intersect(b._s.clone()));
329
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
262
330
  });
263
331
  },
264
332
  union: (t) => {
265
333
  const key = h("union", [hash, t._hash]);
266
- return cached(key, () => {
334
+ return judgedCache(key, () => {
267
335
  const a = mat(), b = t._mat();
268
336
  guardBoolean("union", [a._s, b._s]);
269
- return wrap(a._s.clone().fuse(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
337
+ const result = judge("union", [a._s, b._s], () => a._s.clone().fuse(b._s.clone()));
338
+ return wrap(result, [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
270
339
  });
271
340
  },
272
341
  clone: () => wrap(shape.clone(), cloneLabels(labels), hash, pose, baseHash),
@@ -343,7 +412,7 @@ export function createOcctKernel(replicad) {
343
412
  return wrap(safeOp(a._s.clone(), (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(a._labels), key);
344
413
  });
345
414
  },
346
- volume: () => measureVolume(mat()._s),
415
+ volume: () => volumeOf(mat()._s), // shares the gate's memo — a judged result is already measured
347
416
  // Same default as toSTL: an export is an export, so a .3mf must not ship a
348
417
  // coarser tessellation than the .stl of the same solid would.
349
418
  toIndexedMesh: ({ quality = "print" } = {}) => {
@@ -643,11 +712,11 @@ export function createOcctKernel(replicad) {
643
712
  sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
644
713
  union: (solids) => {
645
714
  const key = h("union", solids.map((s) => s._hash));
646
- return cached(key, () => {
715
+ return judgedCache(key, () => {
647
716
  const ms = solids.map((s) => s._mat());
648
717
  guardBoolean("union", ms.map((m) => m._s));
649
718
  return wrap(
650
- ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
719
+ judge("union", ms.map((m) => m._s), () => ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b))),
651
720
  ms.flatMap((m) => cloneLabels(m._labels)),
652
721
  key,
653
722
  );
@@ -661,10 +730,15 @@ export function createOcctKernel(replicad) {
661
730
  // Same cache key as union — the geometry is identical either way.
662
731
  _trustedUnion: (solids) => {
663
732
  const key = h("union", solids.map((s) => s._hash));
664
- return cached(key, () => {
733
+ return judgedCache(key, () => {
665
734
  const ms = solids.map((s) => s._mat());
735
+ // Trusted skips the PRE-check only; the result is judged like any union —
736
+ // the volume gate is precisely what proves the trusted composition worked.
737
+ // Labelled for what it is: the author never wrote this union, so a refusal
738
+ // here means the framework's own audited composition broke on this kernel.
666
739
  return wrap(
667
- ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
740
+ judge("union", ms.map((m) => m._s), () => ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
741
+ "k.tappedBore's bore ∪ thread union (the framework's own composition — report this)"),
668
742
  ms.flatMap((m) => cloneLabels(m._labels)),
669
743
  key,
670
744
  );
@@ -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
  ];
@@ -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
  }
@@ -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";