partforge 0.162.0 → 0.163.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.
@@ -4193,6 +4193,12 @@ A part that opted into the overhang check (see the `verify` block) also carries
4193
4193
  checked or on an `exportable: false` sub-part), `overhangAngle` (the steepest such
4194
4194
  face, degrees from vertical) and `overhangAt` (that face's centroid); the report's
4195
4195
  `measuredOverhang` stamps the angle the pass ran against, or `null`.
4196
+ A part checked for bed contact (an FDM profile, see the `verify` block) carries
4197
+ `bedContactPct` (0–100, the share of the near-bed flat underside on the bed; `null`
4198
+ when not checked or on an `exportable: false` sub-part), `bedContactArea` and
4199
+ `bedHoverArea` (mm² touching and hovering), `bedHoverGap` (how far above the bed the
4200
+ largest hovering face sits, `null` when nothing hovers) and `bedHoverAt` (its
4201
+ centroid); the report's `measuredBedContact` says whether the pass ran.
4196
4202
  A sub-part that declares `wall` (see the `verify` section below) also carries
4197
4203
  `wall` — `{ value, location, band, members }` or `null`: the declared band's
4198
4204
  worst member, located, with the band it was measured against and how many rays
@@ -4297,7 +4303,7 @@ A part can declare how it should be checked, co-located with its schema, so
4297
4303
  ```js
4298
4304
  verify: {
4299
4305
  process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
4300
- // inline { base, bed:[x,y,z], minWall, overhang, fits, holeUndersize, bridge } object
4306
+ // inline { base, bed:[x,y,z], minWall, overhang, bedContact, fits, holeUndersize, bridge } object
4301
4307
  orientation: "print", // optional; ONLY with this is overhang checked — it says the
4302
4308
  // part is laid out for its bed (Z up, bed at the lowest Z)
4303
4309
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
@@ -4335,6 +4341,40 @@ flat beside its base) is measured as displayed, and `exportable: false` sub-part
4335
4341
  are skipped. Switch it off under an FDM profile with an inline
4336
4342
  `{ base: "fdm-pla", overhang: null }`.
4337
4343
 
4344
+ **Bed contact — a GATE, armed by the FDM profile alone.** Every exporter and slicer
4345
+ lays a printed piece on its lowest point. When that point is something small — a
4346
+ horizontal knuckle or pin whose cylinder dips under the plate, lettering embossed on
4347
+ the underside, a boss — the piece rests on it and its big
4348
+ flat underside prints a fraction of a millimetre in mid-air: the first layer goes
4349
+ down onto nothing, strings, and the print fails or leaves a ruined bottom. So every
4350
+ printed sub-part of a part under `fdm-pla` or `fdm-petg` is checked in its print
4351
+ (export) pose: `bedContactPct` is the share of its near-bed FLAT underside (faces
4352
+ within 2° of level, within 1 mm of its lowest point — the band the overhang check
4353
+ leaves alone) that actually touches the bed; a face hovers only when it is level (within
4354
+ 0.1°, as a plane you made horizontal is) and every one of its corners is clear of the
4355
+ bed, so a cylinder, sphere or dome resting on the bed is never read as hovering, and it must be at least
4356
+ the profile's `bedContact` (50%). A plate flat on the bed reads 100; a lever resting
4357
+ on a knuckle 0.25 mm lower reads about 1. Slanted faces — the curl of a bottom fillet
4358
+ or chamfer, the sides of a cylinder — never count, and hovering area under 1 mm² is
4359
+ read as noise. A flat bottom tilted off level is not caught (it is not level, the same
4360
+ signature as a curved surface's lowest facets). The failure is located on the largest hovering face and
4361
+ its note says how far up it sits and how much area hovers versus touches. The fix is
4362
+ almost always to trim the piece flat at the bed plane — cut away everything below
4363
+ z = 0 in its print pose — or to raise or shorten the lower feature so the flat face
4364
+ is the lowest; size a print-in-place knuckle so its cylinder is tangent to the bed
4365
+ (diameter = 2 × its centre height) and give it a support block down to z = 0. Unlike
4366
+ overhang, no orientation key is needed: an overhang is a design choice a part still
4367
+ being shaped may not have made, but a hovering underside is a defect in whatever
4368
+ orientation the piece exports. Resin prints on supports, so `resin` never checks it
4369
+ unless you ask: your own `bedContactPct` expectation arms it under any profile, and
4370
+ `orientation: "print"` arms it on a part with no profile at all (both against 50%). A
4371
+ named profile otherwise decides alone. A recess meant to clear the
4372
+ bed — a shallow pocket inside a foot ring — hovers by design and is reported; such a
4373
+ pocket is a long bridge laid in the first layers and rarely prints well, so the
4374
+ default stands, and a part that means it declares a lower `bedContactPct` (or
4375
+ `{ base: "fdm-pla", bedContact: null }` to switch it off). `exportable: false`
4376
+ sub-parts are not judged.
4377
+
4338
4378
  **What the profile tells you.** Three more fields carry the numbers a mating
4339
4379
  feature needs. Nothing checks them; they are the one place the value is spelled,
4340
4380
  so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
@@ -4359,7 +4399,7 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
4359
4399
  **What `expect` gives you:** per-sub-part
4360
4400
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4361
4401
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4362
- `overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4402
+ `overhangArea`, `bedContactPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4363
4403
  positions, written in the same `{ min, max }` shape `measure` reports them — where
4364
4404
  the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4365
4405
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
@@ -4405,7 +4445,8 @@ verify: { expect: {
4405
4445
  **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
4406
4446
  `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
4407
4447
  **warning** — it flags walls below the profile's minimum but never fails the build —
4408
- and so is `overhangArea` (see above). `holes`/`watertight` are Manifold-only, so those
4448
+ and so is `overhangArea` (see above). `bedContactPct` is a **gate** even though the
4449
+ profile arms it (see above). `holes`/`watertight` are Manifold-only, so those
4409
4450
  assertions **skip** on OCCT parts rather than fail.
4410
4451
 
4411
4452
  **A wall that must stay one thickness: `wall`.** `minWall` answers "is anything too
@@ -204,6 +204,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
204
204
  - **Cause:** The piece is placed by eye rather than against a mating datum — a lid a hair above its box, a bracket beside the wall it should bolt to, a pin whose bore it was never moved into — or it is held only by a reference ghost (`exportable: false`), which is never printed. On OCCT a sub-part fully inside another reads as not touching, since overlap detection is Manifold-only.
205
205
  - **Fix:** Seat the part on a mating face by deriving its position from the face it sits on in `derive()`, or add the fastener, boss or tab that holds it, then keep `verify.expect._view.held` so the next edit re-checks it. If the piece is meant to run free in a clearance bore (a hinge pin, a print-in-place link), the clearance is correct and `held` is the wrong check for that assembly — remove `held` and declare the pair under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
206
206
 
207
+ ## piece-hovers-above-bed
208
+
209
+ - **Symptom:** A `✗ … bedContactPct` failure from `verify` (e.g. `1.28 not >= 50`) with the note `… mm² of flat underside sits 0.25 mm above the bed`; or, in a slicer, a piece whose flat bottom is not on the bed and whose first layers string or spaghetti.
210
+ - **Cause:** Something smaller than the piece's flat underside sits lower than it in the print pose — a horizontal knuckle or pin whose cylinder dips below the plate, a boss, or lettering embossed on the bottom — so exporters and slicers rest the piece on that feature and the flat face prints in mid-air.
211
+ - **Fix:** Trim the piece flat at the bed plane (cut everything below z = 0 in its print pose), or raise or shorten the lower feature so the flat face is the lowest; size a print-in-place knuckle so its cylinder is tangent to the bed and give it a support block down to z = 0. A recess meant to clear the bed declares a lower `bedContactPct`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
212
+
207
213
  ## expect-static-across-presets
208
214
 
209
215
  - **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
@@ -1023,8 +1023,9 @@ The recurring constraint: every op here is implementable on **both** a mesh-CSG
1023
1023
  and a B-rep kernel (see `docs/geometry-backend-strategy.md` for why that dual-backend
1024
1024
  property is worth protecting — OCCT booleans are ~75–1400× slower). Generation *safety*
1025
1025
  comes not from a restricted DSL but from the verify loop (`measure`/`verify` gates:
1026
- `bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall` and `overhangArea`
1027
- *warnings*, which report but never fail) — a generator gets machine-checkable
1026
+ `bbox`, `volume`, `holes`, `watertight`, overlaps and, under an FDM profile,
1027
+ `bedContactPct` — plus `minWall` and `overhangArea` *warnings*, which report but never
1028
+ fail) — a generator gets machine-checkable
1028
1029
  pass/fail feedback per part, which a syntax could never provide.
