partforge 0.163.0 → 0.164.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.
@@ -2,7 +2,7 @@ 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
4
  import { heldCheck } from "./held.js";
5
- import { resolveProfile, overhangAngleFor, bedContactFor } from "./dfm-profiles.js";
5
+ import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
6
6
  import { expandExpectations, partGatesMinWall } from "./gates.js";
7
7
  import { relevanceHash } from "../param-deps.js";
8
8
  import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError } from "../verify-metrics.js";
@@ -121,12 +121,13 @@ function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
121
121
  return checks;
122
122
  }
123
123
 
124
- function check(scope, subpart, metric, spec, registry, factsObj) {
124
+ function check(scope, subpart, metric, spec, registry, factsObj, accept = []) {
125
125
  const reg = registry[metric];
126
126
  if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
127
127
  const { expr, hint: partHint } = normalizeExpectation(spec);
128
128
  const actual = reg.extract(factsObj);
129
- const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
129
+ const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr),
130
+ ...(reg.kind === "cost" ? { accepted: accept.includes(reg.costId) } : {}) };
130
131
  if (actual === null || actual === undefined) {
131
132
  if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
132
133
  if (metric === "minWall") {
@@ -156,7 +157,7 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
156
157
  return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
157
158
  }
158
159
  const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
159
- const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
160
+ const status = pass ? "pass" : reg.kind === "warn" ? "warn" : reg.kind === "cost" ? "cost" : "fail";
160
161
  const out = { ...base, actual, status, pass, message };
161
162
  // A measurement caveat rides along whatever the verdict — a min-wall reading
162
163
  // taken from a sample still passed, but the reader should know it was a sample.
@@ -178,14 +179,14 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
178
179
  // (`bounds: { min, max }` → `bounds.min`, `bounds.max`), each judged by `check()` against
179
180
  // its side's own registry entry. A malformed compound value throws, as a malformed
180
181
  // expression does.
181
- function checksFor(scope, subpart, metric, spec, registry, factsObj) {
182
+ function checksFor(scope, subpart, metric, spec, registry, factsObj, accept = []) {
182
183
  const reg = registry[metric];
183
- if (!reg?.sides) return [check(scope, subpart, metric, spec, registry, factsObj)];
184
+ if (!reg?.sides) return [check(scope, subpart, metric, spec, registry, factsObj, accept)];
184
185
  const bad = sidesSpecError(metric, reg, spec);
185
186
  if (bad) throw new Error(`${scope === "view" ? "_view" : subpart}: ${bad}`);
186
187
  return Object.entries(spec).map(([side, sideSpec]) => {
187
188
  const name = `${metric}.${side}`;
188
- return check(scope, subpart, name, sideSpec, { [name]: reg.sides[side] }, factsObj);
189
+ return check(scope, subpart, name, sideSpec, { [name]: reg.sides[side] }, factsObj, accept);
189
190
  });
190
191
  }
191
192
 
@@ -214,8 +215,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
214
215
  // The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
215
216
  // Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
216
217
  // that declares only these has no verdict rather than a vacuous pass.
217
- const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct"]);
218
- const PRINT_ONLY_HINT = "minWall, wall, overhangArea and bedContactPct are print checks, and a sheet part is cut, not printed: nothing measures them here. For a laser part declare sheetBridge (the narrowest web or finger) or sheetGap (the narrowest hole or slot) instead.";
218
+ const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct"]);
219
+ const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct and footprintPct are print checks, and a sheet part is cut, not printed: nothing measures them here. For a laser part declare sheetBridge (the narrowest web or finger) or sheetGap (the narrowest hole or slot) instead.";
219
220
 
220
221
  // A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
221
222
  // volunteered check warns with the reason; a declared one warns with it too, and is
@@ -268,12 +269,16 @@ function sheetRowChecks(s, declaredExp) {
268
269
  }
269
270
 
270
271
  // Pure policy: profile rules + per-part expect → checks for one case's facts.
271
- // `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
272
- // or null/undefined: only then does the profile's overhang rule apply, as a
273
- // 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
272
+ // `overhang` is the angle the part's process arms (dfm-profiles.js overhangAngleFor),
273
+ // or null/undefined: only then does the profile's overhang rule apply, as a 1 mm²
274
+ // cost — a chamfer sitting exactly on the angle sheds slivers.
274
275
  // `bedContact` is the floor the part is checked against (dfm-profiles.js bedContactFor),
275
276
  // or null/undefined: only then does every PRINTED sub-part get `bedContactPct >= floor`.
276
- export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null }) {
277
+ // `footprint` the same for the footprint cost (dfm-profiles.js footprintFor):
278
+ // `footprintPct >= floor` on every printed sub-part.
279
+ // `accept` the cost ids the user accepted (`verify.accept`), stamped on each cost the
280
+ // process volunteered (never on one the author wrote in `expect`).
281
+ export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [] }) {
277
282
  const checks = [];
278
283
  // contacts/clearance/held are pair-derived, not scalar view metrics — peel
279
284
  // them off before the registry loop. held.js explains the exportable bound.
@@ -301,6 +306,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
301
306
  // is withheld rather than read as "unavailable" — which would count as answered.
302
307
  const overhangSkipped = overhang != null && (facts.measuredOverhang ?? null) === null;
303
308
  const bedContactSkipped = bedContact != null && facts.measuredBedContact !== true;
309
+ const footprintSkipped = footprint != null && facts.measuredFootprint !== true;
304
310
  // A piece that is never printed (`exportable: false`) is not judged for bed contact;
305
311
  // the floor is applied to the rest. Without the vocabulary, every row is printed.
306
312
  const printedNames = exportableNames ? new Set(exportableNames) : null;
@@ -319,9 +325,14 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
319
325
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
320
326
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
321
327
  ...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
328
+ ...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
322
329
  ...(expect?.[s.name] ?? {}),
323
330
  };
