partforge 0.162.0 → 0.163.1
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 +12 -0
- package/docs/KERNEL-CONTRACT.md +3 -2
- package/package.json +1 -1
- package/src/framework/geometry/occt-backend.js +14 -1
- package/src/framework/jobs.js +7 -1
- package/src/framework/kernel-error.js +161 -0
- 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/src/framework/worker.js +7 -2
- 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.
|
|
@@ -871,6 +877,12 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
871
877
|
- **Cause:** Peak WASM memory during the preview build exceeded what the phone allows a page (roughly 1–1.5 GB on iOS; a desktop tolerates several GB). The usual shape is hundreds of repeated small features unioned into one body: before partforge 0.116 every sphere was 6,728 triangles regardless of radius (the flat 116-segment preview count, squared), so a body with ~250 rivet spheres carried 1.7 M triangles of rivets into its booleans — measured 10 s and a 2.7 GB peak on a desktop for a 400k-triangle sub-part, against 4 s and 500 MB with the rivets as 12-triangle boxes. A boolean chain whose intermediates dwarf the result (a full-body skin intersected per groove) is the other shape.
|
|
872
878
|
- **Fix:** Update partforge (0.116 sizes spheres by chord tolerance: that body builds in ~550 MB and 120k triangles unchanged; 0.117 extends the rule to lathe profile arcs — `torus`, `roundedCylinder`, revolved rounded profiles — and `roundedBox` corners, the other two shapes that spend the count squared). Then keep the preview build's peak down the same ways the export needs: build repeated detail as one union of instances rather than a chain of per-feature booleans, cut grooves from a thin local skin rather than the whole envelope, and drop feature counts the print cannot show ([Preview vs print quality](AUTHORING-PARTS.md#conventions--gotchas)). `partforge measure` reports triangle counts per sub-part; a single preview sub-part past ~200k triangles, or a whole view past ~400k, is the range where phones start to fail.
|
|
873
879
|
|
|
880
|
+
## kernel-internal-error
|
|
881
|
+
|
|
882
|
+
- **Symptom:** `the geometry kernel raised an internal error it could not describe` — from a build or a STEP import, naming the operation when known (`during STEP import of "frame" (17.1 MB)`) and, when the kernel's own heap could say, the C++ exception type (`C++ exception StdFail_NotDone`, `Standard_ConstructionError`, `std::bad_alloc`, …) and the heap size. Before partforge 0.163.1 the same failure was a bare number (`{"ok":false,"error":"154591824"}`), different on every attempt.
|
|
883
|
+
- **Cause:** The OCCT kernel is a WASM build compiled without C++ exception catching, so an exception nothing inside it catches — the STEP reader's transfer refusing an entity, a construction with a zero-length direction, an allocation the heap could not grow for — reaches JavaScript as the exception's ADDRESS. The number is not a code and means nothing across runs. partforge now names the operation and reads the exception's type off the heap, but it cannot read OCCT's own message for it.
|
|
884
|
+
- **Fix:** Act on the type. `std::bad_alloc` or `Standard_OutOfMemory` (the message then says "probably too large for the in-browser kernel") is memory: a STEP import needs roughly six times its file size of kernel heap (measured on OCCT-written files: 17 MB → 100 MiB, 42 MB → 248 MiB), so a file that exhausts the 2 GB wasm32 ceiling is hundreds of megabytes, or the browser refused to grow the heap — simplify the imported model or split it. Any other type on an import is the file itself: re-export it from the CAD tool as AP214/AP242 solids (no assembly-only or surface-only exports) and try the smaller file; the same type from a build is that operation refusing its input (a `Standard_ConstructionError` is usually a zero-length direction or a degenerate profile). Retrying the identical build fails the same way — it is not transient.
|
|
885
|
+
|
|
874
886
|
# Hardware library
|
|
875
887
|
|
|
876
888
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
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
|
@@ -27,6 +27,7 @@ import { makeProfileWarner } from "./profile-warnings.js";
|
|
|
27
27
|
import { createOcctRepair } from "./occt-repair.js";
|
|
28
28
|
import { occtRoundAll } from "./occt-roundall.js";
|
|
29
29
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
30
|
+
import { emscriptenThrowEvidence, labelImportFailure } from "../kernel-error.js";
|
|
30
31
|
import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
|
|
31
32
|
import { resolveSweepStations } from "./sweep.js";
|
|
32
33
|
import { normalizeProfile } from "./profile.js";
|
|
@@ -199,6 +200,7 @@ export function createOcctKernel(replicad) {
|
|
|
199
200
|
// registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
|
|
200
201
|
// solid cache: imports are the framework's own memo, keyed by name+digest).
|
|
201
202
|
const imports = new Map();
|
|
203
|
+
const throwEvidence = (value) => emscriptenThrowEvidence(replicad.getOC?.(), value);
|
|
202
204
|
// name -> { digest, width, height, data } — depth-map grids the framework
|
|
203
205
|
// registers pre-build via `_registerImage` (ensureImages). Kernel-lifetime like
|
|
204
206
|
// `imports` above, and plain data rather than OCCT objects, so there is nothing
|
|
@@ -815,11 +817,22 @@ export function createOcctKernel(replicad) {
|
|
|
815
817
|
const prev = imports.get(name);
|
|
816
818
|
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
817
819
|
if (error) { imports.set(name, { error, digest }); return; }
|
|
818
|
-
|
|
820
|
+
// A STEP the reader refuses, or an OCCT exception out of its transfer,
|
|
821
|
+
// leaves here labelled with the import's name and size. The latter
|
|
822
|
+
// reaches JavaScript as a bare number from this exception-less build
|
|
823
|
+
// (kernel-error.js), and the heap is read for its C++ type BEFORE
|
|
824
|
+
// anything else runs on the kernel.
|
|
825
|
+
let shape;
|
|
826
|
+
try { shape = await importSTEP(new Blob([step])); }
|
|
827
|
+
catch (e) { throw labelImportFailure({ name, bytes: step?.byteLength, error: e, evidence: throwEvidence(e) }); }
|
|
828
|
+
imports.set(name, { shape, digest });
|
|
819
829
|
},
|
|
820
830
|
// Registration memo: undefined for an error entry, so a later registration with
|
|
821
831
|
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
822
832
|
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
833
|
+
// What the kernel's heap can say about a value OCCT threw (kernel-error.js):
|
|
834
|
+
// the job loop asks this when a build throws something that is not an Error.
|
|
835
|
+
_throwEvidence: throwEvidence,
|
|
823
836
|
// Depth-map grids, registered pre-build by the framework via `_registerImage`
|
|
824
837
|
// (ensureImages). Unlike imports there is no per-format error entry: every
|
|
825
838
|
// backend can consume a normalized grid, so registration never fails.
|
package/src/framework/jobs.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fontsFor, resolveFonts, forgetFontSource } from "./fonts.js";
|
|
|
10
10
|
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
11
11
|
import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
|
|
12
12
|
import { imagesFor, ensureImages } from "./images.js";
|
|
13
|
+
import { describeKernelThrow } from "./kernel-error.js";
|
|
13
14
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
14
15
|
import { safeName } from "./safe-name.js";
|
|
15
16
|
import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
|
|
@@ -651,7 +652,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
651
652
|
} else if (opts.jobs && Object.hasOwn(opts.jobs, msg.type)) {
|
|
652
653
|
await opts.jobs[msg.type](kernel, part, msg, post, { isStale: opts.isStale });
|
|
653
654
|
}
|
|
654
|
-
} catch (
|
|
655
|
+
} catch (thrown) {
|
|
656
|
+
// A kernel compiled without exception catching throws a bare NUMBER for an
|
|
657
|
+
// uncaught C++ exception; describeKernelThrow turns that into a readable
|
|
658
|
+
// Error (with the C++ type when the kernel's heap can say) and returns a
|
|
659
|
+
// real Error untouched, `code` included.
|
|
660
|
+
const err = describeKernelThrow(thrown, { evidence: kernel._throwEvidence?.(thrown) });
|
|
655
661
|
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
656
662
|
// failed job covered, so only those latch to OCCT — not the whole part.
|
|
657
663
|
if (err?.code === "NEEDS_IMPORT_MESH") post({ type: "needs-import-mesh", jobId: msg.jobId, subparts: msg.subparts });
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// What to say when the geometry kernel throws something that is not an Error.
|
|
2
|
+
//
|
|
3
|
+
// replicad-opencascadejs's `replicad_single` build (the OCCT kernel worker.js
|
|
4
|
+
// loads) is compiled WITHOUT C++ exception catching. An exception OCCT throws
|
|
5
|
+
// that nothing inside the WASM catches — a Standard_Failure out of the STEP
|
|
6
|
+
// reader, a std::bad_alloc when the heap cannot grow — leaves the module through
|
|
7
|
+
// Emscripten's `___cxa_throw`, whose last line is `throw ptr`: JavaScript
|
|
8
|
+
// receives the exception's ADDRESS, a bare number such as 154591824, different
|
|
9
|
+
// on every run. Stringified as-is, that number was the whole error message a
|
|
10
|
+
// part author (and partforge-cloud's agent, which read it as the part's fault
|
|
11
|
+
// and retried blindly — cloud feedback #208) got for a failed build.
|
|
12
|
+
//
|
|
13
|
+
// This module is the ONE place such a value becomes a readable Error, used by
|
|
14
|
+
// both the job loop's error post (jobs.js, worker.js) and the STEP import path
|
|
15
|
+
// (occt-backend.js). It never invents a cause: the message says the kernel
|
|
16
|
+
// raised an internal error it could not describe, names the operation when the
|
|
17
|
+
// caller knows it, and adds only what the kernel's own heap can prove —
|
|
18
|
+
// `emscriptenThrowEvidence` reads the thrown exception's C++ type name off the
|
|
19
|
+
// heap through the Itanium ABI layout Emscripten's `ExceptionInfo` uses, and
|
|
20
|
+
// says "too large for the in-browser kernel" only when that type IS an
|
|
21
|
+
// out-of-memory type, or the heap has reached the wasm32 ceiling. Anything the
|
|
22
|
+
// read cannot validate is dropped rather than guessed.
|
|
23
|
+
|
|
24
|
+
// A thrown value the caller cannot read: a number (the Emscripten case above), or
|
|
25
|
+
// an object with no non-empty string message (a bare WebAssembly.Exception, a
|
|
26
|
+
// plain object). A real Error — anything with a message — is never opaque.
|
|
27
|
+
export function isOpaqueThrow(err) {
|
|
28
|
+
if (typeof err === "number") return true;
|
|
29
|
+
if (err == null) return true;
|
|
30
|
+
if (typeof err === "string") return err.trim() === "";
|
|
31
|
+
return !(typeof err.message === "string" && err.message.trim() !== "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Emscripten's ExceptionInfo (replicad-opencascadejs 0.23): the thrown pointer
|
|
35
|
+
// is the exception OBJECT; a 24-byte header sits before it, with the
|
|
36
|
+
// std::type_info pointer at header + 4. A std::type_info (libc++abi, wasm32)
|
|
37
|
+
// is { vtable*, const char* __type_name }, so the mangled class name is the
|
|
38
|
+
// C string at *(typeInfo + 4). test/kernel-error-occt.test.js holds this
|
|
39
|
+
// layout against the installed glue: a replicad-opencascadejs bump that moves
|
|
40
|
+
// it fails there, and until it is re-pinned the read simply yields no type
|
|
41
|
+
// name (a wrong read almost never parses as a mangled name — see below).
|
|
42
|
+
const EXCEPTION_HEADER_BYTES = 24;
|
|
43
|
+
const TYPE_OFFSET_IN_HEADER = 4;
|
|
44
|
+
const NAME_OFFSET_IN_TYPE_INFO = 4;
|
|
45
|
+
const MAX_MANGLED_NAME = 128;
|
|
46
|
+
|
|
47
|
+
// getHeapMax() in the replicad_single glue: the wasm32 build's memory ceiling.
|
|
48
|
+
export const WASM32_HEAP_CEILING_BYTES = 2 ** 31;
|
|
49
|
+
// At or past this share of the ceiling, a throw with no readable type is still
|
|
50
|
+
// called out of memory: nothing else grows a heap to the top of its range.
|
|
51
|
+
const HEAP_CEILING_SHARE = 0.9;
|
|
52
|
+
|
|
53
|
+
// C++ exception types that mean the kernel could not get memory.
|
|
54
|
+
const OUT_OF_MEMORY_TYPES = new Set(["std::bad_alloc", "std::bad_array_new_length", "Standard_OutOfMemory"]);
|
|
55
|
+
|
|
56
|
+
// Itanium-mangled class name → readable, for the shapes exception types take:
|
|
57
|
+
// `St9bad_alloc` (std::bad_alloc), `16Standard_Failure`, `N3foo3BarE`
|
|
58
|
+
// (foo::Bar). Every length prefix must match its identifier exactly and the
|
|
59
|
+
// string must be consumed whole, which is what makes a misread (a pointer that
|
|
60
|
+
// landed on arbitrary heap bytes) come back null instead of as a "type".
|
|
61
|
+
export function demangleClassName(mangled) {
|
|
62
|
+
if (typeof mangled !== "string" || !/^[A-Za-z0-9_]{1,128}$/.test(mangled)) return null;
|
|
63
|
+
let i = 0;
|
|
64
|
+
const nested = mangled[0] === "N";
|
|
65
|
+
if (nested) i++;
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (mangled.startsWith("St", i)) { parts.push("std"); i += 2; }
|
|
68
|
+
while (i < mangled.length && /[0-9]/.test(mangled[i])) {
|
|
69
|
+
let j = i;
|
|
70
|
+
while (j < mangled.length && /[0-9]/.test(mangled[j])) j++;
|
|
71
|
+
const len = Number(mangled.slice(i, j));
|
|
72
|
+
if (len < 1 || j + len > mangled.length) return null;
|
|
73
|
+
const ident = mangled.slice(j, j + len);
|
|
74
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) return null;
|
|
75
|
+
parts.push(ident);
|
|
76
|
+
i = j + len;
|
|
77
|
+
}
|
|
78
|
+
if (nested ? mangled.slice(i) !== "E" : i !== mangled.length) return null;
|
|
79
|
+
if (parts.length === 0 || (parts.length === 1 && parts[0] === "std")) return null;
|
|
80
|
+
return parts.join("::");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readCString(u8, ptr, cap) {
|
|
84
|
+
if (!Number.isInteger(ptr) || ptr <= 0 || ptr >= u8.length) return null;
|
|
85
|
+
let s = "";
|
|
86
|
+
for (let i = 0; i < cap && ptr + i < u8.length; i++) {
|
|
87
|
+
const c = u8[ptr + i];
|
|
88
|
+
if (c === 0) return s;
|
|
89
|
+
s += String.fromCharCode(c);
|
|
90
|
+
}
|
|
91
|
+
return null; // ran past the cap or the heap without a terminator
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What the kernel's heap can say about a thrown value: `{ heapBytes, typeName? }`.
|
|
95
|
+
// `module` is the Emscripten module object (what `opencascade()` resolved to,
|
|
96
|
+
// `replicad.getOC()`); the HEAP views are re-read from it on every call because
|
|
97
|
+
// the glue replaces them whenever the memory grows. Returns `{}` for a module
|
|
98
|
+
// without heap views, and never throws.
|
|
99
|
+
export function emscriptenThrowEvidence(module, value) {
|
|
100
|
+
const evidence = {};
|
|
101
|
+
const u8 = module?.HEAPU8;
|
|
102
|
+
const u32 = module?.HEAPU32;
|
|
103
|
+
if (!(u8 instanceof Uint8Array) || !(u32 instanceof Uint32Array)) return evidence;
|
|
104
|
+
evidence.heapBytes = u8.length;
|
|
105
|
+
if (!Number.isInteger(value) || value <= EXCEPTION_HEADER_BYTES || value >= u8.length) return evidence;
|
|
106
|
+
const typeInfo = u32[(value - EXCEPTION_HEADER_BYTES + TYPE_OFFSET_IN_HEADER) >> 2];
|
|
107
|
+
if (!(typeInfo > 0 && typeInfo + NAME_OFFSET_IN_TYPE_INFO + 4 <= u8.length)) return evidence;
|
|
108
|
+
const namePtr = u32[(typeInfo + NAME_OFFSET_IN_TYPE_INFO) >> 2];
|
|
109
|
+
const typeName = demangleClassName(readCString(u8, namePtr, MAX_MANGLED_NAME));
|
|
110
|
+
if (typeName) evidence.typeName = typeName;
|
|
111
|
+
return evidence;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatBytes(n) {
|
|
115
|
+
if (!Number.isFinite(n) || n < 0) return "unknown size";
|
|
116
|
+
if (n < 1e6) return `${Math.max(1, Math.round(n / 1e3))} KB`;
|
|
117
|
+
return `${(n / 1e6).toFixed(1)} MB`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const describeRaw = (err) => {
|
|
121
|
+
if (typeof err === "number") return `the kernel reported only an exception address, ${err}`;
|
|
122
|
+
const ctor = err?.constructor?.name;
|
|
123
|
+
return `the kernel reported only an opaque ${ctor && ctor !== "Object" ? ctor : "value"}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Turn a thrown value into an Error. A real Error (anything with a message) is
|
|
127
|
+
// returned AS IS — same object, nothing rewritten — so a caller can branch on
|
|
128
|
+
// its `code` exactly as before. `operation` names what was running ("STEP
|
|
129
|
+
// import of "frame" (17.1 MB)"); `subject` is what the memory clause blames
|
|
130
|
+
// ("the file" for an import, "the part" otherwise); `evidence` is
|
|
131
|
+
// `emscriptenThrowEvidence`'s answer, or nothing.
|
|
132
|
+
export function describeKernelThrow(err, { operation, subject = "the part", evidence = {} } = {}) {
|
|
133
|
+
if (!isOpaqueThrow(err)) return err;
|
|
134
|
+
const { typeName, heapBytes } = evidence ?? {};
|
|
135
|
+
let message = "the geometry kernel raised an internal error it could not describe";
|
|
136
|
+
if (operation) message += ` during ${operation}`;
|
|
137
|
+
const detail = [];
|
|
138
|
+
if (typeName) detail.push(`C++ exception ${typeName}`);
|
|
139
|
+
if (Number.isFinite(heapBytes)) detail.push(`kernel heap ${formatBytes(heapBytes)}`);
|
|
140
|
+
message += detail.length ? ` (${detail.join(", ")})` : ` (${describeRaw(err)})`;
|
|
141
|
+
const outOfMemory = typeName
|
|
142
|
+
? OUT_OF_MEMORY_TYPES.has(typeName)
|
|
143
|
+
: Number.isFinite(heapBytes) && heapBytes >= WASM32_HEAP_CEILING_BYTES * HEAP_CEILING_SHARE;
|
|
144
|
+
if (outOfMemory) {
|
|
145
|
+
message += ` — the kernel ran out of memory, so ${subject} is probably too large for the in-browser kernel; retrying without reducing it will fail the same way`;
|
|
146
|
+
}
|
|
147
|
+
const out = new Error(message, { cause: err });
|
|
148
|
+
if (typeName) out.kernelExceptionType = typeName;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The STEP import path's wrapper: whatever `importSTEP` threw, labelled with the
|
|
153
|
+
// import's name and size. An opaque throw takes `describeKernelThrow` with the
|
|
154
|
+
// import as its operation; a real Error (replicad's "Failed to load STEP file",
|
|
155
|
+
// its "This shape has not type, it is null" for a file the reader parsed but
|
|
156
|
+
// could not transfer) keeps its text behind the same label, `cause` attached.
|
|
157
|
+
export function labelImportFailure({ name, bytes, error, evidence }) {
|
|
158
|
+
const operation = `STEP import of "${name}" (${formatBytes(bytes)})`;
|
|
159
|
+
if (isOpaqueThrow(error)) return describeKernelThrow(error, { operation, subject: "the file", evidence });
|
|
160
|
+
return new Error(`${operation} failed: ${error.message}`, { cause: error });
|
|
161
|
+
}
|
|
@@ -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/src/framework/worker.js
CHANGED
|
@@ -15,6 +15,7 @@ import { handle } from "./jobs.js";
|
|
|
15
15
|
export { coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./core/core-status.js";
|
|
16
16
|
import { lintPart } from "../lint.js";
|
|
17
17
|
import { cachedVectorDocs } from "./vectors.js";
|
|
18
|
+
import { describeKernelThrow } from "./kernel-error.js";
|
|
18
19
|
|
|
19
20
|
async function manifoldKernels() {
|
|
20
21
|
const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
|
|
@@ -109,10 +110,11 @@ export function runWorker(part, opts = {}) {
|
|
|
109
110
|
// escaping rejection would kill the pump: this job AND everything queued behind
|
|
110
111
|
// it would be dropped with no reply at all, leaving the host waiting on a message
|
|
111
112
|
// that never comes until its own timeout fires.
|
|
113
|
+
let kernel = null;
|
|
112
114
|
try {
|
|
113
115
|
// A generate superseded while it sat in the queue never builds at all.
|
|
114
116
|
if (job.epoch !== null && job.epoch !== epoch) continue;
|
|
115
|
-
|
|
117
|
+
kernel = await kernelFor(job.data);
|
|
116
118
|
// handle() declares each message's transferables (the big binary buffers).
|
|
117
119
|
const post = (m, transfer = []) => postMessage(m, transfer);
|
|
118
120
|
if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
|
|
@@ -125,11 +127,14 @@ export function runWorker(part, opts = {}) {
|
|
|
125
127
|
const gated = (m, transfer = []) =>
|
|
126
128
|
(m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
|
|
127
129
|
await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
|
|
128
|
-
} catch (
|
|
130
|
+
} catch (thrown) {
|
|
129
131
|
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
|
130
132
|
// Carry the job's jobId when it has one (capture/export are correlated by it):
|
|
131
133
|
// a boot failure hitting kernelFor here must reach the right controller, or a
|
|
132
134
|
// correlated caller (captureView, exportParts) would hang instead of settling.
|
|
135
|
+
// A bare number (an uncaught C++ exception out of the kernel) is given
|
|
136
|
+
// words here too, exactly as jobs.js does for a build.
|
|
137
|
+
const err = describeKernelThrow(thrown, { evidence: kernel?._throwEvidence?.(thrown) });
|
|
133
138
|
const jobId = job.data?.jobId;
|
|
134
139
|
postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
|
|
135
140
|
}
|
package/types/part.d.ts
CHANGED
|
@@ -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
|