1029
1030
 
1030
1031
  ## Conformance checklist for a new backend or host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.162.0",
3
+ "version": "0.163.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,12 +34,26 @@ const isIdentity = (m) => m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-12);
34
34
  // probes first: both poses are place(build), so E · D⁻¹ needs only place() to be
35
35
  // readable, and a build that queries its solid cannot make it untrusted. A place()
36
36
  // that only the full-scope probe reads falls back to printFrameMatrix's posed branch.
37
- export function displayToPrintMatrix(subPart, { view, p, d }) {
38
- if (!placeOf(subPart)) return null;
37
+ export function displayToPrintMatrix(subPart, opts) {
38
+ return displayToPrintPose(subPart, opts).matrix;
39
+ }
40
+
41
+ // displayToPrintMatrix with the one thing its null cannot say: whether the poses were
42
+ // READ. `{ matrix, trusted }` — `matrix` as above, `trusted` false when neither probe
43
+ // scope could read them (a place() that queries its solid), in which case `matrix` is
44
+ // null for want of an answer, not because the piece prints as shown. The bed-contact
45
+ // gate reads the difference: it must not judge a display mesh it could not pose.
46
+ export function displayToPrintPose(subPart, { view, p, d }) {
47
+ if (!placeOf(subPart)) return { matrix: null, trusted: true };
39
48
  const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d }, { scope: "place" });
40
49
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
41
- const m = disp.trusted && exp.trusted
42
- ? poseDelta(exp.pose, disp.pose)
43
- : printFrameMatrix(subPart, { view, p, d, frame: "posed" });
44
- return isIdentity(m) ? null : m;
50
+ if (disp.trusted && exp.trusted) {
51
+ const m = poseDelta(exp.pose, disp.pose);
52
+ return { matrix: isIdentity(m) ? null : m, trusted: true };
53
+ }
54
+ const fd = probeSubPartPose(subPart, { view, purpose: "display", p, d });
55
+ const fe = probeSubPartPose(subPart, { view, purpose: "export", p, d });
56
+ if (!fd.trusted || !fe.trusted || fd.baseHash !== fe.baseHash) return { matrix: null, trusted: false };
57
+ const m = poseDelta(fe.pose, fd.pose);
58
+ return { matrix: isIdentity(m) ? null : m, trusted: true };
45
59
  }