324
- for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
331
+ // `accept` covers only the process's own default check: a cost metric the author
332
+ // wrote in `expect` is the user's own limit and keeps its meaning, never accepted.
333
+ const authored = expect?.[s.name] ?? {};
334
+ const acceptFor = (metric) => (Object.hasOwn(authored, metric) ? [] : accept);
335
+ for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s, acceptFor(metric))) {
325
336
  if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
326
337
  checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
327
338
  hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
@@ -332,10 +343,11 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
332
343
  hint: "re-run this check without `quick` to measure overhang" });
333
344
  continue;
334
345
  }
335
- if (metric === "bedContactPct" && c.actual == null && s.bedContactError) {
346
+ // The footprint is read by bed contact's pass, so it is withheld the same ways.
347
+ if ((metric === "bedContactPct" || metric === "footprintPct") && c.actual == null && s.bedContactError) {
336
348
  checks.push({ ...c, status: "skip", pass: null, unevaluated: true,
337
349
  message: `not measured: its print (export) pose did not build — ${s.bedContactError}`,
338
- hint: "bed contact is read in the print pose, and this piece's could not be posed by its place() alone, so its export build was read instead — and that build failed. Fix the export pose; the check then runs." });
350
+ hint: `${metric === "footprintPct" ? "the footprint" : "bed contact"} is read in the print pose, and this piece's could not be posed by its place() alone, so its export build was read instead — and that build failed. Fix the export pose; the check then runs.` });
339
351
  continue;
340
352
  }
341
353
  if (bedContactSkipped && metric === "bedContactPct" && c.actual == null) {
@@ -343,6 +355,11 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
343
355
  hint: "re-run this check without `quick` to measure bed contact" });
344
356
  continue;
345
357
  }
358
+ if (footprintSkipped && metric === "footprintPct" && c.actual == null) {
359
+ checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
360
+ hint: "re-run this check without `quick` to measure the footprint" });
361
+ continue;
362
+ }
346
363
  checks.push(c);
347
364
  }
348
365
  }
@@ -372,6 +389,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
372
389
  // Throws on a bad `verify.orientation`, the same loudness as a bad profile name.
373
390
  const overhangAngle = overhangAngleFor(part, process, { expanded });
374
391
  const bedContactFloor = bedContactFor(part, process, { expanded });
