partforge 0.163.1 → 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.
@@ -1,41 +1,7 @@
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); `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
- export const PROFILES = {
23
- "fdm-pla": {
24
- bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50,
25
- fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
26
- holeUndersize: 0.25, bridge: 10,
27
- },
28
- "fdm-petg": {
29
- bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50,
30
- fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
31
- holeUndersize: 0.3, bridge: 10,
32
- },
33
- "resin": {
34
- bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
35
- fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
36
- holeUndersize: 0.05,
37
- },
38
- };
1
+ // The profiles live on the print process descriptors (process/fdm, process/resin);
2
+ // PROFILES is the registry's merged map, re-exported so every import keeps working.
3
+ import { PRINT_PROFILES as PROFILES } from "../process/registry.js";
4
+ export { PROFILES };
39
5
 
40
6
  export function resolveProfile(spec) {
41
7
  if (typeof spec === "string") {
@@ -62,11 +28,11 @@ export const ORIENTATIONS = ["print"];
62
28
  export const DEFAULT_OVERHANG_ANGLE = 45;
63
29
 
64
30
  // Which overhang angle a part is checked against, or null when it is not checked.
65
- // Two ways in, both explicit — a profile alone never arms it, because the cloud
66
- // agent writes `process: "fdm-pla"` by habit and a part still being shaped should
67
- // not be nagged about its underside:
68
- // - `verify.orientation: "print"` (the part is laid out for its bed) under a
69
- // profile carrying `overhang`; a profile without one (resin) checks nothing;
31
+ // Two ways in:
32
+ // - the print process alone: a profile carrying `overhang` arms it (a profile
33
+ // without one, resin, checks nothing). The reading is a COST, not a warning, so a
34
+ // part still being shaped is told what supports would cost, never nagged;
35
+ // `verify.orientation` no longer takes part in the decision;
70
36
  // - an `overhangArea` expectation the author wrote themselves, in any case's
71
37
  // `expect` (`expanded`, from gates.js's expandExpectations) — measured against
72
38
  // the profile's angle, or DEFAULT_OVERHANG_ANGLE when the profile has none,
@@ -82,10 +48,10 @@ export function overhangAngleFor(part, process, { expanded = [] } = {}) {
82
48
  const spec = process ?? part?.verify?.process;
83
49
  const fromProfile = spec ? resolveProfile(spec).overhang : undefined;
84
50
  const angle = typeof fromProfile === "number" && Number.isFinite(fromProfile) ? fromProfile : null;
85
- if (orientation === "print" && angle != null) return angle;
51
+ if (angle != null) return angle;
86
52
  const asserted = expanded.some(({ expect }) =>
87
53
  Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "overhangArea" in o));
88
- return asserted ? (angle ?? DEFAULT_OVERHANG_ANGLE) : null;
54
+ return asserted ? DEFAULT_OVERHANG_ANGLE : null;
89
55
  }
90
56
 
91
57
  // The `bedContactPct` floor an author's own expectation or `orientation: "print"` arms
@@ -93,12 +59,11 @@ export function overhangAngleFor(part, process, { expanded = [] } = {}) {
93
59
  export const DEFAULT_BED_CONTACT = 50;
94
60
 
95
61
  // The least share of a printed piece's near-bed flat underside that must touch the bed
96
- // (oracle/bed-contact.js), or null when it is not checked. Unlike overhangAngleFor, the
97
- // PROFILE ALONE arms it: an overhang is a design choice a part still being shaped may
98
- // not have made yet, but a flat underside hovering a fraction of a millimetre above the
99
- // piece's lowest point is a defect in whatever orientation the piece is exported —
100
- // every exporter and slicer lays it on that lowest point — so a part carrying an FDM
101
- // profile is checked from its first build. Three ways in:
62
+ // (oracle/bed-contact.js), or null when it is not checked. Like overhangAngleFor the
63
+ // PROFILE ALONE arms it, but as a GATE, not a cost: a flat underside hovering a fraction
64
+ // of a millimetre above the piece's lowest point is a defect in whatever orientation the
65
+ // piece is exported — every exporter and slicer lays it on that lowest point — so a part
66
+ // carrying an FDM profile is checked from its first build. Three ways in:
102
67
  // - a profile carrying `bedContact` (the FDM profiles; resin carries none);
103
68
  // - `verify.orientation: "print"` with NO profile. A named profile decides alone:
104
69
  // resin prints on supports, so orientation must not arm it there (overhangAngleFor's
@@ -118,3 +83,29 @@ export function bedContactFor(part, process, { expanded = [] } = {}) {
118
83
  Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "bedContactPct" in o));
119
84
  return asserted ? DEFAULT_BED_CONTACT : null;
120
85
  }
86
+
87
+ // The `footprintPct` floor an author's own expectation arms the measurement against
88
+ // when the profile names none (a resin part, or no profile at all).
89
+ export const DEFAULT_FOOTPRINT = 10;
90
+
91
+ // The least share of a printed piece's silhouette on the bed that must actually touch
92
+ // it (the footprint, read by bed contact's pass — measure.js `footprintPct`), or null when it
93
+ // is not checked. A COST, like overhang: a piece standing on a point, a line or a curl
94
+ // prints with a brim or a raft, which the user may well choose. Two ways in:
95
+ // - a profile carrying `footprint` (the FDM profiles; resin carries none, since it
96
+ // prints on supports), and `{ base: "fdm-pla", footprint: null }` stays off;
97
+ // - a `footprintPct` expectation the author wrote themselves, in any case's `expect`.
98
+ // `verify.orientation` takes no part (overhangAngleFor's rule), but an invalid value
99
+ // still throws, as it does there.
100
+ export function footprintFor(part, process, { expanded = [] } = {}) {
101
+ const orientation = part?.verify?.orientation;
102
+ if (orientation != null && !ORIENTATIONS.includes(orientation)) {
103
+ throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
104
+ }
105
+ const spec = process ?? part?.verify?.process;
106
+ const fromProfile = spec ? resolveProfile(spec).footprint : undefined;
107
+ if (typeof fromProfile === "number" && Number.isFinite(fromProfile)) return fromProfile;
108
+ const asserted = expanded.some(({ expect }) =>
109
+ Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "footprintPct" in o));
110
+ return asserted ? DEFAULT_FOOTPRINT : null;
111
+ }
@@ -12,7 +12,7 @@
12
12
  // conservative direction, since that is the full-resolution behaviour every part
13
13
  // had before budgets existed. The real error surfaces from verify, which is where
14
14
  // a reader can act on it.
15
- import { resolveProfile, overhangAngleFor, bedContactFor } from "./dfm-profiles.js";
15
+ import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
16
16
  import { expandCases } from "./cases.js";
17
17
  import { resolveParams } from "../part-model.js";
18
18
  import { parseAssertion } from "./assert-dsl.js";
@@ -56,6 +56,12 @@ export function partBedContact(part, { process, expanded } = {}) {
56
56
  try { return bedContactFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
57
57
  }
58
58
 
59
+ // The footprint floor a part is checked against, or null when not checked
60
+ // (dfm-profiles.js footprintFor holds the rule). Total, like partBedContact.
61
+ export function partFootprint(part, { process, expanded } = {}) {
62
+ try { return footprintFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
63
+ }
64
+
59
65
  export function partGatesMinWall(part, { process, expanded } = {}) {
60
66
  try {
61
67
  const spec = process ?? part?.verify?.process;
@@ -14,7 +14,7 @@ import { bounds, meshArea, meshCentroid } from "./mesh.js";
14
14
  import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
15
15
  import { overhang } from "./overhang.js";
16
16
  import { bedContact } from "./bed-contact.js";
17
- import { partBedContact, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
17
+ import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
18
18
  import { summarizeContours } from "./shape-probe.js";
19
19
 
20
20
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -133,15 +133,15 @@ const transformPoint = (m, [x, y, z]) => [
133
133
  // Bed contact read off the piece's real EXPORT build, for a sub-part whose print pose
134
134
  // the probes could not read (a place() that queries its solid): judging the display
135
135
  // mesh there would pass a bad print orientation or fail a good one. One extra build,
136
- // in its own cache round like printPoseBboxes, only for such a piece. `at` is withheld:
137
- // with the pose unreadable there is no honest way back to display coordinates. An
138
- // export pose that does not build answers `{ error }`, which verify reports as
139
- // unevaluated rather than guessing.
136
+ // in its own cache round like printPoseBboxes, only for such a piece. `at` and
137
+ // `lowestAt` are withheld: with the pose unreadable there is no honest way back to
138
+ // display coordinates. An export pose that does not build answers `{ error }`, which
139
+ // verify reports as unevaluated rather than guessing.
140
140
  function exportPoseBedContact(kernel, part, name, view, { p, d }) {
141
141
  kernel.beginSubPart?.(`oracle:print:${view}`);
142
142
  try {
143
143
  const out = bedContact(buildPosed(kernel, part, name, { purpose: "export", view, p, d }).toMesh());
144
- return out ? { ...out, at: null } : out;
144
+ return out ? { ...out, at: null, lowestAt: null } : out;
145
145
  } catch (e) {
146
146
  return { error: String(e?.message || e).slice(0, 200) };
147
147
  } finally { kernel.endSubPart?.(); }
@@ -153,8 +153,9 @@ function exportPoseBedContact(kernel, part, name, view, { p, d }) {
153
153
  // box) is not how it prints: judging its underside there flagged the wrong faces and
154
154
  // missed the real ones. The display mesh is carried into the export pose by a rigid
155
155
  // matrix — no second build, and one copy shared by both readings — and the bed is that
156
- // pose's own lowest Z; each `at` comes back in display coordinates, where every other
157
- // finding of this view sits. An unreadable or identical pose measures as shown.
156
+ // pose's own lowest Z; each `at` (and bed contact's `lowestAt`) comes back in display
157
+ // coordinates, where every other finding of this view sits. An unreadable or identical
158
+ // pose measures as shown.
158
159
  // `m` is displayToPrintMatrix's answer, resolved by the caller: the measure memo
159
160
  // keys on it, since the pose comes from place() and is not part of the solid's hash.
160
161
  function printPoseUnderside(mesh, m, { maxAngle, bed, displayBedZ }) {
@@ -163,7 +164,15 @@ function printPoseUnderside(mesh, m, { maxAngle, bed, displayBedZ }) {
163
164
  posed = { positions: Float32Array.from(mesh.positions), indices: mesh.indices };
164
165
  transformPositions(posed.positions, m);
165
166
  }
166
- const back = (out) => (m && out?.at ? { ...out, at: transformPoint(invertRigid(m), out.at) } : out);
167
+ const back = (out) => {
168
+ if (!m || !out) return out;
169
+ const inv = invertRigid(m);
170
+ return {
171
+ ...out,
172
+ ...(out.at ? { at: transformPoint(inv, out.at) } : {}),
173
+ ...(out.lowestAt ? { lowestAt: transformPoint(inv, out.lowestAt) } : {}),
174
+ };
175
+ };
167
176
  return {
168
177
  oh: maxAngle != null ? back(posed ? overhang(posed, { maxAngle }) : overhang(mesh, { maxAngle, bedZ: displayBedZ })) : null,
169
178
  bc: bed ? back(posed ? bedContact(posed) : bedContact(mesh, { bedZ: displayBedZ })) : null,
@@ -293,9 +302,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
293
302
  // Declared wall bands, per sub-part, for exactly these params (Task 5). Resolved
294
303
  // once per measure; a bad declaration throws here, which is a measure error.
295
304
  const wallBands = opts.minWall ? partWallBands(part, params, reads) : {};
296
- // Overhang is measured only for a part that opted in (dfm-profiles.js
297
- // overhangAngleFor) — everything else reads null. verify hands the angle it
298
- // resolved in (a `process` override changes it); `null` there is an explicit
305
+ // Overhang is measured for a part whose print process arms it (dfm-profiles.js
306
+ // overhangAngleFor: a profile carrying `overhang`, or the author's own
307
+ // `overhangArea` expectation) — everything else reads null. verify hands the angle
308
+ // it resolved in (a `process` override changes it); `null` there is an explicit
299
309
  // "not checked", not a fallback. The angle used is stamped on the result as
300
310
  // `measuredOverhang`, and verify refuses a seed whose stamp disagrees with the
301
311
  // angle it needs — the min-wall superset rule's counterpart, since a reading
@@ -304,8 +314,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
304
314
  // Bed contact is measured for a part whose profile (or orientation, or its own
305
315
  // expectation) arms it — dfm-profiles.js bedContactFor. Unlike overhang the reading
306
316
  // does not depend on the threshold, so the stamp is a boolean: `measuredBedContact`.
307
- // verify passes its own answer (a `process` override can arm or disarm it).
308
- const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : partBedContact(part)) != null;
317
+ // verify passes its own answer (a `process` override can arm or disarm it). The same
318
+ // pass reads the footprint cost (dfm-profiles.js footprintFor), so a part whose
319
+ // footprint alone is checked arms it too — here and in verify — and one stamp, and
320
+ // one memo-key bit, covers both readings.
321
+ const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
309
322
  const underside = overhangAngle != null || bedArmed;
310
323
  const subBounds = [];
311
324
  // Sheet parts: a view holding one sizes each printed sub-part in its print pose
@@ -341,7 +354,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
341
354
  const printMatrix = printPose?.matrix ?? null;
342
355
  // A pose the probes could not read is not the identity: bed contact then reads the
343
356
  // export build itself (exportPoseBedContact). Overhang keeps its display-pose
344
- // fallback — a warning, and unchanged here.
357
+ // fallback — a cost, and unchanged here.
345
358
  const exportBed = bedArmed && printPose && !printPose.trusted;
346
359
  // Memo key = the inputs this sub-part's facts actually depend on, besides its
347
360
  // own geometry hash: whether min-wall ran, at what sample budget and against
@@ -447,6 +460,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
447
460
  bedHoverArea: bc ? bc.hoverArea : null,
448
461
  bedHoverGap: bc?.gap ?? null,
449
462
  bedHoverAt: bc?.at ?? null,
463
+ // The footprint (same pass): the share of the piece's silhouette on the bed that
464
+ // actually touches it, in percent (clamped at 100: the silhouette is a
465
+ // raster, a cell or so off along its edges) — near 0 for a
466
+ // piece standing on a point, a line or a curl, which bedContactPct passes since
467
+ // nothing flat hovers, and near 100 for anything that stands on its whole
468
+ // underside, however little of its bounding box that fills (a tube on its rim, a
469
+ // frame, a diagonal bar). Null when not measured or the silhouette is empty. Located at
470
+ // the piece's lowest point, in display coordinates.
471
+ footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
472
+ footprintAt: bc?.lowestAt ?? null,
450
473
  ...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
451
474
  // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
452
475
  sheet: sheet
@@ -537,6 +560,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
537
560
  measuredOverhang: overhangAngle ?? null,
538
561
  // Whether the bed-contact pass ran — the same kind of stamp, read by the same gate.
539
562
  measuredBedContact: bedArmed,
563
+ // The footprint is read by the same pass, so the same stamp.
564
+ measuredFootprint: bedArmed,
540
565
  // Companion stamp to measuredMinWall, and read the same way: whether the pass
541
566
  // ran, said by the pass itself rather than claimed by whoever holds the result.
542
567
  measuredGaps,
@@ -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