@@ -0,0 +1,127 @@
1
+ // src/framework/oracle/bed-contact.js
2
+ // How much of a printed piece's flat underside actually rests on the bed.
3
+ //
4
+ // The failure this exists for: a piece whose big flat bottom sits a fraction of a
5
+ // millimetre ABOVE its lowest point, because something smaller pokes lower — a
6
+ // horizontal knuckle or pin whose cylinder dips under the plate, lettering embossed
7
+ // on the underside, a boss. Every exporter and slicer
8
+ // lays a piece on its lowest point, so that small feature becomes the only thing on
9
+ // the bed and the flat face prints in mid-air: the first layer is laid onto nothing,
10
+ // strings and spaghettis, and what does print has a ruined bottom surface. Seen on a
11
+ // real forge (a print-in-place toggle latch whose lever knuckle sat lower than the
12
+ // lever body): the flat underside hovered 0.1-0.3 mm and the print failed. Every
13
+ // other check passed it — overhang.js deliberately skips the first millimetre above
14
+ // the bed (the near-bed band), so a hovering face is exactly what it cannot see.
15
+ //
16
+ // One pass over the triangles, pure, no BVH — the same cost class as overhang.js and
17
+ // run beside it. Two areas, both of downward-facing FLAT faces (normal within FLAT_TOL
18
+ // of straight down — so the lower curl of a bottom fillet or chamfer, and a cylinder's
19
+ // side, count as neither):
20
+ // - contact: the footprint, flat faces with every vertex within `bedEps` of the bed. The
21
+ // slack starts as overhang.js's (0.1% of the mesh height, floored at 10 µm — boolean
22
+ // noise a few microns low must not lift a whole face off the bed) but is CAPPED at
23
+ // MAX_BED_EPS: overhang can let it grow with the part because it only excludes the
24
+ // footprint, whereas here it decides what counts as touching, and a 200 mm tall piece
25
+ // would otherwise call a face 0.15 mm up "on the bed" — the very gap this exists for;
26
+ // - hover: LEVEL faces (within LEVEL_TOL, a tenth of a degree) with EVERY vertex
27
+ // above the footprint slack and the centroid within `bedBand` (1 mm, overhang.js's
28
+ // near-bed band) of the bed — a face floating clear of the bed, as a plate over a
29
+ // lower knuckle or emboss is. Above the band a face is a ceiling, which is
30
+ // overhang.js's to judge. The two extra conditions are what keep a curved surface
31
+ // resting on the bed (a cylinder on its side, a sphere, a dome) from reading as
32
+ // hovering, since its lowest facets are near level too. A face that hovers because
33
+ // of a lower feature is a plane the author made horizontal, level to float
34
+ // precision; a curved surface's facet is level only where it contains the lowest
35
+ // point — where it touches the bed — and every facet clear of the bed is tilted by
36
+ // about one tessellation step or more (3.1° at the preview's 116 segments, 0.75° at
37
+ // the print tier's 480, geometry/circle-segs.js): a Ø120 cylinder's facets beside
38
+ // its lowest line are 1.55° off and touch the bed there, and a sphere's second ring
39
+ // around its pole is clear of the bed but just as tilted. Every vertex, besides,
40
+ // so a facet that does reach the bed is never hover, however it is tilted.
41
+ // `pct` is the share of that near-bed flat underside which touches: 100 when nothing
42
+ // hovers, near 0 for a plate resting on a knuckle. A hover total under HOVER_FLOOR is
43
+ // noise and reads as 100.
44
+ //
45
+ // Known limit, stated rather than hidden: a recess meant to clear the bed (a foot ring
46
+ // with a shallow pocket inside it) hovers by design, and is reported. A pocket that
47
+ // shallow is a long bridge laid in the first layers and rarely prints well, so the
48
+ // default threshold stands; a part that means it lowers its own `bedContactPct`. And
49
+ // a flat bottom TILTED off level (one edge on the bed, the rest rising) is not caught:
50
+ // it is not level, which is exactly the signature of curvature above, and telling the
51
+ // two apart would need the mesh's connectivity.
52
+ //
53
+ // Works on both mesh forms the oracle sees (Manifold's soup, OCCT's indexed vertices).
54
+
55
+ // A face counts as flat (contact) when its normal is within FLAT_TOL degrees of straight
56
+ // down, and as level (hover) within LEVEL_TOL. LEVEL_TOL is far above float32 normal
57
+ // noise (~1e-4°) and far below any tessellation step a curved surface is made of (see
58
+ // the hover rule above).
59
+ const FLAT_TOL = 2;
60
+ const FLAT_COS = Math.cos((FLAT_TOL * Math.PI) / 180);
61
+ const LEVEL_TOL = 0.1;
62
+ const LEVEL_COS = Math.cos((LEVEL_TOL * Math.PI) / 180);
63
+ // Hovering area below this, in mm², is noise and never reported.
64
+ export const HOVER_FLOOR = 1;
65
+ // The footprint slack never exceeds this (mm): well under a first layer, and under the
66
+ // smallest hover worth reporting, so a tall piece is judged the way a short one is.
67
+ export const MAX_BED_EPS = 0.05;
68
+
69
+ /**
70
+ * @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, triangles: number }} mesh
71
+ * @param {{ bedZ?: number, bedEps?: number, bedBand?: number }} [opts]
72
+ * `bedZ` the bed height if the caller already knows the mesh's lowest Z (else
73
+ * scanned); `bedEps` the footprint slack (default 0.1% of the mesh height, floored at
74
+ * 0.01 mm and capped at MAX_BED_EPS); `bedBand` the height above the bed within which a flat face hovers
75
+ * (default 1 mm)
76
+ * @returns {{ pct: number, contactArea: number, hoverArea: number, gap: number|null, at: number[]|null }|null}
77
+ * `pct` 0..100, the share of the near-bed flat underside on the bed; the two areas in
78
+ * mm²; `gap` how far above the bed the largest hovering face sits, and `at` its
79
+ * centroid (null when nothing hovers past the floor); null for an empty mesh.
80
+ */
81
+ export function bedContact(mesh, { bedZ, bedEps, bedBand = 1 } = {}) {
82
+ const { positions, indices } = mesh;
83
+ const n = indices ? indices.length : positions.length / 3;
84
+ if (n < 3) return null;
85
+
86
+ let minZ = Infinity, maxZ = -Infinity;
87
+ if (bedZ === undefined || bedEps === undefined) {
88
+ for (let i = 2; i < positions.length; i += 3) {
89
+ const z = positions[i];
90
+ if (z < minZ) minZ = z;
91
+ if (z > maxZ) maxZ = z;
92
+ }
93
+ }
94
+ const bed = bedZ ?? minZ;
95
+ const eps = bedEps ?? Math.min(MAX_BED_EPS, Math.max(0.01, 1e-3 * (maxZ - minZ)));
96
+ const bandTop = bed + bedBand;
97
+
98
+ let contactArea = 0, hoverArea = 0, largest = 0, gap = null, at = null;
99
+ for (let i = 0; i < n; i += 3) {
100
+ const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
101
+ const az = positions[a + 2], bz = positions[b + 2], cz = positions[c + 2];
102
+ const cenZ = (az + bz + cz) / 3;
103
+ if (cenZ > bandTop) continue; // above the band: not the underside
104
+ const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = bz - az;
105
+ const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = cz - az;
106
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
107
+ if (nz >= 0) continue; // not facing down
108
+ const len = Math.hypot(nx, ny, nz);
109
+ if (len < 1e-9) continue; // degenerate: no normal
110
+ const triArea = len / 2;
111
+ if (-nz / len < FLAT_COS) continue; // slanted: a fillet curl, a cylinder's side
112
+ const lowest = Math.min(az, bz, cz) - bed, highest = Math.max(az, bz, cz) - bed;
113
+ if (highest <= eps) { contactArea += triArea; continue; } // the footprint
114
+ if (lowest <= eps) continue; // touching at an edge or vertex: neither
115
+ if (-nz / len < LEVEL_COS) continue; // near level but tilted: a curved surface's facet
116
+ hoverArea += triArea;
117
+ if (triArea > largest) {
118
+ largest = triArea;
119
+ gap = cenZ - bed;
120
+ at = [(positions[a] + positions[b] + positions[c]) / 3,
121
+ (positions[a + 1] + positions[b + 1] + positions[c + 1]) / 3,
122
+ cenZ];
123
+ }
124
+ }
125
+ if (hoverArea < HOVER_FLOOR) return { pct: 100, contactArea, hoverArea, gap: null, at: null };
126
+ return { pct: (100 * contactArea) / (contactArea + hoverArea), contactArea, hoverArea, gap, at };
127
+ }
@@ -3,8 +3,10 @@
3
3
  // [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `overhang` is the