392
+ const footprintFloor = footprintFor(part, process, { expanded });
393
+ const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
375
394
  // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
376
395
  // B agrees with A on every param that measurement READ (recorded by measure()
377
396
  // itself). Sound for the same reason the mesh cache is: a deterministic build
@@ -437,7 +456,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
437
456
  const overhangMatches = (seed?.result?.measuredOverhang ?? null) === (overhangAngle ?? null);
438
457
  // Bed contact's half: a seed that skipped the pass cannot answer an armed gate. The
439
458
  // reverse (a seed that ran it, for a run that does not need it) is a free superset.
440
- const bedContactMatches = bedContactFloor == null || seed?.result?.measuredBedContact === true;
459
+ // The footprint cost rides the same pass, so either floor needs it.
460
+ // measure() reads `bedContact` only as "run the pass", so either floor arms it.
461
+ const armsBedPass = bedContactFloor ?? footprintFloor;
462
+ const bedContactMatches = armsBedPass == null || seed?.result?.measuredBedContact === true;
441
463
  // The print-pose half (views holding a sheet part): a seed measured without the print
442
464
  // sizes cannot answer a bed, so it is admitted only when this run needs no bed.
443
465
  const needPrintBboxes = profile?.bed != null;
@@ -456,7 +478,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
456
478
  // for every case buys nothing. (A seed measured WITH probes is a superset in
457
479
  // the same way a min-wall seed is: the extra key is simply never read here.)
458
480
  const result = measureFn(kernel, part, view, params,
459
- { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: bedContactFloor, printBboxes: needPrintBboxes });
481
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes });
460
482
  entries.push({ params: fp, result });
461
483
  return result;
462
484
  };
@@ -477,7 +499,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
477
499
  }];
478
500
  const caseResults = expanded.map(({ name, params, expect }) => {
479
501
  const facts = measureCase(params);
480
- return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor }) : notMeasured(name) };
502
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept }) : notMeasured(name) };
481
503
  });
482
504
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
483
505
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -512,11 +534,12 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
512
534
  declared++;
513
535
  if (c.status !== "skip" && !c.unevaluated) evaluated++;
514
536
  }
515
- const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null
537
+ const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null
516
538
  || expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
517
539
  const failures = all.filter((c) => c.status === "fail");
518
540
  const unevaluated = all.filter((c) => c.unevaluated);
519
541
  const warnings = all.filter((c) => c.status === "warn");
