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.
- package/bin/cli.js +22 -3
- package/docs/AUTHORING-PARTS.md +68 -12
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +40 -2
- package/package.json +1 -1
- package/src/framework/geometry/boolean-gate.js +200 -0
- package/src/framework/geometry/errors.js +11 -0
- package/src/framework/geometry/manifold-backend.js +46 -7
- package/src/framework/geometry/occt-backend.js +91 -17
- package/src/framework/lint/rules-verify.js +17 -1
- package/src/framework/oracle/dfm-profiles.js +45 -5
- package/src/framework/oracle/gates.js +10 -1
- package/src/framework/oracle/measure.js +27 -1
- package/src/framework/oracle/overhang.js +111 -0
- package/src/framework/oracle/verify.js +73 -8
- package/src/framework/verify-metrics.js +8 -0
- package/src/oracle.js +1 -0
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +20 -0
- package/types/testing.d.ts +46 -4
|
@@ -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
|
|
292
|
+
return judgedCache(key, () => {
|
|
232
293
|
const a = mat(), b = t._mat();
|
|
233
294
|
guardBoolean("cut", [a._s, b._s]);
|
|
234
|
-
|
|
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
|
|
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, ...
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
325
|
+
return judgedCache(key, () => {
|
|
259
326
|
const a = mat(), b = t._mat();
|
|
260
327
|
guardBoolean("intersect", [a._s, b._s]);
|
|
261
|
-
|
|
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
|
|
334
|
+
return judgedCache(key, () => {
|
|
267
335
|
const a = mat(), b = t._mat();
|
|
268
336
|
guardBoolean("union", [a._s, b._s]);
|
|
269
|
-
|
|
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: () =>
|
|
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
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Reusable design-for-manufacturing process profiles — a manufacturing technique
|
|
2
|
+
// as DATA: which checks apply and at what thresholds. `bed` is the build volume
|
|
3
|
+
// [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `overhang` is the
|
|
4
|
+
// steepest unsupported face the process prints cleanly, in degrees from vertical
|
|
5
|
+
// (a warn, armed only by the part — see overhangAngleFor; resin prints on
|
|
6
|
+
// supports, so it carries none); `clearance` mm is carried for a future gap check
|
|
7
|
+
// (not enforced yet).
|
|
4
8
|
export const PROFILES = {
|
|
5
|
-
"fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2 },
|
|
6
|
-
"fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3 },
|
|
9
|
+
"fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45 },
|
|
10
|
+
"fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45 },
|
|
7
11
|
"resin": { bed: [120, 68, 160], minWall: 0.6, clearance: 0.1 },
|
|
8
12
|
};
|
|
9
13
|
|
|
@@ -21,3 +25,39 @@ export function resolveProfile(spec) {
|
|
|
21
25
|
}
|
|
22
26
|
throw new Error(`invalid process profile: ${JSON.stringify(spec)}`);
|
|
23
27
|
}
|
|
28
|
+
|
|
29
|
+
// The one legal `verify.orientation` value today. Z is up everywhere in partforge
|
|
30
|
+
// and the bed is a sub-part's own lowest Z, so "print" is a declaration, not a
|
|
31
|
+
// choice of axis.
|
|
32
|
+
export const ORIENTATIONS = ["print"];
|
|
33
|
+
|
|
34
|
+
// The angle an author's own `overhangArea` expectation is measured against when
|
|
35
|
+
// the profile names none (a resin part, or no profile at all): FDM's usual 45.
|
|
36
|
+
export const DEFAULT_OVERHANG_ANGLE = 45;
|
|
37
|
+
|
|
38
|
+
// Which overhang angle a part is checked against, or null when it is not checked.
|
|
39
|
+
// Two ways in, both explicit — a profile alone never arms it, because the cloud
|
|
40
|
+
// agent writes `process: "fdm-pla"` by habit and a part still being shaped should
|
|
41
|
+
// not be nagged about its underside:
|
|
42
|
+
// - `verify.orientation: "print"` (the part is laid out for its bed) under a
|
|
43
|
+
// profile carrying `overhang`; a profile without one (resin) checks nothing;
|
|
44
|
+
// - an `overhangArea` expectation the author wrote themselves, in any case's
|
|
45
|
+
// `expect` (`expanded`, from gates.js's expandExpectations) — measured against
|
|
46
|
+
// the profile's angle, or DEFAULT_OVERHANG_ANGLE when the profile has none,
|
|
47
|
+
// so a declared expectation is never answered "unavailable".
|
|
48
|
+
// Throws on an orientation value outside ORIENTATIONS — verify's callers want that
|
|
49
|
+
// loud, like an unknown profile name, and lint's `verify-unknown-orientation`
|
|
50
|
+
// catches it before a kernel boots; gates.js wraps this total for measure().
|
|
51
|
+
export function overhangAngleFor(part, process, { expanded = [] } = {}) {
|
|
52
|
+
const orientation = part?.verify?.orientation;
|
|
53
|
+
if (orientation != null && !ORIENTATIONS.includes(orientation)) {
|
|
54
|
+
throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
|
|
55
|
+
}
|
|
56
|
+
const spec = process ?? part?.verify?.process;
|
|
57
|
+
const fromProfile = spec ? resolveProfile(spec).overhang : undefined;
|
|
58
|
+
const angle = typeof fromProfile === "number" && Number.isFinite(fromProfile) ? fromProfile : null;
|
|
59
|
+
if (orientation === "print" && angle != null) return angle;
|
|
60
|
+
const asserted = expanded.some(({ expect }) =>
|
|
61
|
+
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "overhangArea" in o));
|
|
62
|
+
return asserted ? (angle ?? DEFAULT_OVERHANG_ANGLE) : null;
|
|
63
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// `expect` function throws, is reported as GATED — the conservative direction, since
|
|
11
11
|
// that is the full-resolution behaviour every part had before budgets existed. The
|
|
12
12
|
// real error surfaces from verify, which is where a reader can act on it.
|
|
13
|
-
import { resolveProfile } from "./dfm-profiles.js";
|
|
13
|
+
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
14
14
|
import { expandCases } from "./cases.js";
|
|
15
15
|
import { resolveParams } from "../part-model.js";
|
|
16
16
|
|
|
@@ -38,6 +38,15 @@ export function expandExpectations(part) {
|
|
|
38
38
|
return expanded;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The overhang angle a part is checked against, or null when it is not checked
|
|
42
|
+
// (dfm-profiles.js overhangAngleFor holds the rule). Total, like the rest of this
|
|
43
|
+
// file: a malformed orientation or profile answers null here and raises from
|
|
44
|
+
// verify, where a reader can act on it. measure() asks so the fact is computed
|
|
45
|
+
// for exactly the parts that will be judged on it.
|
|
46
|
+
export function partOverhangAngle(part, { process, expanded } = {}) {
|
|
47
|
+
try { return overhangAngleFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
export function partGatesMinWall(part, { process, expanded } = {}) {
|
|
42
51
|
try {
|
|
43
52
|
const spec = process ?? part?.verify?.process;
|
|
@@ -5,7 +5,8 @@ import { resolveParams } from "../part-model.js";
|
|
|
5
5
|
import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
|
|
6
6
|
import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
7
7
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
8
|
-
import {
|
|
8
|
+
import { overhang } from "./overhang.js";
|
|
9
|
+
import { partGatesMinWall, partOverhangAngle } from "./gates.js";
|
|
9
10
|
|
|
10
11
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
11
12
|
const unionBounds = (list) => list.reduce(
|
|
@@ -123,6 +124,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
123
124
|
// inward ray per sampled triangle plus the BVH those rays need — and on an
|
|
124
125
|
// ungated part it buys a fact nobody checks, at full price, on every agent edit.
|
|
125
126
|
const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
|
|
127
|
+
// Overhang is measured only for a part that opted in (dfm-profiles.js
|
|
128
|
+
// overhangAngleFor) — everything else reads null. verify hands the angle it
|
|
129
|
+
// resolved in (a `process` override changes it); `null` there is an explicit
|
|
130
|
+
// "not checked", not a fallback. The angle used is stamped on the result as
|
|
131
|
+
// `measuredOverhang`, and verify refuses a seed whose stamp disagrees with the
|
|
132
|
+
// angle it needs — the min-wall superset rule's counterpart, since a reading
|
|
133
|
+
// taken against the wrong threshold is worse than none.
|
|
134
|
+
const overhangAngle = opts.overhang !== undefined ? opts.overhang : partOverhangAngle(part);
|
|
126
135
|
const subBounds = [];
|
|
127
136
|
const subparts = built.map(({ name, solid, mesh }) => {
|
|
128
137
|
const b = bounds(mesh.positions);
|
|
@@ -130,6 +139,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
130
139
|
// Resolved lazily and only when asked for: without min-wall, a single-sub-part
|
|
131
140
|
// view (no meshGaps) must still build no index at all.
|
|
132
141
|
const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples }) : null;
|
|
142
|
+
// One pass over the triangles, no index — cheap enough for every lap. The bed
|
|
143
|
+
// is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
|
|
144
|
+
// that is never printed (`exportable: false` — a reference ghost, a probe
|
|
145
|
+
// slab, a placeholder) is not judged. Judged in the DISPLAY pose, which is
|
|
146
|
+
// what measure builds; a part whose export pose differs (a lid that prints
|
|
147
|
+
// flat beside its base) is a known gap, stated in the authoring docs.
|
|
148
|
+
const printed = part.parts[name]?.exportable !== false;
|
|
149
|
+
const oh = overhangAngle != null && printed ? overhang(mesh, { maxAngle: overhangAngle, bedZ: b.min[2] }) : null;
|
|
133
150
|
const vol = solid.volume();
|
|
134
151
|
// Deviation-from-reference: only for a sub-part that declares `reference:
|
|
135
152
|
// "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
|
|
@@ -172,6 +189,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
172
189
|
// `measuredMinWall` false is "never looked".
|
|
173
190
|
minWallSampled: mw?.sampled ?? false,
|
|
174
191
|
minWallSamples: mw ? { sampled: mw.sampledTriangles, total: mw.totalTriangles } : null,
|
|
192
|
+
// Unsupported downward-facing area in mm² (overhang.js), null when the part
|
|
193
|
+
// is not laid out for a bed: bridges and bore ceilings count, by design.
|
|
194
|
+
overhangArea: oh ? oh.area : null,
|
|
195
|
+
overhangAngle: oh?.worstAngle ?? null,
|
|
196
|
+
overhangAt: oh?.at ?? null,
|
|
175
197
|
};
|
|
176
198
|
});
|
|
177
199
|
|
|
@@ -231,6 +253,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
231
253
|
// nothing measured it, which reads identically to "no reading available";
|
|
232
254
|
// verify's seeding rule turns on exactly this distinction (see verify.js).
|
|
233
255
|
measuredMinWall: !!opts.minWall,
|
|
256
|
+
// The overhang angle every sub-part's `overhangArea` was measured against,
|
|
257
|
+
// or null when the pass did not run — read by verify's seed gate, never a
|
|
258
|
+
// caller's claim.
|
|
259
|
+
measuredOverhang: overhangAngle ?? null,
|
|
234
260
|
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
235
261
|
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
236
262
|
measuredGaps,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/framework/oracle/overhang.js
|
|
2
|
+
// Unsupported downward-facing surface, for a part oriented for a print bed.
|
|
3
|
+
//
|
|
4
|
+
// One pass over the triangles, pure, no BVH and no rays — cheap enough to run on
|
|
5
|
+
// the quick lap alongside bbox and volume, and deliberately NOT sampled the way
|
|
6
|
+
// min-wall.js is: a sampled minimum degrades to an honest upper bound, but a
|
|
7
|
+
// sampled AREA is an estimate that would flicker either side of the warning
|
|
8
|
+
// floor between identical laps. Measured at ~13 ms on a 400k-triangle soup before
|
|
9
|
+
// the prefilter below, under `meshArea`'s own cost.
|
|
10
|
+
//
|
|
11
|
+
// A face is an overhang when its outward normal points down more steeply than
|
|
12
|
+
// the process allows: the angle is measured FROM VERTICAL, so a wall reads 0°,
|
|
13
|
+
// a 45° chamfer 45°, a ceiling 90°, and a face counts when its angle exceeds
|
|
14
|
+
// `maxAngle` (FDM's usual 45) by more than ANGLE_EPS — a chamfer authored AT the
|
|
15
|
+
// angle must not warn, and float32 positions perturb a computed normal by ~1e-6,
|
|
16
|
+
// which is a thousandth of a degree, not the nanodegree a sine-space epsilon
|
|
17
|
+
// would allow (measured: a 45° underside fired on 32 of 40 Z-rotations before
|
|
18
|
+
// the tolerance moved into angle space).
|
|
19
|
+
//
|
|
20
|
+
// Two bands of faces are excluded, both against the bed — the MESH's own lowest
|
|
21
|
+
// Z rather than z = 0, so an assembly's sub-parts are each judged as printed
|
|
22
|
+
// separately on their own base:
|
|
23
|
+
// - the footprint: faces with every vertex within `bedEps` of the bed. The
|
|
24
|
+
// slack scales with the part (0.1% of its height, floored at 10 µm) because a
|
|
25
|
+
// single boolean-noise vertex a few microns low, or a 0.001° tilt, would
|
|
26
|
+
// otherwise lift the entire bottom face off the bed and report it as a 90°
|
|
27
|
+
// ceiling (measured on a 60×60×5 plate: 3600 mm² of false overhang either way).
|
|
28
|
+
// - the near-bed band: faces whose centroid sits within `bedBand` (1 mm) of the
|
|
29
|
+
// bed. That is the lower curl of a bottom-edge fillet or chamfer — a 20×20×5
|
|
30
|
+
// plate with r=1 fillets carried 58 mm² of "overhang" there, and every FDM
|
|
31
|
+
// printer lays those first layers down fine. A real ceiling under 1 mm of
|
|
32
|
+
// clearance is missed by the same rule; that gap is not printable anyway.
|
|
33
|
+
//
|
|
34
|
+
// Known limit, stated rather than hidden: a bridge (a flat underside spanning two
|
|
35
|
+
// supports) is geometrically a ceiling, and the mesh alone cannot tell the two
|
|
36
|
+
// apart, so it is reported as an overhang. That is why this fact backs a WARNING
|
|
37
|
+
// and never a gate. Likewise the ceiling of a horizontal bore counts, which is
|
|
38
|
+
// usually what a print-minded author wants told.
|
|
39
|
+
//
|
|
40
|
+
// Works on both mesh forms the oracle sees (Manifold's 9-floats-per-triangle soup
|
|
41
|
+
// and OCCT's indexed vertices), same as min-wall.js.
|
|
42
|
+
|
|
43
|
+
const DEG = 180 / Math.PI;
|
|
44
|
+
// Angle-space tolerance on the threshold (degrees): well above float32 normal
|
|
45
|
+
// noise (~1e-3°), far below any angle an author would distinguish.
|
|
46
|
+
const ANGLE_EPS = 0.01;
|
|
47
|
+
// A face too small to have a trustworthy normal contributes its (negligible) area
|
|
48
|
+
// but never the reported worst angle — a float32 sliver's normal is round-off, and
|
|
49
|
+
// one such sliver used to report a 90° ceiling on a part with none.
|
|
50
|
+
const MIN_ANGLE_AREA = 1e-6; // mm²
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, triangles: number }} mesh
|
|
54
|
+
* @param {{ maxAngle?: number, bedZ?: number, bedEps?: number, bedBand?: number }} [opts]
|
|
55
|
+
* `maxAngle` in degrees from vertical (default 45); `bedZ` the bed height if the
|
|
56
|
+
* caller already knows the mesh's lowest Z (else scanned); `bedEps` the footprint
|
|
57
|
+
* slack (default 0.1% of the mesh height, floored at 0.01 mm); `bedBand` the
|
|
58
|
+
* near-bed band (default 1 mm)
|
|
59
|
+
* @returns {{ area: number, worstAngle: number|null, at: number[]|null }|null}
|
|
60
|
+
* `area` in mm² of every offending face, `worstAngle` the steepest one found,
|
|
61
|
+
* `at` the centroid of the largest offending triangle (a place to point at);
|
|
62
|
+
* null for an empty mesh.
|
|
63
|
+
*/
|
|
64
|
+
export function overhang(mesh, { maxAngle = 45, bedZ, bedEps, bedBand = 1 } = {}) {
|
|
65
|
+
const { positions, indices } = mesh;
|
|
66
|
+
const n = indices ? indices.length : positions.length / 3;
|
|
67
|
+
if (n < 3) return null;
|
|
68
|
+
|
|
69
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
70
|
+
if (bedZ === undefined || bedEps === undefined) {
|
|
71
|
+
for (let i = 2; i < positions.length; i += 3) {
|
|
72
|
+
const z = positions[i];
|
|
73
|
+
if (z < minZ) minZ = z;
|
|
74
|
+
if (z > maxZ) maxZ = z;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const bed = bedZ ?? minZ;
|
|
78
|
+
const eps = bedEps ?? Math.max(0.01, 1e-3 * (maxZ - minZ));
|
|
79
|
+
const bandTop = bed + bedBand;
|
|
80
|
+
|
|
81
|
+
// Prefilter in sine space, generously: only faces that might exceed the angle
|
|
82
|
+
// pay for the asin. The exact comparison is in degrees.
|
|
83
|
+
const nearLimit = Math.sin(Math.max(0, maxAngle - 1) / DEG);
|
|
84
|
+
let area = 0, worst = -1, largest = 0, at = null;
|
|
85
|
+
for (let i = 0; i < n; i += 3) {
|
|
86
|
+
const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
|
|
87
|
+
const az = positions[a + 2], bz = positions[b + 2], cz = positions[c + 2];
|
|
88
|
+
if (az - bed <= eps && bz - bed <= eps && cz - bed <= eps) continue; // the footprint
|
|
89
|
+
if ((az + bz + cz) / 3 <= bandTop) continue; // the near-bed band
|
|
90
|
+
const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = bz - az;
|
|
91
|
+
const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = cz - az;
|
|
92
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
93
|
+
if (nz >= 0) continue; // not facing down
|
|
94
|
+
const len = Math.hypot(nx, ny, nz);
|
|
95
|
+
if (len < 1e-9) continue; // degenerate: no normal
|
|
96
|
+
const down = -nz / len;
|
|
97
|
+
if (down <= nearLimit) continue;
|
|
98
|
+
const angle = Math.asin(Math.min(1, down)) * DEG;
|
|
99
|
+
if (angle <= maxAngle + ANGLE_EPS) continue;
|
|
100
|
+
const triArea = len / 2;
|
|
101
|
+
area += triArea;
|
|
102
|
+
if (triArea >= MIN_ANGLE_AREA && angle > worst) worst = angle;
|
|
103
|
+
if (triArea > largest) {
|
|
104
|
+
largest = triArea;
|
|
105
|
+
at = [(positions[a] + positions[b] + positions[c]) / 3,
|
|
106
|
+
(positions[a + 1] + positions[b + 1] + positions[c + 1]) / 3,
|
|
107
|
+
(az + bz + cz) / 3];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { area, worstAngle: worst < 0 ? null : worst, at };
|
|
111
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
3
|
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
4
|
-
import { resolveProfile } from "./dfm-profiles.js";
|
|
4
|
+
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
5
5
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
|
|
7
7
|
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
@@ -153,7 +153,10 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// Pure policy: profile rules + per-part expect → checks for one case's facts.
|
|
156
|
-
|
|
156
|
+
// `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
|
|
157
|
+
// or null/undefined: only then does the profile's overhang rule apply, as a
|
|
158
|
+
// 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
|
|
159
|
+
export function evaluateCase(facts, { profile, expect, subPartNames, overhang = null }) {
|
|
157
160
|
const checks = [];
|
|
158
161
|
// contacts/clearance are per-pair, not scalar view metrics — peel them off
|
|
159
162
|
// before the registry loop and hand them to pairGapChecks.
|
|
@@ -170,9 +173,14 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
170
173
|
// rays were cast and all missed, wrong when they were never cast. The stamp is
|
|
171
174
|
// what tells those apart, and it is measure()'s own, never a caller's claim.
|
|
172
175
|
const minWallSkipped = facts.measuredMinWall === false;
|
|
176
|
+
// Same shape for overhang: the rule is armed but these facts were measured
|
|
177
|
+
// without the pass (a quick lap reusing a seed that had no angle), so the check
|
|
178
|
+
// is withheld rather than read as "unavailable" — which would count as answered.
|
|
179
|
+
const overhangSkipped = overhang != null && (facts.measuredOverhang ?? null) === null;
|
|
173
180
|
for (const s of facts.subparts) {
|
|
174
181
|
const merged = {
|
|
175
182
|
...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
|
|
183
|
+
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
176
184
|
...(expect?.[s.name] ?? {}),
|
|
177
185
|
};
|
|
178
186
|
for (const [metric, expr] of Object.entries(merged)) {
|
|
@@ -182,6 +190,11 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
182
190
|
hint: "re-run this check without `quick` to measure min wall" });
|
|
183
191
|
continue;
|
|
184
192
|
}
|
|
193
|
+
if (overhangSkipped && metric === "overhangArea" && c.actual == null) {
|
|
194
|
+
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
195
|
+
hint: "re-run this check without `quick` to measure overhang" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
185
198
|
checks.push(c);
|
|
186
199
|
}
|
|
187
200
|
}
|
|
@@ -208,6 +221,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
208
221
|
// budget by it) and must not derive it separately — see there.
|
|
209
222
|
const expanded = expandExpectations(part);
|
|
210
223
|
const needMinWall = partGatesMinWall(part, { process, expanded });
|
|
224
|
+
// Throws on a bad `verify.orientation`, the same loudness as a bad profile name.
|
|
225
|
+
const overhangAngle = overhangAngleFor(part, process, { expanded });
|
|
211
226
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
212
227
|
// byteAwareReplacer on the RELEVANT_ALL branch too: an unattributable derive()
|
|
213
228
|
// still might read a byte-valued image param, and this memo key gates whether
|
|
@@ -263,7 +278,13 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
263
278
|
// a min-wall-less seed is reused, and the min-wall gate it cannot answer becomes
|
|
264
279
|
// `unevaluated` instead of being re-measured. The rule exists to stop a coarse
|
|
265
280
|
// reading standing in for a gate's verdict, and a withheld verdict does that too.
|
|
266
|
-
|
|
281
|
+
// The overhang half of the same rule: a seed is admitted only when it was
|
|
282
|
+
// measured against the angle THIS run needs (both null when neither checks) —
|
|
283
|
+
// `measuredOverhang` is stamped by measure() itself. A `process` override that
|
|
284
|
+
// changes the angle therefore re-measures rather than reusing the inspect
|
|
285
|
+
// job's seed, which was taken against the part's own profile.
|
|
286
|
+
const overhangMatches = (seed?.result?.measuredOverhang ?? null) === (overhangAngle ?? null);
|
|
287
|
+
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && seed.result.view === view) {
|
|
267
288
|
memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
|
|
268
289
|
}
|
|
269
290
|
|
|
@@ -274,7 +295,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
274
295
|
// `probes: false` — no gate reads probe values, so re-running their booleans
|
|
275
296
|
// for every case buys nothing. (A seed measured WITH probes is a superset in
|
|
276
297
|
// the same way a min-wall seed is: the extra key is simply never read here.)
|
|
277
|
-
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false }));
|
|
298
|
+
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false, overhang: overhangAngle }));
|
|
278
299
|
return memo.get(key);
|
|
279
300
|
};
|
|
280
301
|
|
|
@@ -291,19 +312,63 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
291
312
|
}];
|
|
292
313
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
293
314
|
const facts = measureCase(params);
|
|
294
|
-
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames }) : notMeasured(name) };
|
|
315
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, overhang: overhangAngle }) : notMeasured(name) };
|
|
295
316
|
});
|
|
317
|
+
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
318
|
+
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
319
|
+
// (the CLI's exit code, the cloud agent's "verify passes" stop rule) took that
|
|
320
|
+
// as verified. Nothing was. Two counts, and the difference between them is a
|
|
321
|
+
// second thing this used to hide:
|
|
322
|
+
// `declared` — checks the part or its profile asked for, as produced: the
|
|
323
|
+
// undeclared near-miss warnings are facts the oracle volunteers
|
|
324
|
+
// and the quick-lap "not measured" marker stands for a case,
|
|
325
|
+
// so neither counts;
|
|
326
|
+
// `evaluated` — the declared checks that were actually answered. A check
|
|
327
|
+
// that SKIPPED (a `ref*` metric on a sub-part with no
|
|
328
|
+
// reference, `holes` on the OCCT backend, a pair on a disabled
|
|
329
|
+
// sub-part) was declared, but it verified nothing.
|
|
330
|
+
// Zero evaluated withholds the verdict — the same `null` a quick lap uses for
|
|
331
|
+
// "could not check", because that is what it is. Whether anything was DECLARED
|
|
332
|
+
// is decided from the declaration itself (`profile`, the expanded `expect`
|
|
333
|
+
// maps, an armed overhang angle), never from the produced checks: on a quick
|
|
334
|
+
// lap whose seed matches no case there are no checks at all, and counting
|
|
335
|
+
// those would print "no expectations declared" at a part that declares plenty.
|
|
336
|
+
// The notice rides `warnings`, the channel every host already shows, and is
|
|
337
|
+
// deliberately NOT pushed into any case's check list — it is about the part,
|
|
338
|
+
// not about `defaults`. A quick lap that withheld gates explains itself through
|
|
339
|
+
// `unevaluated` and gets no notice.
|
|
340
|
+
const isDeclared = (c) => c.scope !== "case" && c.metric !== "nearMiss";
|
|
296
341
|
const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
|
|
342
|
+
let declared = 0, evaluated = 0;
|
|
343
|
+
for (const c of all) {
|
|
344
|
+
if (!isDeclared(c)) continue;
|
|
345
|
+
declared++;
|
|
346
|
+
if (c.status !== "skip" && !c.unevaluated) evaluated++;
|
|
347
|
+
}
|
|
348
|
+
const declaresAnything = profile != null || overhangAngle != null
|
|
349
|
+
|| expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
|
|
297
350
|
const failures = all.filter((c) => c.status === "fail");
|
|
298
351
|
const unevaluated = all.filter((c) => c.unevaluated);
|
|
352
|
+
const warnings = all.filter((c) => c.status === "warn");
|
|
353
|
+
if (evaluated === 0 && unevaluated.length === 0) {
|
|
354
|
+
warnings.push({ case: null, scope: "part", subpart: null, metric: "expectations", kind: "warn", expr: "evaluated",
|
|
355
|
+
actual: 0, status: "warn", pass: null,
|
|
356
|
+
message: declaresAnything ? "no expectation could be evaluated" : "no expectations declared",
|
|
357
|
+
hint: declaresAnything
|
|
358
|
+
? "every declared check skipped — see the skip reasons above (a metric this backend cannot read, a reference the sub-part does not declare, a disabled sub-part) and declare something this run can answer"
|
|
359
|
+
: "nothing was verified — pin the dimensions and features the part is meant to have in verify.expect (and a process profile for bed fit and min wall), so every edit re-checks them" });
|
|
360
|
+
}
|
|
299
361
|
return {
|
|
300
362
|
// Tri-state, and the order matters: a real failure is still a failure even on a
|
|
301
|
-
// lap that skipped other gates, so `false` outranks the withheld `null
|
|
302
|
-
|
|
363
|
+
// lap that skipped other gates, so `false` outranks the withheld `null`; and a
|
|
364
|
+
// part on which nothing was evaluated has no verdict to give.
|
|
365
|
+
ok: failures.length ? false : unevaluated.length || evaluated === 0 ? null : true,
|
|
303
366
|
view,
|
|
304
367
|
cases: caseResults,
|
|
305
368
|
failures,
|
|
306
|
-
warnings
|
|
369
|
+
warnings,
|
|
307
370
|
unevaluated,
|
|
371
|
+
declared,
|
|
372
|
+
evaluated,
|
|
308
373
|
};
|
|
309
374
|
}
|
|
@@ -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";
|