4
4
  // steepest unsupported face the process prints cleanly, in degrees from vertical
5
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).
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).
8
10
  //
9
11
  // `fits`, `holeUndersize` and `bridge` are the numbers an author (or the cloud
10
12
  // agent) copies into derive() for a mating feature; nothing in this repo reads
@@ -19,12 +21,12 @@
19
21
  // on supports, so it carries none.
20
22
  export const PROFILES = {
21
23
  "fdm-pla": {
22
- bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45,
24
+ bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50,
23
25
  fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
24
26
  holeUndersize: 0.25, bridge: 10,
25
27
  },
26
28
  "fdm-petg": {
27
- bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45,
29
+ bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50,
28
30
  fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
29
31
  holeUndersize: 0.3, bridge: 10,
30
32
  },
@@ -85,3 +87,34 @@ export function overhangAngleFor(part, process, { expanded = [] } = {}) {
85
87
  Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "overhangArea" in o));
86
88
  return asserted ? (angle ?? DEFAULT_OVERHANG_ANGLE) : null;
87
89
  }
90
+
91
+ // The `bedContactPct` floor an author's own expectation or `orientation: "print"` arms
92
+ // the measurement against when the profile names none.
93
+ export const DEFAULT_BED_CONTACT = 50;
94
+
95
+ // 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:
102
+ // - a profile carrying `bedContact` (the FDM profiles; resin carries none);
103
+ // - `verify.orientation: "print"` with NO profile. A named profile decides alone:
104
+ // resin prints on supports, so orientation must not arm it there (overhangAngleFor's
105
+ // rule), and `{ base: "fdm-pla", bedContact: null }` must stay off;
106
+ // - a `bedContactPct` expectation the author wrote themselves, in any case's `expect`.
107
+ // Throws on an orientation value outside ORIENTATIONS, as overhangAngleFor does.
108
+ export function bedContactFor(part, process, { expanded = [] } = {}) {
109
+ const orientation = part?.verify?.orientation;
110
+ if (orientation != null && !ORIENTATIONS.includes(orientation)) {
111
+ throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
112
+ }
113
+ const spec = process ?? part?.verify?.process;
114
+ const fromProfile = spec ? resolveProfile(spec).bedContact : undefined;
115
+ if (typeof fromProfile === "number" && Number.isFinite(fromProfile)) return fromProfile;
116
+ if (orientation === "print" && !spec) return DEFAULT_BED_CONTACT;
117
+ const asserted = expanded.some(({ expect }) =>
118
+ Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "bedContactPct" in o));
119
+ return asserted ? DEFAULT_BED_CONTACT : null;
120
+ }
@@ -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 } from "./dfm-profiles.js";
15
+ import { resolveProfile, overhangAngleFor, bedContactFor } 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";
@@ -50,6 +50,12 @@ export function partOverhangAngle(part, { process, expanded } = {}) {
50
50
  try { return overhangAngleFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
51
51
  }
52
52
 
53
+ // The bed-contact floor a part is checked against, or null when it is not checked
54
+ // (dfm-profiles.js bedContactFor holds the rule). Total, like partOverhangAngle.
55
+ export function partBedContact(part, { process, expanded } = {}) {
56
+ try { return bedContactFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
57
+ }
58
+
53
59
  export function partGatesMinWall(part, { process, expanded } = {}) {
54
60
  try {
55
61
  const spec = process ?? part?.verify?.process;
@@ -5,7 +5,7 @@ import { resolveParams, buildPosed } from "../part-model.js";
5
5
  import { newReadSink, expandReads } from "../read-recorder.js";
6
6
  import { probeSubPartPose } from "../pose-probe-core.js";
7
7
  import { composePose, invertRigid, transformPositions } from "../geometry/pose.js";
8
- import { displayToPrintMatrix } from "../materials/print-frame.js";
8
+ import { displayToPrintPose } from "../materials/print-frame.js";
9
9
  import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
10
10
  import { resolveSheet } from "../sheet/resolve.js";
11
11
  import { processFor } from "../process/registry.js";
@@ -13,7 +13,8 @@ import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
13
13
  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
- import { partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
16
+ import { bedContact } from "./bed-contact.js";
17
+ import { partBedContact, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
17
18
  import { summarizeContours } from "./shape-probe.js";
18
19
 
19
20
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -129,20 +130,44 @@ const transformPoint = (m, [x, y, z]) => [
129
130
  m[2] * x + m[6] * y + m[10] * z + m[14],
130
131
  ];
131
132
 
132
- // Overhang of one printed sub-part in its PRINT pose. measure builds the display pose,
133
- // and a piece shown assembled (a lid closed over its box) is not how it prints: judging
134
- // its underside there flagged the wrong faces and missed the real ones. The display mesh
135
- // is carried into the export pose by a rigid matrix — no second build — and the bed is
136
- // that pose's own lowest Z; `at` comes back in display coordinates, where every other
133
+ // Bed contact read off the piece's real EXPORT build, for a sub-part whose print pose
134
+ // the probes could not read (a place() that queries its solid): judging the display
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.
140
+ function exportPoseBedContact(kernel, part, name, view, { p, d }) {
141
+ kernel.beginSubPart?.(`oracle:print:${view}`);
142
+ try {
143
+ const out = bedContact(buildPosed(kernel, part, name, { purpose: "export", view, p, d }).toMesh());
144
+ return out ? { ...out, at: null } : out;
145
+ } catch (e) {
146
+ return { error: String(e?.message || e).slice(0, 200) };
147
+ } finally { kernel.endSubPart?.(); }
148
+ }
149
+
150
+ // The underside readings of one printed sub-part in its PRINT pose — overhang
151
+ // (overhang.js) when `maxAngle` is set, bed contact (bed-contact.js) when `bed` is.
152
+ // measure builds the display pose, and a piece shown assembled (a lid closed over its
153
+ // box) is not how it prints: judging its underside there flagged the wrong faces and
154
+ // missed the real ones. The display mesh is carried into the export pose by a rigid
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
137
157
  // finding of this view sits. An unreadable or identical pose measures as shown.
138
158
  // `m` is displayToPrintMatrix's answer, resolved by the caller: the measure memo
139
159
  // keys on it, since the pose comes from place() and is not part of the solid's hash.
140
- function printPoseOverhang(mesh, m, { maxAngle, displayBedZ }) {
141
- if (!m) return overhang(mesh, { maxAngle, bedZ: displayBedZ });
142
- const positions = Float32Array.from(mesh.positions);
143
- transformPositions(positions, m);
144
- const out = overhang({ positions, indices: mesh.indices }, { maxAngle });
145
- return out?.at ? { ...out, at: transformPoint(invertRigid(m), out.at) } : out;
160
+ function printPoseUnderside(mesh, m, { maxAngle, bed, displayBedZ }) {
161
+ let posed = null;
162
+ if (m) {
163
+ posed = { positions: Float32Array.from(mesh.positions), indices: mesh.indices };
164
+ transformPositions(posed.positions, m);
165
+ }
166
+ const back = (out) => (m && out?.at ? { ...out, at: transformPoint(invertRigid(m), out.at) } : out);
167
+ return {
168
+ oh: maxAngle != null ? back(posed ? overhang(posed, { maxAngle }) : overhang(mesh, { maxAngle, bedZ: displayBedZ })) : null,
169
+ bc: bed ? back(posed ? bedContact(posed) : bedContact(mesh, { bedZ: displayBedZ })) : null,
170
+ };
146
171
  }
147
172
 
148
173
  // One sheet row's facts, within `budget.left` ms of 2-D work, which it spends (see
@@ -276,6 +301,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
276
301
  // angle it needs — the min-wall superset rule's counterpart, since a reading
277
302
  // taken against the wrong threshold is worse than none.
278
303
  const overhangAngle = opts.overhang !== undefined ? opts.overhang : partOverhangAngle(part);
304
+ // Bed contact is measured for a part whose profile (or orientation, or its own
305
+ // expectation) arms it — dfm-profiles.js bedContactFor. Unlike overhang the reading
306
+ // 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;
309
+ const underside = overhangAngle != null || bedArmed;
279
310
  const subBounds = [];
280
311
  // Sheet parts: a view holding one sizes each printed sub-part in its print pose
281
312
  // (verify fits the bed per part there), and every sheet row gets its 2-D facts under
@@ -288,7 +319,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
288
319
  // The overhang reading poses each piece for print (printPoseOverhang), which reads the
289
320
  // resolved params through place() — recorded into the same sink, since the reading
290
321
  // depends on them.
291
- const poseParams = overhangAngle != null ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
322
+ const poseParams = underside ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
292
323
  // The print-pose sizes are read by one check, the process bed (verify passes whether
293
324
  // its profile has one; alone, measure asks the part's own profile), and each costs a
294
325
  // second build of the part — so they are built only for a bed.
@@ -304,9 +335,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
304
335
  // (printPoseOverhang) is read off place() with the resolved params — geometry-free,
305
336
  // so it is resolved here, ahead of the memo, which must key on it.
306
337
  const printed = part.parts[name]?.exportable !== false && !sheet;
307
- const printMatrix = overhangAngle != null && printed && poseParams?.p
308
- ? displayToPrintMatrix(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
338
+ const printPose = underside && printed && poseParams?.p
339
+ ? displayToPrintPose(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
309
340
  : null;
341
+ const printMatrix = printPose?.matrix ?? null;
342
+ // A pose the probes could not read is not the identity: bed contact then reads the
343
+ // export build itself (exportPoseBedContact). Overhang keeps its display-pose
344
+ // fallback — a warning, and unchanged here.
345
+ const exportBed = bedArmed && printPose && !printPose.trusted;
310
346
  // Memo key = the inputs this sub-part's facts actually depend on, besides its
311
347
  // own geometry hash: whether min-wall ran, at what sample budget and against
312
348
  // which declared band, the overhang angle, and whether the sub-part is
@@ -320,10 +356,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
320
356
  // holds a sheet part (the budgeted 2-D pass and print-pose sizes are
321
357
  // call-scoped, not per-sub-part cacheable), and a solid with no `_hash`
322
358
  // (nothing to key reuse on).
323
- const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash
359
+ const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash && !exportBed
324
360
  ? JSON.stringify({
325
361
  mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
326
- oh: overhangAngle ?? null, ex: printed,
362
+ oh: overhangAngle ?? null, bc: bedArmed, ex: printed,
327
363
  pm: printMatrix ? Array.from(printMatrix) : null,
328
364
  })
329
365
  : null;
@@ -342,12 +378,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
342
378
  const mw = opts.minWall && !sheet
343
379
  ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
344
380
  : null;
345
- // One pass over the triangles, no index — cheap enough for every lap. The bed
346
- // is this sub-part's own lowest Z in its PRINT pose (printPoseOverhang); `printed`
381
+ // One pass over the triangles each, no index — cheap enough for every lap. The bed
382
+ // is this sub-part's own lowest Z in its PRINT pose (printPoseUnderside); `printed`
347
383
  // (above) says whether it is judged at all.
348
- const oh = overhangAngle != null && printed
349
- ? printPoseOverhang(mesh, printMatrix, { maxAngle: overhangAngle, displayBedZ: b.min[2] })
350
- : null;
384
+ const { oh, bc: posedBc } = underside && printed
385
+ ? printPoseUnderside(mesh, printMatrix, { maxAngle: overhangAngle, bed: bedArmed && !exportBed, displayBedZ: b.min[2] })
386
+ : { oh: null, bc: null };
387
+ const exportBc = exportBed ? exportPoseBedContact(kernel, part, name, view, poseParams) : null;
388
+ const bc = exportBc?.error ? null : (exportBc ?? posedBc);
351
389
  const vol = solid.volume();
352
390
  // Deviation-from-reference: only for a sub-part that declares `reference:
353
391
  // "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
@@ -400,6 +438,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
400
438
  overhangArea: oh ? oh.area : null,
401
439
  overhangAngle: oh?.worstAngle ?? null,
402
440
  overhangAt: oh?.at ?? null,
441
+ // How much of the near-bed flat underside rests on the bed (bed-contact.js), in
442
+ // percent, null when the part does not check it: a piece whose flat bottom
443
+ // hovers above a lower knuckle, pin or emboss reads near 0. The two areas (mm²),
444
+ // and how far up the largest hovering face sits and where (display coordinates).
445
+ bedContactPct: bc ? bc.pct : null,
446
+ bedContactArea: bc ? bc.contactArea : null,
447
+ bedHoverArea: bc ? bc.hoverArea : null,
448
+ bedHoverGap: bc?.gap ?? null,
449
+ bedHoverAt: bc?.at ?? null,
450
+ ...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
403
451
  // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
404
452
  sheet: sheet
405
453
  ? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
@@ -487,6 +535,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
487
535
  // or null when the pass did not run — read by verify's seed gate, never a
488
536
  // caller's claim.
489
537
  measuredOverhang: overhangAngle ?? null,
538
+ // Whether the bed-contact pass ran — the same kind of stamp, read by the same gate.
539
+ measuredBedContact: bedArmed,
490
540
  // Companion stamp to measuredMinWall, and read the same way: whether the pass
491
541
  // ran, said by the pass itself rather than claimed by whoever holds the result.
492
542
  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 } from "./dfm-profiles.js";
5
+ import { resolveProfile, overhangAngleFor, bedContactFor } 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";
@@ -214,8 +214,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
214
214
  // The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
215
215
  // Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
216
216
  // that declares only these has no verdict rather than a vacuous pass.
217
- const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea"]);
218
- const PRINT_ONLY_HINT = "minWall, wall and overhangArea 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.";
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.";
219
219
 
220
220
  // A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
221
221
  // volunteered check warns with the reason; a declared one warns with it too, and is
@@ -271,7 +271,9 @@ function sheetRowChecks(s, declaredExp) {
271
271
  // `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
272
272
  // or null/undefined: only then does the profile's overhang rule apply, as a
273
273
  // 1 mm² warning floor — a chamfer sitting exactly on the angle sheds slivers.
274
- export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null }) {
274
+ // `bedContact` is the floor the part is checked against (dfm-profiles.js bedContactFor),
275
+ // 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 }) {
275
277
  const checks = [];
276
278
  // contacts/clearance/held are pair-derived, not scalar view metrics — peel
277
279
  // them off before the registry loop. held.js explains the exportable bound.
@@ -298,6 +300,10 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
298
300
  // without the pass (a quick lap reusing a seed that had no angle), so the check
299
301
  // is withheld rather than read as "unavailable" — which would count as answered.
300
302
  const overhangSkipped = overhang != null && (facts.measuredOverhang ?? null) === null;
303
+ const bedContactSkipped = bedContact != null && facts.measuredBedContact !== true;
304
+ // A piece that is never printed (`exportable: false`) is not judged for bed contact;
305
+ // the floor is applied to the rest. Without the vocabulary, every row is printed.
306
+ const printedNames = exportableNames ? new Set(exportableNames) : null;
301
307
  for (const s of facts.subparts) {
302
308
  if (s.sheet) { checks.push(...sheetRowChecks(s, expect?.[s.name] ?? {})); continue; }
303
309
  if (anySheet && profile?.bed && s.printBbox) {
@@ -312,6 +318,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
312
318
  const merged = {
313
319
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
314
320
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
321
+ ...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
315
322
  ...(expect?.[s.name] ?? {}),
316
323
  };
317
324
  for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
@@ -325,6 +332,17 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
325
332
  hint: "re-run this check without `quick` to measure overhang" });
326
333
  continue;
327
334
  }
335
+ if (metric === "bedContactPct" && c.actual == null && s.bedContactError) {
336
+ checks.push({ ...c, status: "skip", pass: null, unevaluated: true,
337
+ 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." });
339
+ continue;
340
+ }
341
+ if (bedContactSkipped && metric === "bedContactPct" && c.actual == null) {
342
+ checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
343
+ hint: "re-run this check without `quick` to measure bed contact" });
344
+ continue;
345
+ }
328
346
  checks.push(c);
329
347
  }
330
348
  }
@@ -353,6 +371,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
353
371
  const needMinWall = partGatesMinWall(part, { process, expanded });
354
372
  // Throws on a bad `verify.orientation`, the same loudness as a bad profile name.
355
373
  const overhangAngle = overhangAngleFor(part, process, { expanded });
374
+ const bedContactFloor = bedContactFor(part, process, { expanded });
356
375
  // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
357
376
  // B agrees with A on every param that measurement READ (recorded by measure()
358
377
  // itself). Sound for the same reason the mesh cache is: a deterministic build
@@ -416,11 +435,14 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
416
435
  // changes the angle therefore re-measures rather than reusing the inspect
417
436
  // job's seed, which was taken against the part's own profile.
418
437
  const overhangMatches = (seed?.result?.measuredOverhang ?? null) === (overhangAngle ?? null);
438
+ // Bed contact's half: a seed that skipped the pass cannot answer an armed gate. The
439
+ // 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;
419
441
  // The print-pose half (views holding a sheet part): a seed measured without the print
420
442
  // sizes cannot answer a bed, so it is admitted only when this run needs no bed.
421
443
  const needPrintBboxes = profile?.bed != null;
422
444
  const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
423
- if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && printBboxesMatch
445
+ if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && printBboxesMatch
424
446
  && seed.result.view === view) {
425
447
  entries.push({ params: full(seed.params), result: seed.result });
426
448
  }
@@ -434,7 +456,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
434
456
  // for every case buys nothing. (A seed measured WITH probes is a superset in
435
457
  // the same way a min-wall seed is: the extra key is simply never read here.)
436
458
  const result = measureFn(kernel, part, view, params,
437
- { minWall: needMinWall, probes: false, overhang: overhangAngle, printBboxes: needPrintBboxes });
459
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: bedContactFloor, printBboxes: needPrintBboxes });
438
460
  entries.push({ params: fp, result });
439
461
  return result;
440
462
  };
@@ -455,7 +477,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
455
477
  }];