542
+ const costs = all.filter((c) => c.status === "cost");
520
543
  if (evaluated === 0 && unevaluated.length === 0) {
521
544
  warnings.push({ case: null, scope: "part", subpart: null, metric: "expectations", kind: "warn", expr: "evaluated",
522
545
  actual: 0, status: "warn", pass: null,
@@ -534,6 +557,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
534
557
  cases: caseResults,
535
558
  failures,
536
559
  warnings,
560
+ costs,
537
561
  unevaluated,
538
562
  declared,
539
563
  evaluated,
@@ -0,0 +1,48 @@
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 cost — supports — armed by the profile alone, see overhangAngleFor; resin
6
+ // prints on supports, so it carries none); `bedContact` is the least share (%) of a printed
7
+ // piece's near-bed flat underside that must rest on the bed (a GATE, armed by the
8
+ // profile alone — see bedContactFor; resin prints on supports, so it carries none);
9
+ // `clearance` mm is carried for a future gap check (not enforced yet).
10
+ //
11
+ // `fits`, `holeUndersize` and `bridge` are the numbers an author (or the cloud
12
+ // agent) copies into derive() for a mating feature; nothing in this repo reads
13
+ // them yet. They are guide defaults from the FDM design guides, not measurements:
14
+ // a calibrated printer overrides them through an inline `{ base, fits: {...} }`
15
+ // profile, which REPLACES the fits table whole. `fits` are diametral offsets in
16
+ // mm (negative = interference): press (a pin that never comes out), push (a cap
17
+ // pressed on by hand), slip (a lid that lifts off), moving (a hinge pin or axle),
18
+ // printInPlace (parts printed already assembled). `holeUndersize` is added to a
19
+ // horizontal bore's diameter, since the top of a printed arch sags inward.
20
+ // `bridge` is the longest flat span the process prints unsupported; resin prints
21
+ // on supports, so it carries none.
22
+ //
23
+ // `footprint` is the percent of a printed piece's posed base area resting on the
24
+ // bed below which the footprint cost fires.
25
+ //
26
+ // Import-free, like every process descriptor: lint and the oracle read it through
27
+ // process/registry.js.
28
+ export const FDM = {
29
+ id: "fdm",
30
+ label: "FDM 3D printing",
31
+ stock: "filament",
32
+ docId: "printed-parts",
33
+ profiles: {
34
+ "fdm-pla": {
35
+ bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50, footprint: 10,
36
+ fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
37
+ holeUndersize: 0.25, bridge: 10,
38
+ },
39
+ "fdm-petg": {
40
+ bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50, footprint: 10,
41
+ fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
42
+ holeUndersize: 0.3, bridge: 10,
43
+ },
44
+ },
45
+ costs: { supports: "overhangArea", footprint: "footprintPct" },
46
+ metrics: {},
47
+ lintRules: [],
48
+ };
@@ -7,6 +7,8 @@
7
7
  import { isSheetPart } from "../sheet/constants.js";
8
8
  import { LASER } from "./laser/descriptor.js";
9
9
  import { ROUTER } from "./cnc-router/descriptor.js";
10
+ import { FDM } from "./fdm/descriptor.js";
11
+ import { RESIN } from "./resin/descriptor.js";
10
12
 
11
13
  export const PROCESSES = [LASER, ROUTER];
12
14
  export const PROCESS_IDS = PROCESSES.map((d) => d.id);
@@ -23,3 +25,31 @@ export const SHEET_METRICS = Object.assign({}, ...PROCESSES.map((d) => d.metrics
23
25
 
24
26
  // Every process's own lint rules; lint/rules-sheet.js appends them to SHEET_RULES.
25
27
  export const PROCESS_LINT_RULES = PROCESSES.flatMap((d) => d.lintRules);
28
+
29
+ // Print processes are their own list: PROCESSES stays the SHEET list (exporters,
30
+ // destinations, SHEET_METRICS and sheet/part.js's PROCESS_IDS check all read it), so
31
+ // fdm and resin never pass as a sheet part's process.
32
+ export const PRINT_PROCESSES = [FDM, RESIN];
33
+ export const PRINT_PROCESS_IDS = PRINT_PROCESSES.map((d) => d.id);
34
+ export const ALL_PROCESSES = [...PROCESSES, ...PRINT_PROCESSES];
35
+
36
+ export const printProcessById = (id) => PRINT_PROCESSES.find((d) => d.id === id) ?? null;
37
+
38
+ // The DFM profiles of every print process as one map, keyed by profile id.
39
+ export const PRINT_PROFILES = Object.freeze(Object.assign({}, ...PRINT_PROCESSES.map((d) => d.profiles)));
40
+
41
+ // The print process owning a profile spec: a profile id, or an inline object whose
42
+ // `base` (recursively) names one. Anything else is null.
43
+ export function printProcessFor(spec) {
44
+ let id = spec;
45
+ // A cyclic inline profile (`p.base = p`) is malformed, not a loop to follow: lint calls
46
+ // this on user-authored parts, and a hang there takes the editor with it.
47
+ const seen = new Set();
48
+ while (id && typeof id === "object") {
49
+ if (seen.has(id)) return null;
50
+ seen.add(id);
51
+ id = id.base;
52
+ }
53
+ if (typeof id !== "string") return null;
54
+ return PRINT_PROCESSES.find((d) => Object.hasOwn(d.profiles, id)) ?? null;
55
+ }
@@ -0,0 +1,19 @@
1
+ // Resin printing as DATA: see process/fdm/descriptor.js for what each profile field
2
+ // means. Resin prints on supports, so it carries no overhang, bedContact, footprint
3
+ // or bridge, and no costs. Import-free, like every process descriptor.
4
+ export const RESIN = {
5
+ id: "resin",
6
+ label: "Resin 3D printing",
7
+ stock: "resin",
8
+ docId: "printed-parts",
9
+ profiles: {
10
+ "resin": {
11
+ bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
12
+ fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
13
+ holeUndersize: 0.05,
14
+ },
15
+ },
16
+ costs: {},
17
+ metrics: {},
18
+ lintRules: [],
19
+ };
@@ -100,12 +100,14 @@ export const SUBPART_METRICS = {
100
100
  ? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
101
101
  : `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
102
102
  } },
103
- // Unsupported downward-facing area (oracle/overhang.js), in mm². Measured only
104
- // for a part that declares `verify.orientation: "print"` under a profile with
105
- // an `overhang` angle; elsewhere `extract` returns null and check() skips it.
106
- // A warning, never a gate: a bridge reads as a ceiling and cannot be told apart.
107
- overhangArea: { kind: "warn", extract: (s) => s.overhangArea,
108
- 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",
103
+ // Unsupported downward-facing area (oracle/overhang.js), in mm². Measured for a
104
+ // part whose print process carries an `overhang` angle (the process alone arms it)
105
+ // or that asserts it; elsewhere `extract` returns null and check() skips it.
106
+ // A cost, never a gate or a warning: needing supports is a design choice with a
107
+ // price (a bridge reads as a ceiling and cannot be told apart), so it is listed
108
+ // under `costs` and the user may accept it (`verify.accept: ["supports"]`).
109
+ overhangArea: { kind: "cost", costId: "supports", extract: (s) => s.overhangArea,
110
+ 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; when the user chooses supports instead, record it with verify.accept: [\"supports\"]",
109
111
  locate: (s) => s.overhangAt,
110
112
  note: (s) => (s.overhangAngle != null ? `steepest unsupported face ${s.overhangAngle.toFixed(1)}° from vertical` : null) },
111
113
  // How much of a printed piece's near-bed flat underside rests on the bed
@@ -121,6 +123,15 @@ export const SUBPART_METRICS = {
121
123
  note: (s) => (s.bedHoverGap != null
122
124
  ? `${s.bedHoverArea.toFixed(1)} mm² of flat underside sits ${s.bedHoverGap.toFixed(2)} mm above the bed; ${s.bedContactArea.toFixed(1)} mm² touches it`
123
125
  : null) },
126
+ // The footprint (measure.js, from bed contact's pass): the flat contact area over the
127
+ // piece's silhouette on the bed, in percent. Measured where bed contact is, plus where the
128
+ // profile carries `footprint` or the author declares it. A COST, like overhangArea: a
129
+ // piece on a point or a line prints with a brim or a raft, which the user may choose
130
+ // (`accept: ["footprint"]`); bedContactPct passes such a piece, since nothing hovers.
131
+ footprintPct: { kind: "cost", costId: "footprint", extract: (s) => s.footprintPct,
132
+ hint: "the share of this piece's silhouette on the bed (its outline in the print pose) that actually touches it is small — it stands on a point, a line or a curve, which reads near zero, so the first layer has almost nothing to hold on to and the piece can tip or pull loose. Lay it on its flat face, add a flat foot or pad at its lowest point, or print it with a brim (accept: [\"footprint\"] records that choice)",
133
+ pattern: "piece-stands-on-nothing",
134
+ locate: (s) => s.footprintAt },
124
135
  // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
125
136
  // "<import name>"`; on every other sub-part `extract` returns null, which
126
137
  // `check()` already reports as status "skip" rather than a fail — the
@@ -15,6 +15,7 @@ import { handle } from "./jobs.js";
15
15
  export { coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./core/core-status.js";
16
16
  import { lintPart } from "../lint.js";
17
17
  import { cachedVectorDocs } from "./vectors.js";
18
+ import { describeKernelThrow } from "./kernel-error.js";
18
19
 
19
20
  async function manifoldKernels() {
20
21
  const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
@@ -109,10 +110,11 @@ export function runWorker(part, opts = {}) {
109
110
  // escaping rejection would kill the pump: this job AND everything queued behind
110
111
  // it would be dropped with no reply at all, leaving the host waiting on a message
111
112
  // that never comes until its own timeout fires.
113
+ let kernel = null;
112
114
  try {
113
115
  // A generate superseded while it sat in the queue never builds at all.
114
116
  if (job.epoch !== null && job.epoch !== epoch) continue;
115
- const kernel = await kernelFor(job.data);
117
+ kernel = await kernelFor(job.data);
116
118
  // handle() declares each message's transferables (the big binary buffers).
117
119
  const post = (m, transfer = []) => postMessage(m, transfer);
118
120
  if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
@@ -125,11 +127,14 @@ export function runWorker(part, opts = {}) {
125
127
  const gated = (m, transfer = []) =>
126
128
  (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
127
129
  await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
128
- } catch (err) {
130
+ } catch (thrown) {
129
131
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
130
132
  // Carry the job's jobId when it has one (capture/export are correlated by it):
131
133
  // a boot failure hitting kernelFor here must reach the right controller, or a
132
134
  // correlated caller (captureView, exportParts) would hang instead of settling.
135
+ // A bare number (an uncaught C++ exception out of the kernel) is given
136
+ // words here too, exactly as jobs.js does for a build.
137
+ const err = describeKernelThrow(thrown, { evidence: kernel?._throwEvidence?.(thrown) });
133
138
  const jobId = job.data?.jobId;
134
139
  postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
135
140
  }
package/types/part.d.ts CHANGED
@@ -484,9 +484,9 @@ export interface DfmProfile {
484
484
  minWall?: number;
485
485
  /**
486
486
  * Steepest unsupported face the process prints cleanly, in degrees from
487
- * vertical (45 on the FDM profiles; absent on resin). Checked only for a part
488
- * that also declares `verify.orientation: "print"`; `null` switches it off
489
- * under a named base. A warning, never a gate.
487
+ * vertical (45 on the FDM profiles; absent on resin). The print process alone
488
+ * arms the check; `null` switches it off under a named base. Reported as a
489
+ * cost (needing supports), never a failure.
490
490
  */
491
491
  overhang?: number | null;
492
492
  /**
@@ -495,6 +495,8 @@ export interface DfmProfile {
495
495
  * profile alone; `null` switches it off under a named base.
496
496
  */
497
497
  bedContact?: number | null;
498
+ /** Percent of a printed piece's posed base area resting on the bed below which the footprint cost fires. */
499
+ footprint?: number | null;
498
500
  /** Carried for a future gap check; not enforced yet. */
499
501
  clearance?: number;
500
502
  /**
@@ -562,8 +564,8 @@ export interface SubPartExpectations {
562
564
  wall?: Expectation;
563
565
  /**
564
566
  * Unsupported downward-facing surface in mm² (faces steeper than the profile's
565
- * `overhang` angle, bed at the sub-part's own lowest Z). Measured only under
566
- * `verify.orientation: "print"`; a warning, never a gate.
567
+ * `overhang` angle, bed at the sub-part's own lowest Z). Armed by the print
568
+ * process alone; reported as a cost (`verify.costs`), never a failure.
567
569
  */
568
570
  overhangArea?: Expectation;
569
571
  /**
@@ -572,6 +574,13 @@ export interface SubPartExpectations {
572
574
  * Under an FDM profile every printed sub-part gets `>=50` by default; a gate.
573
575
  */
574
576
  bedContactPct?: Expectation;
577
+ /**
578
+ * Share (0–100) of the sub-part's posed base area (its xy extent in the print pose)
579
+ * whose flat underside touches the bed — near 0 for a piece standing on a point, a
580
+ * line or a curl. Under an FDM profile every printed sub-part gets `>=5`; reported as
581
+ * a cost (`verify.costs`), never a failure; `accept: ["footprint"]` records a brim.
582
+ */
583
+ footprintPct?: Expectation;
575
584
  /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
576
585
  refXorVolume?: Expectation;
577
586
  /** Percent volume delta vs. the sub-part's declared `reference` import. */
@@ -640,12 +649,17 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
640
649
  /** A named DFM profile or an inline one. */
641
650
  process?: DfmProfileName | DfmProfile;
642
651
  /**
643
- * `"print"` declares the part is laid out for its bed — Z up, each sub-part's
644
- * bed at its own lowest Z — which is the only way the profile's `overhang`
645
- * check is armed. Omit it while a part is still being shaped or is bound for
646
- * another process.
652
+ * `"print"` declares that `build()` is the print pose — Z up, each sub-part's
653
+ * bed at its own lowest Z. It does not arm any check; the print process does.
647
654
  */
648
655
  orientation?: "print";
656
+ /**
657
+ * Costs the user accepted, by id. An accepted cost is still measured and
658
+ * reported (`accepted: true`), but no longer raised as something to fix. Only the
659
+ * process's own default check is marked accepted: a limit you wrote in `expect`
660
+ * (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
661
+ */
662
+ accept?: ("supports" | "footprint")[];
649
663
  /** Which cases to check; default is `"defaults"` plus every preset name. */
650
664
  cases?: string[];
651
665
  /**
@@ -433,6 +433,14 @@ export interface SubPartFacts {
433
433
  bedHoverGap: number | null;
434
434
  /** Centroid of the largest hovering face; `null` when nothing hovers. */
435
435
  bedHoverAt: number[] | null;
436
+ /**
437
+ * Share (0–100) of the piece's silhouette on the bed (its outline seen from below, in
438
+ * the print pose) that actually touches it — the footprint cost's reading, from the
439
+ * bed-contact pass; `null` when that pass did not run or the silhouette is empty.
440
+ */
441
+ footprintPct: number | null;
442
+ /** The piece's lowest point, in display coordinates; `null` when not checked. */
443
+ footprintAt: number[] | null;
436
444
  /**
437
445
  * Present only when bed contact had to be read off the piece's export build (its
438
446
  * print pose could not be read from place() alone) and that build failed.
@@ -521,6 +529,8 @@ export interface MeasureReport {
521
529
  measuredOverhang: number | null;
522
530
  /** Whether the bed-contact pass ran (the part is checked for it). */
523
531
  measuredBedContact: boolean;
532
+ /** Whether the footprint was read — the same pass, so always `measuredBedContact`. */
533
+ measuredFootprint: boolean;
524
534
  /**
525
535
  * Present only in a view holding a sheet part: whether the printed sub-parts' print
526
536
  * (export) poses were built for `printBbox`.
@@ -595,7 +605,7 @@ export function measure(
595
605
 
596
606
  // --- verify -----------------------------------------------------------------
597
607
 
598
- export type CheckStatus = "pass" | "fail" | "warn" | "skip";
608
+ export type CheckStatus = "pass" | "fail" | "warn" | "cost" | "skip";
599
609
 
600
610
  export interface VerifyCheck {
601
611
  /** `"part"` is the vacuous-verify notice; `"case"` the quick-lap "not measured" marker. */
@@ -603,8 +613,10 @@ export interface VerifyCheck {
603
613
  /** The sub-part name, `"a×b"` for a pair check, or `null` for a scalar view metric. */
604
614
  subpart: string | null;
605
615
  metric: string;
606
- /** `gate` failures set a non-zero exit code; `warn` never blocks. */
607
- kind: "gate" | "warn";
616
+ /** `gate` failures set a non-zero exit code; `warn` never blocks; `cost` is a design choice with a price. */
617
+ kind: "gate" | "warn" | "cost";
618
+ /** On a `cost` check: whether the user listed its id in `verify.accept`. */
619
+ accepted?: boolean;
608
620
  /** The asserted expression, stringified. */
609
621
  expr: string;
610
622
  actual: unknown;
@@ -651,6 +663,8 @@ export interface VerifyReport {
651
663
  /** Every failing check, flattened, each tagged with its `case`. */
652
664
  failures: Array<VerifyCheck & { case: string }>;
653
665
  warnings: Array<VerifyCheck & { case: string }>;
666
+ /** Design choices with a price (supports, footprint); never failures. Accepted ones are flagged. */
667
+ costs: Array<VerifyCheck & { case: string }>;
654
668
  /** Checks a quick lap could not evaluate. */
655
669
  unevaluated: Array<VerifyCheck & { case: string }>;
656
670
  /** Check instances the part or its profile declared, across cases (near-miss notices excluded). */