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.
- package/docs/AUTHORING-PARTS.md +44 -3
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +3 -2
- package/package.json +1 -1
- package/src/framework/materials/print-frame.js +20 -6
- package/src/framework/oracle/bed-contact.js +127 -0
- package/src/framework/oracle/dfm-profiles.js +37 -4
- package/src/framework/oracle/gates.js +7 -1
- package/src/framework/oracle/measure.js +73 -23
- package/src/framework/oracle/verify.js +30 -8
- package/src/framework/verify-metrics.js +13 -0
- package/types/part.d.ts +12 -0
- package/types/testing.d.ts +26 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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). `
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
|
1027
|
-
*warnings*, which report but never
|
|
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
|
@@ -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,
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
:
|
|
44
|
-
|
|
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); `
|
|
7
|
-
// (
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
|
308
|
-
?
|
|
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 (
|
|
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
|
|
349
|
-
?
|
|
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
|
|
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
|
-
|
|
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. */
|
package/types/testing.d.ts
CHANGED
|
@@ -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
|