456
478
  const caseResults = expanded.map(({ name, params, expect }) => {
457
479
  const facts = measureCase(params);
458
- return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle }) : notMeasured(name) };
480
+ return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor }) : notMeasured(name) };
459
481
  });
460
482
  // VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
461
483
  // `expect` — used to come back `ok: true` with zero checks, and every reader
@@ -490,7 +512,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
490
512
  declared++;
491
513
  if (c.status !== "skip" && !c.unevaluated) evaluated++;
492
514
  }
493
- const declaresAnything = profile != null || overhangAngle != null
515
+ const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null
494
516
  || expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
495
517
  const failures = all.filter((c) => c.status === "fail");
496
518
  const unevaluated = all.filter((c) => c.unevaluated);
@@ -108,6 +108,19 @@ export const SUBPART_METRICS = {
108
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",
109
109
  locate: (s) => s.overhangAt,
110
110
  note: (s) => (s.overhangAngle != null ? `steepest unsupported face ${s.overhangAngle.toFixed(1)}° from vertical` : null) },
111
+ // How much of a printed piece's near-bed flat underside rests on the bed
112
+ // (oracle/bed-contact.js), in percent. Measured for a part whose FDM profile carries
113
+ // `bedContact` (or, with no profile, `orientation: "print"`; or this expectation itself);
114
+ // elsewhere `extract` returns null and check() skips it. A GATE, unlike overhangArea:
115
+ // a flat bottom hovering above a lower knuckle or emboss is a failed print in any
116
+ // orientation, and a flat face cannot be mistaken for anything else.
117
+ bedContactPct: { kind: "gate", extract: (s) => s.bedContactPct,
118
+ hint: "this piece's flat underside sits just above the bed, because something lower (a knuckle, a pin, a boss, or lettering on the bottom) touches first — exporters and slicers rest the piece on that lowest point, so the flat face prints in mid-air. Trim the piece flat at the bed plane (cut everything below z = 0 in its print pose), or raise or shorten the lower feature so the flat face is the lowest; a recess meant to clear the bed can declare a lower bedContactPct",
119
+ pattern: "piece-hovers-above-bed",
120
+ locate: (s) => s.bedHoverAt,
121
+ note: (s) => (s.bedHoverGap != null
122
+ ? `${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
+ : null) },
111
124
  // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
112
125
  // "<import name>"`; on every other sub-part `extract` returns null, which
113
126
  // `check()` already reports as status "skip" rather than a fail — the
package/types/part.d.ts CHANGED
@@ -489,6 +489,12 @@ export interface DfmProfile {
489
489
  * under a named base. A warning, never a gate.
490
490
  */
491
491
  overhang?: number | null;
492
+ /**
493
+ * The least share (%) of a printed piece's near-bed flat underside that must rest
494
+ * on the bed (50 on the FDM profiles; absent on resin). A GATE, armed by the
495
+ * profile alone; `null` switches it off under a named base.
496
+ */
497
+ bedContact?: number | null;
492
498
  /** Carried for a future gap check; not enforced yet. */
493
499
  clearance?: number;
494
500
  /**
@@ -560,6 +566,12 @@ export interface SubPartExpectations {
560
566
  * `verify.orientation: "print"`; a warning, never a gate.
561
567
  */
562
568
  overhangArea?: Expectation;
569
+ /**
570
+ * Share (0–100) of the sub-part's near-bed flat underside — faces within 2° of
571
+ * level, within 1 mm of its lowest point in the print pose — that touches the bed.
572
+ * Under an FDM profile every printed sub-part gets `>=50` by default; a gate.
573
+ */
574
+ bedContactPct?: Expectation;
563
575
  /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
564
576
  refXorVolume?: Expectation;
565
577
  /** Percent volume delta vs. the sub-part's declared `reference` import. */
@@ -419,6 +419,25 @@ export interface SubPartFacts {
419
419
  overhangAngle: number | null;
420
420
  /** Centroid of the largest offending face; `null` when none. */
421
421
  overhangAt: number[] | null;
422
+ /**
423
+ * Share (0–100) of the near-bed flat underside, in the print pose, that rests on
424
+ * the bed (oracle/bed-contact.js); `null` when the part is not checked for bed
425
+ * contact (no FDM profile) or the sub-part is `exportable: false`.
426
+ */
427
+ bedContactPct: number | null;
428
+ /** Flat underside on the bed, mm²; `null` when not checked. */
429
+ bedContactArea: number | null;
430
+ /** Flat underside hovering within 1 mm above the bed, mm²; `null` when not checked. */
431
+ bedHoverArea: number | null;
432
+ /** How far above the bed the largest hovering face sits, mm; `null` when nothing hovers. */
433
+ bedHoverGap: number | null;
434
+ /** Centroid of the largest hovering face; `null` when nothing hovers. */
435
+ bedHoverAt: number[] | null;
436
+ /**
437
+ * Present only when bed contact had to be read off the piece's export build (its
438
+ * print pose could not be read from place() alone) and that build failed.
439
+ */
440
+ bedContactError?: string;
422
441
  /** A sheet sub-part's 2-D facts, or `null` on every other sub-part. */
423
442
  sheet: SheetFacts | null;
424
443
  /**
@@ -500,6 +519,8 @@ export interface MeasureReport {
500
519
  measuredMinWall: boolean;
501
520
  /** The overhang angle every sub-part's `overhangArea` was measured against, `null` when the pass did not run. */
502
521
  measuredOverhang: number | null;
522
+ /** Whether the bed-contact pass ran (the part is checked for it). */
523
+ measuredBedContact: boolean;
503
524
  /**
504
525
  * Present only in a view holding a sheet part: whether the printed sub-parts' print
505
526
  * (export) poses were built for `printBbox`.
@@ -536,6 +557,11 @@ export function measure(
536
557
  * `verify` block the way verify does.
537
558
  */
538
559
  overhang?: number | null;
560
+ /**
561
+ * The bed-contact floor (percent) the run is checked against, or `null` for "not
562
+ * checked". Omitted, measure derives it from the part's own `verify` block.
563
+ */
564
+ bedContact?: number | null;
539
565
  gapThreshold?: number;
540
566
  /**
541
567
  * Milliseconds the 2-D sheet checks may spend across every sheet sub-part in