partforge 0.170.0 → 0.172.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/README.md +1 -1
- package/docs/AUTHORING-PARTS.md +56 -5
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +1 -1
- package/src/framework/lint/rules-verify.js +51 -1
- package/src/framework/mount-preview.js +10 -6
- package/src/framework/oracle/flex-root.js +128 -0
- package/src/framework/oracle/measure.js +18 -1
- package/src/framework/oracle/verify.js +58 -9
- package/src/framework/preview-player.js +24 -0
- package/src/framework/verify-metrics.js +32 -1
- package/src/framework/viewer.js +51 -2
- package/types/index.d.ts +3 -2
- package/types/part.d.ts +15 -0
- package/types/testing.d.ts +7 -0
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ never under `prefers-reduced-motion`), `fill: 0.8` (frame the model so its
|
|
|
199
199
|
bounding box, as seen from the opening view, spans that fraction of the preview
|
|
200
200
|
along its tighter axis — larger in a small card; with a turntable the model swept
|
|
201
201
|
a full turn is framed, so it never clips as it turns and a round part still fills, and it
|
|
202
|
-
turns about its hub; a model that is not turning has its on-screen outline centred, since 0.169.0), `camera: "default"` (drop the carried camera
|
|
202
|
+
turns about its hub; a model that is not turning has its on-screen outline centred, since 0.169.0, and a playing animation is framed over every pose it passes through, since 0.171.0, so a part that swings out never leaves the frame), `camera: "default"` (drop the carried camera
|
|
203
203
|
and projection and keep the file's own framing; a carried render mode and
|
|
204
204
|
environment still apply) and `renderMode: "cad" | "realistic"` (the opening
|
|
205
205
|
mode, over any carried one; it can change afterwards), and `autostart: false`
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1953,7 +1953,7 @@ Every print check is in exactly one tier, decided by what the reading means:
|
|
|
1953
1953
|
|
|
1954
1954
|
| tier | checks | effect on `verify.ok` |
|
|
1955
1955
|
| --- | --- | --- |
|
|
1956
|
-
| **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50) | fails it |
|
|
1956
|
+
| **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50), a declared flexing member's root (`flexRoot`) | fails it |
|
|
1957
1957
|
| **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`) | never |
|
|
1958
1958
|
| **advisory** — a reading that may not matter | min wall (`minWall`), `wall` | never; a warning |
|
|
1959
1959
|
|
|
@@ -2015,6 +2015,37 @@ pin together) or a pinned clevis, not a socket snapped over a sphere: the snap's
|
|
|
2015
2015
|
is a downward cavity that prints on supports nobody can reach. Build the snap only when
|
|
2016
2016
|
the user wants it and has accepted the supports.
|
|
2017
2017
|
|
|
2018
|
+
### Flexing members
|
|
2019
|
+
|
|
2020
|
+
Anything meant to bend in use — a snap arm, a buckle prong, a clip, a latch hook, a
|
|
2021
|
+
cantilever tab, a living spring — must meet the body it grows from through a **fillet
|
|
2022
|
+
at its root of at least half its thickness**. Bending puts the highest stress at the
|
|
2023
|
+
root, a sharp inside corner multiplies it several times over, and an FDM print's layers
|
|
2024
|
+
are weakest across exactly that corner: an unrounded arm snaps off flush with the body,
|
|
2025
|
+
often on the first few flexes. Decide the root fillet with the arm, not after it, and
|
|
2026
|
+
keep it clear of whatever the arm slides into (chamfer that mouth so the fillet seats).
|
|
2027
|
+
|
|
2028
|
+
Declare every such member in `verify.flex`, by its feature label, and label the member
|
|
2029
|
+
**apart from the body** it grows from:
|
|
2030
|
+
|
|
2031
|
+
```js
|
|
2032
|
+
const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
|
|
2033
|
+
let s = k.union([base, arm, otherArm])
|
|
2034
|
+
.fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
|
|
2035
|
+
// …
|
|
2036
|
+
verify: { process: "fdm-pla", flex: ["Right prong", "Left prong"], expect: { … } },
|
|
2037
|
+
```
|
|
2038
|
+
|
|
2039
|
+
verify then fails (`flexRoot`, a gate, under any process or none) wherever a triangle of
|
|
2040
|
+
the member meets a triangle of anything else at a sharp inside corner — a fillet's faces
|
|
2041
|
+
are new surfaces turning a few degrees per facet, so a rounded root reads clean, and a
|
|
2042
|
+
face that only continues flush into the body is not a corner. A rib or barb on the arm
|
|
2043
|
+
that carries its own label is part of the member: list them together,
|
|
2044
|
+
`flex: [["Right prong", "Right prong rib"]]`. Only what you declare is checked: the
|
|
2045
|
+
oracle cannot tell an arm that bends from a wall, a tooth or a pin of the same shape. A
|
|
2046
|
+
name no build labels is lint's `verify-flex-unknown-label` warning, and a member no
|
|
2047
|
+
sub-part of the verified view carries is reported as skipped.
|
|
2048
|
+
|
|
2018
2049
|
### Small features and scale
|
|
2019
2050
|
|
|
2020
2051
|
A feature thinner than the profile's `minWall` (1.2 mm PLA, 1.5 mm PETG, 0.6 mm
|
|
@@ -2028,7 +2059,8 @@ in the control's label ("below 150 mm the pins get thin").
|
|
|
2028
2059
|
Size a mating feature from the profile's fit class — `press`, `push`, `slip`,
|
|
2029
2060
|
`moving`, `printInPlace` — copied into `derive()` with a comment naming it
|
|
2030
2061
|
(`// slip fit: fdm-pla 0.25`), and a horizontal bore enlarged by `holeUndersize`. A
|
|
2031
|
-
snap is a contact; its fit is `push` or `slip
|
|
2062
|
+
snap is a contact; its fit is `push` or `slip`, and its arm gets a root fillet
|
|
2063
|
+
("Flexing members" above). The numbers, and how a diametral fit
|
|
2032
2064
|
becomes a `clearance` expectation, are in the `verify` section below.
|
|
2033
2065
|
|
|
2034
2066
|
### First layer
|
|
@@ -4132,8 +4164,9 @@ runtime a throwing probe only degrades quietly to `{ error }`.
|
|
|
4132
4164
|
|
|
4133
4165
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
4134
4166
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
4135
|
-
`verify-unknown-orientation`, `verify-unknown-accept`,
|
|
4136
|
-
`verify-expect-throws` (all errors)
|
|
4167
|
+
`verify-unknown-orientation`, `verify-unknown-accept`, `verify-bad-flex`,
|
|
4168
|
+
`verify-expect-throws` (all errors), and `verify-flex-unknown-label` (a warning: a
|
|
4169
|
+
`verify.flex` label no build writes at the default parameters). Note `_view` also accepts the pair-wise
|
|
4137
4170
|
`contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
|
|
4138
4171
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
4139
4172
|
|
|
@@ -4534,6 +4567,24 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
|
|
|
4534
4567
|
does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
|
|
4535
4568
|
sub-parts are not judged.
|
|
4536
4569
|
|
|
4570
|
+
**Flexing members — a GATE, armed by `verify.flex` alone.** `flex` lists the part's
|
|
4571
|
+
flexing members by feature label (`flex: ["Right prong", "Left prong"]`; a list entry,
|
|
4572
|
+
`["Arm", "Arm rib"]`, is one member made of several labels). Every printed sub-part
|
|
4573
|
+
whose mesh carries a member's label gets one `flexRoot` row (`"0"` by default; your own
|
|
4574
|
+
`flexRoot` expectation on that sub-part replaces it): the sharp inside corner where a
|
|
4575
|
+
member meets anything else — a concave edge turning more than 30° between one of its
|
|
4576
|
+
triangles and one that is not — fails on any. `actual` is the worst member's length in
|
|
4577
|
+
mm, `feature` names that member, `location` is its longest stretch, and `note` lists
|
|
4578
|
+
every member the sub-part carries. A rounded root reads 0. It is independent of the
|
|
4579
|
+
process and of the print pose: the root is the same in any orientation. Three
|
|
4580
|
+
declaration problems are reported per member rather than per sub-part: a member no
|
|
4581
|
+
sub-part of the verified view carries is skipped (it may be switched off in that case);
|
|
4582
|
+
a member carried but meeting nothing — its label put on the whole union, or a piece
|
|
4583
|
+
that never touches the body — withholds the verdict (`unevaluated`), since a clean root
|
|
4584
|
+
and no root must never read alike; and a seed that did not read a member answers
|
|
4585
|
+
unevaluated on a quick lap. See "Flexing members" under "Printed parts" for why, and
|
|
4586
|
+
how to round a root.
|
|
4587
|
+
|
|
4537
4588
|
**What the profile tells you.** Three more fields carry the numbers a mating
|
|
4538
4589
|
feature needs. Nothing checks them; they are the one place the value is spelled,
|
|
4539
4590
|
so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
|
|
@@ -4558,7 +4609,7 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
|
|
|
4558
4609
|
**What `expect` gives you:** per-sub-part
|
|
4559
4610
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4560
4611
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
4561
|
-
`overhangArea`, `bedContactPct`, `footprintPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4612
|
+
`overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4562
4613
|
positions, written in the same `{ min, max }` shape `measure` reports them — where
|
|
4563
4614
|
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4564
4615
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -234,6 +234,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
234
234
|
- **Cause:** The lowest point of the piece is not a flat face, so almost none of its silhouette on the bed actually touches it (a cylinder on its side reads a few percent, under the floor): the first layer has almost nothing to bond to, and the piece tips or pulls loose unless it prints with a brim, a raft, or in a different orientation.
|
|
235
235
|
- **Fix:** Lay the piece on its flat face in its print pose, add a flat foot or pad at its lowest point, or print it with a brim. When the user chose the brim, `verify.accept: ["footprint"]` records it and the cost reads accepted. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
236
236
|
|
|
237
|
+
## flex-root-sharp
|
|
238
|
+
|
|
239
|
+
- **Symptom:** A `✗ … flexRoot` failure from `verify` naming the member in `feature`, whose note reads e.g. `"Right prong": 12.2 mm of sharp inside corner (90°)`; or, in use, a snap arm, buckle prong, clip or cantilever tab that breaks off flush with the body it grows from.
|
|
240
|
+
- **Cause:** The member declared in `verify.flex` meets the rest of the part at a sharp inside corner. Bending puts the highest stress at the root, a sharp corner multiplies it several times over, and in FDM the layer bonds are weakest right there — so the member snaps at its root, often on the first few flexes.
|
|
241
|
+
- **Fix:** Round the root with a fillet of at least half the member's thickness: `s.fillet({ r, edges: { near: [x, y, z] } })` on the joined solid, one root edge at a time, or `edges: { inPlane, at }` when every root edge lies on the face the member grows from (fillet before anything else that touches that face joins, so only the roots are selected); or draw the fillet into the 2-D profile the member and body share. Keep the fillet clear of whatever the member slides into — chamfer that mouth to seat it. If a grip rib or barb on the member is labelled apart, list it with the member: `flex: [["Arm", "Arm rib"]]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
242
|
+
|
|
237
243
|
## expect-static-across-presets
|
|
238
244
|
|
|
239
245
|
- **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/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// throws from verify() mid-run, AFTER measure has printed and the kernel has booted,
|
|
3
3
|
// which is also the documented reason CLI stdout isn't pure JSON in that case.
|
|
4
4
|
// Catching them statically removes both the wasted boot and the stdout caveat.
|
|
5
|
-
import { err } from "./finding.js";
|
|
5
|
+
import { err, warn } from "./finding.js";
|
|
6
6
|
import { SUBPART_METRICS, VIEW_METRICS, BOUNDS_FORM, teachableMetrics, sidesSpecError } from "../verify-metrics.js";
|
|
7
7
|
import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
|
|
8
8
|
import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
|
|
@@ -447,4 +447,54 @@ export const VERIFY_RULES = [
|
|
|
447
447
|
return out;
|
|
448
448
|
},
|
|
449
449
|
},
|
|
450
|
+
{
|
|
451
|
+
// `verify.flex` names the part's flexing members by feature label (oracle/flex-root.js):
|
|
452
|
+
// a list whose entries are a label, or a list of labels read as one member. verify()
|
|
453
|
+
// drops a malformed entry silently, so the shape is checked here.
|
|
454
|
+
id: "verify-bad-flex",
|
|
455
|
+
run: ({ part }) => {
|
|
456
|
+
const flex = part?.verify?.flex;
|
|
457
|
+
if (flex == null) return [];
|
|
458
|
+
const form = "Write `flex` as a list of feature labels, e.g. `flex: [\"Left arm\", \"Right arm\"]`; an entry that is itself a list names one member made of several labels (`[\"Arm\", \"Arm rib\"]`).";
|
|
459
|
+
if (!Array.isArray(flex)) {
|
|
460
|
+
return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of feature labels`, form, "verify.flex")];
|
|
461
|
+
}
|
|
462
|
+
const label = (l) => typeof l === "string" && l.length > 0;
|
|
463
|
+
const out = [];
|
|
464
|
+
flex.forEach((e, i) => {
|
|
465
|
+
if (label(e) || (Array.isArray(e) && e.length > 0 && e.every(label))) return;
|
|
466
|
+
out.push(err("verify-bad-flex", `\`verify.flex[${i}]\` is ${describe(e)}, not a feature label or a list of them`, form, `verify.flex[${i}]`));
|
|
467
|
+
});
|
|
468
|
+
return out;
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
// A flex label no build writes is a check that never runs: verify skips a member no
|
|
473
|
+
// sub-part carries (it may be switched off in that case), so a typo would pass
|
|
474
|
+
// silently. The geometry-free probe records every `.label()` call at the default
|
|
475
|
+
// params; a label set only under other params reads as missing, hence a warning.
|
|
476
|
+
id: "verify-flex-unknown-label",
|
|
477
|
+
run: ({ part, probe }) => {
|
|
478
|
+
const flex = part?.verify?.flex;
|
|
479
|
+
if (!Array.isArray(flex) || !flex.length) return [];
|
|
480
|
+
const run = probe();
|
|
481
|
+
if (run.runaway || run.throws.length) return []; // an unfinished build saw only some labels
|
|
482
|
+
const seen = new Set(run.calls.filter((c) => c.op === "label")
|
|
483
|
+
.map((c) => { try { return JSON.parse(c.args[0]); } catch { return null; } })
|
|
484
|
+
.filter((l) => typeof l === "string"));
|
|
485
|
+
const known = [...seen];
|
|
486
|
+
const out = [];
|
|
487
|
+
flex.forEach((e, i) => {
|
|
488
|
+
for (const l of Array.isArray(e) ? e : [e]) {
|
|
489
|
+
if (typeof l !== "string" || !l || seen.has(l)) continue; // a bad shape is verify-bad-flex's
|
|
490
|
+
const hint = safeSuggest(l, known);
|
|
491
|
+
out.push(warn("verify-flex-unknown-label",
|
|
492
|
+
`\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
|
|
493
|
+
`Label the flexing member itself — \`.label("${l}")\` on the arm, apart from the body it grows from — or correct the name${hint ? ` (did you mean "${hint}"?)` : known.length ? ` (labels the build writes: ${known.slice(0, 12).map((k) => `"${k}"`).join(", ")})` : ""}. Until it matches, the root check never runs.`,
|
|
494
|
+
`verify.flex[${i}]`));
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
return out;
|
|
498
|
+
},
|
|
499
|
+
},
|
|
450
500
|
];
|
|
@@ -59,11 +59,13 @@
|
|
|
59
59
|
// about the vertical (its farthest vertex from the axis), so the turntable
|
|
60
60
|
// never clips and a round part still fills, centred on its horizontal
|
|
61
61
|
// vertex centroid (a lopsided part turns about its hub). A model that is
|
|
62
|
-
// not turning (a still, or a playing animation
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
62
|
+
// not turning (a still, or a playing animation) is fitted by its posed
|
|
63
|
+
// VERTICES and its silhouette centred on screen (0.169.0): the target moves
|
|
64
|
+
// in the screen plane so the projected outline's bounding box is centred,
|
|
65
|
+
// spanning `fill`. A playing animation is fitted over EVERY pose it passes
|
|
66
|
+
// through (0.171.0; each part's outermost points at each keyframe), so a
|
|
67
|
+
// part that swings out of its first pose's outline stays in frame — the
|
|
68
|
+
// outline of all its poses together spans `fill`.
|
|
67
69
|
// Unset, the viewer's fixed framing distance, as before.
|
|
68
70
|
// The swept framing follows the configuration, not reduced motion (0.169.0):
|
|
69
71
|
// a turntable mount is framed swept even when reduced motion keeps it still.
|
|
@@ -122,7 +124,7 @@ import { observeViewbarClear } from "./viewbar-clear.js";
|
|
|
122
124
|
import { createTooltipPresenter } from "./tooltip.js";
|
|
123
125
|
import { isFaceAlignedState } from "./projection.js";
|
|
124
126
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
125
|
-
import { createPreviewPlayer } from "./preview-player.js";
|
|
127
|
+
import { createPreviewPlayer, animationPoses } from "./preview-player.js";
|
|
126
128
|
import { attachPreviewPlayButton } from "./preview-play-button.js";
|
|
127
129
|
|
|
128
130
|
// A value the option does not know is a host bug: say so at the call rather
|
|
@@ -292,6 +294,8 @@ export function mountPreview(container, options = {}) {
|
|
|
292
294
|
transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
|
|
293
295
|
// A turning model is framed swept a full turn, so no angle of it clips.
|
|
294
296
|
frameFill: opts.fill, frameSwept: sweeps,
|
|
297
|
+
// An animation is framed over every pose it passes through (0.171.0).
|
|
298
|
+
framePoses: plays ? animationPoses(decoded.animation) : null,
|
|
295
299
|
edgeWidth: opts.edgeWidth, edgeOpacity: opts.edgeOpacity,
|
|
296
300
|
});
|
|
297
301
|
cleanups.push(() => viewer.dispose());
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/framework/oracle/flex-root.js
|
|
2
|
+
// Whether a flexing member meets the rest of its part through a rounded root.
|
|
3
|
+
//
|
|
4
|
+
// The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
|
|
5
|
+
// joined to its body at a sharp inside corner. Bending concentrates the stress at the
|
|
6
|
+
// root, and a sharp inside corner multiplies it several times over, so the member snaps
|
|
7
|
+
// there — often on the first flex, in FDM plastic whose layer bonds are weakest across
|
|
8
|
+
// exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
|
|
9
|
+
// 2 mm prism unioned onto the base, broke off flush with the base face. A fillet at the
|
|
10
|
+
// root of about half the member's thickness spreads the stress and is what fixes it.
|
|
11
|
+
//
|
|
12
|
+
// A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
|
|
13
|
+
// one of them is a thin feature joined at a sharp corner, and a shape-only detector
|
|
14
|
+
// flagged most parts it was tried on. So the part SAYS which members flex
|
|
15
|
+
// (`verify.flex`, by feature label), and this reads only those: every edge where a
|
|
16
|
+
// triangle of the member meets a triangle of anything else. A root is sharp where that
|
|
17
|
+
// edge is concave and turns by more than SHARP_DEG. A fillet's own faces are new,
|
|
18
|
+
// unlabelled surfaces (or the sub-part's), and a tessellated fillet turns by a few
|
|
19
|
+
// degrees per facet, tangent where it meets the member and the body — so a filleted
|
|
20
|
+
// root reads clean, and so does a member whose faces only continue coplanar into the
|
|
21
|
+
// body (its top face flush with the base's top).
|
|
22
|
+
//
|
|
23
|
+
// One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
|
|
24
|
+
// oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
|
|
25
|
+
// `featureIds`/`features` (the feature labels), which both backends attach.
|
|
26
|
+
//
|
|
27
|
+
// Known limits, stated: an inside corner WITHIN one label (the member and its body
|
|
28
|
+
// labelled alike, or a barb's own notch) is invisible — label the flexing member apart
|
|
29
|
+
// from the body it grows from. A label that covers the whole sub-part, or a member that
|
|
30
|
+
// never touches the rest of it, therefore has NO root to read: that is `attached` false,
|
|
31
|
+
// which verify withholds a verdict on rather than passing (a clean root and no root
|
|
32
|
+
// must never read alike). And a feature touching the member (a grip rib on the arm)
|
|
33
|
+
// counts as "the rest of the part", so its corners read as roots unless it is listed
|
|
34
|
+
// with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
|
|
35
|
+
|
|
36
|
+
// A concave edge turning more than this (degrees) is a sharp corner. A tessellated
|
|
37
|
+
// fillet turns a few degrees per facet (3.1° per step at the preview's 116 segments), a
|
|
38
|
+
// 45° chamfer turns 45° twice — a chamfer is a corner too, if a softer one.
|
|
39
|
+
export const SHARP_DEG = 30;
|
|
40
|
+
const SHARP_COS = Math.cos((SHARP_DEG * Math.PI) / 180);
|
|
41
|
+
// Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
|
|
42
|
+
export const ROOT_FLOOR = 0.1;
|
|
43
|
+
|
|
44
|
+
/** The member groups `verify.flex` declares: each entry a label or a list of labels. */
|
|
45
|
+
export function flexGroups(flex) {
|
|
46
|
+
if (!Array.isArray(flex)) return [];
|
|
47
|
+
return flex.map((e) => (Array.isArray(e) ? e : [e])).filter((g) => g.length && g.every((l) => typeof l === "string" && l));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The name a member group is reported under: its first label. */
|
|
51
|
+
export const groupName = (group) => group[0];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
|
|
55
|
+
* @param {string[]} group the labels making up one flexing member
|
|
56
|
+
* @returns {{ found: boolean, attached: boolean, sharpLength: number, maxTurn: number|null, at: number[]|null }}
|
|
57
|
+
* `found` false when no triangle carries any of the labels (the check cannot run);
|
|
58
|
+
* `attached` false when the member shares no edge with the rest of the mesh (nothing
|
|
59
|
+
* to read either — the label covers everything, or the member touches nothing);
|
|
60
|
+
* `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
|
|
61
|
+
* `maxTurn` the sharpest such edge's turn in degrees and `at` the midpoint of the
|
|
62
|
+
* longest one (both null when the root is clean).
|
|
63
|
+
*/
|
|
64
|
+
export function flexRoot(mesh, group) {
|
|
65
|
+
const { positions: P, indices: I, featureIds, features } = mesh;
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
if (featureIds && features) features.forEach((f, i) => { if (group.includes(f)) ids.add(i + 1); });
|
|
68
|
+
if (!ids.size) return { found: false, attached: false, sharpLength: 0, maxTurn: null, at: null };
|
|
69
|
+
const nTri = I ? I.length / 3 : P.length / 9;
|
|
70
|
+
const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
|
|
71
|
+
const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
|
|
72
|
+
|
|
73
|
+
// Normals and centroids, and every edge of a member triangle, keyed by welded endpoints.
|
|
74
|
+
const inMember = (t) => ids.has(featureIds[t]);
|
|
75
|
+
const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3);
|
|
76
|
+
const edges = new Map(); // key -> { a, b, tris: [] }
|
|
77
|
+
const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
|
|
78
|
+
for (let t = 0; t < nTri; t++) {
|
|
79
|
+
const [a, b, c] = tri(t);
|
|
80
|
+
const ux = P[b * 3] - P[a * 3], uy = P[b * 3 + 1] - P[a * 3 + 1], uz = P[b * 3 + 2] - P[a * 3 + 2];
|
|
81
|
+
const vx = P[c * 3] - P[a * 3], vy = P[c * 3 + 1] - P[a * 3 + 1], vz = P[c * 3 + 2] - P[a * 3 + 2];
|
|
82
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
83
|
+
const len = Math.hypot(nx, ny, nz) || 1;
|
|
84
|
+
normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
|
|
85
|
+
for (let k = 0; k < 3; k++) cent[t * 3 + k] = (P[a * 3 + k] + P[b * 3 + k] + P[c * 3 + k]) / 3;
|
|
86
|
+
}
|
|
87
|
+
// Only edges touching a member triangle matter; register those first, then let any
|
|
88
|
+
// triangle sharing such an edge join it.
|
|
89
|
+
const keys = [];
|
|
90
|
+
for (let t = 0; t < nTri; t++) {
|
|
91
|
+
const v = tri(t);
|
|
92
|
+
keys.push(v.map(key));
|
|
93
|
+
if (!inMember(t)) continue;
|
|
94
|
+
for (let j = 0; j < 3; j++) {
|
|
95
|
+
const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
|
|
96
|
+
const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
|
97
|
+
if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (let t = 0; t < nTri; t++) {
|
|
101
|
+
for (let j = 0; j < 3; j++) {
|
|
102
|
+
const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
|
|
103
|
+
const e = edges.get(ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`);
|
|
104
|
+
if (e) e.tris.push(t);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
|
|
109
|
+
for (const e of edges.values()) {
|
|
110
|
+
if (e.tris.length !== 2) continue;
|
|
111
|
+
const [t1, t2] = e.tris;
|
|
112
|
+
if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
|
|
113
|
+
attached = true; // a boundary, at any turn
|
|
114
|
+
const d = normal[t1 * 3] * normal[t2 * 3] + normal[t1 * 3 + 1] * normal[t2 * 3 + 1] + normal[t1 * 3 + 2] * normal[t2 * 3 + 2];
|
|
115
|
+
if (d > SHARP_COS) continue; // shallow: a fillet facet, a flush face
|
|
116
|
+
const mx = (P[e.a * 3] + P[e.b * 3]) / 2, my = (P[e.a * 3 + 1] + P[e.b * 3 + 1]) / 2, mz = (P[e.a * 3 + 2] + P[e.b * 3 + 2]) / 2;
|
|
117
|
+
// Concave: the second triangle lies in front of the first one's face.
|
|
118
|
+
const front = normal[t1 * 3] * (cent[t2 * 3] - mx) + normal[t1 * 3 + 1] * (cent[t2 * 3 + 1] - my) + normal[t1 * 3 + 2] * (cent[t2 * 3 + 2] - mz);
|
|
119
|
+
if (front <= 1e-9) continue; // convex: an outside edge
|
|
120
|
+
const len = Math.hypot(P[e.a * 3] - P[e.b * 3], P[e.a * 3 + 1] - P[e.b * 3 + 1], P[e.a * 3 + 2] - P[e.b * 3 + 2]);
|
|
121
|
+
const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
|
|
122
|
+
sharpLength += len;
|
|
123
|
+
if (maxTurn == null || turn > maxTurn) maxTurn = turn;
|
|
124
|
+
if (len > longest) { longest = len; at = [mx, my, mz]; }
|
|
125
|
+
}
|
|
126
|
+
if (!attached || sharpLength < ROOT_FLOOR) return { found: true, attached, sharpLength: 0, maxTurn: null, at: null };
|
|
127
|
+
return { found: true, attached, sharpLength, maxTurn, at };
|
|
128
|
+
}
|
|
@@ -14,6 +14,7 @@ import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
|
14
14
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
15
15
|
import { overhang } from "./overhang.js";
|
|
16
16
|
import { bedContact } from "./bed-contact.js";
|
|
17
|
+
import { flexRoot, flexGroups, groupName } from "./flex-root.js";
|
|
17
18
|
import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
|
|
18
19
|
import { summarizeContours } from "./shape-probe.js";
|
|
19
20
|
|
|
@@ -320,6 +321,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
320
321
|
// one memo-key bit, covers both readings.
|
|
321
322
|
const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
|
|
322
323
|
const underside = overhangAngle != null || bedArmed;
|
|
324
|
+
// The flexing members the part declares (`verify.flex`, by feature label — flex-root.js):
|
|
325
|
+
// each gets a root reading on every printed sub-part. One pass over the triangles per
|
|
326
|
+
// member, no index, so it runs on every lap. verify passes its own list; alone, measure
|
|
327
|
+
// reads the part's. The list measured is stamped as `measuredFlex`, which verify
|
|
328
|
+
// compares before reusing a seed.
|
|
329
|
+
const flex = flexGroups(opts.flex !== undefined ? opts.flex : part.verify?.flex);
|
|
323
330
|
const subBounds = [];
|
|
324
331
|
// Sheet parts: a view holding one sizes each printed sub-part in its print pose
|
|
325
332
|
// (verify fits the bed per part there), and every sheet row gets its 2-D facts under
|
|
@@ -372,7 +379,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
372
379
|
const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash && !exportBed
|
|
373
380
|
? JSON.stringify({
|
|
374
381
|
mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
|
|
375
|
-
oh: overhangAngle ?? null, bc: bedArmed, ex: printed,
|
|
382
|
+
oh: overhangAngle ?? null, bc: bedArmed, ex: printed, fx: flex,
|
|
376
383
|
pm: printMatrix ? Array.from(printMatrix) : null,
|
|
377
384
|
})
|
|
378
385
|
: null;
|
|
@@ -471,6 +478,13 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
471
478
|
footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
|
|
472
479
|
footprintAt: bc?.lowestAt ?? null,
|
|
473
480
|
...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
|
|
481
|
+
// Each declared flexing member's root (flex-root.js), one entry per member group
|
|
482
|
+
// whose label this sub-part's mesh carries: the length of sharp inside corner where
|
|
483
|
+
// the member meets the rest of the part, the sharpest turn, and where the longest
|
|
484
|
+
// stretch sits. Null when nothing is declared; a sheet part is not printed, so none.
|
|
485
|
+
flexRoots: flex.length && !sheet
|
|
486
|
+
? flex.map((g) => ({ feature: groupName(g), labels: g, ...flexRoot(mesh, g) })).filter((r) => r.found)
|
|
487
|
+
: null,
|
|
474
488
|
// The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
|
|
475
489
|
sheet: sheet
|
|
476
490
|
? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
|
|
@@ -562,6 +576,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
562
576
|
measuredBedContact: bedArmed,
|
|
563
577
|
// The footprint is read by the same pass, so the same stamp.
|
|
564
578
|
measuredFootprint: bedArmed,
|
|
579
|
+
// The flexing-member groups whose roots were read (flex-root.js), as measured —
|
|
580
|
+
// verify reuses a seed only when it read every group this run declares.
|
|
581
|
+
measuredFlex: flex,
|
|
565
582
|
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
566
583
|
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
567
584
|
measuredGaps,
|
|
@@ -2,10 +2,11 @@ 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 { flexGroups, groupName } from "./flex-root.js";
|
|
5
6
|
import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
|
|
6
7
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
7
8
|
import { relevanceHash } from "../param-deps.js";
|
|
8
|
-
import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError } from "../verify-metrics.js";
|
|
9
|
+
import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT } from "../verify-metrics.js";
|
|
9
10
|
import { processById } from "../process/registry.js";
|
|
10
11
|
import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
|
|
11
12
|
// Re-exported for the callers that read them from here; their one home is sheet/constants.js.
|
|
@@ -149,7 +150,7 @@ function check(scope, subpart, metric, spec, registry, factsObj, accept = []) {
|
|
|
149
150
|
// did before it had its own branch.
|
|
150
151
|
if (reg.unavailable) {
|
|
151
152
|
const out = { ...base, actual, status: "warn", pass: null, message: reg.unavailable,
|
|
152
|
-
hint: partHint ?? "no ray read as this wall — the declared band may not match the geometry (check minWall for the real thickness), or a sampled run missed it" };
|
|
153
|
+
hint: partHint ?? reg.unavailableHint ?? "no ray read as this wall — the declared band may not match the geometry (check minWall for the real thickness), or a sampled run missed it" };
|
|
153
154
|
const note = reg.note?.(factsObj);
|
|
154
155
|
if (note) out.note = note;
|
|
155
156
|
return out;
|
|
@@ -159,6 +160,9 @@ function check(scope, subpart, metric, spec, registry, factsObj, accept = []) {
|
|
|
159
160
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
160
161
|
const status = pass ? "pass" : reg.kind === "warn" ? "warn" : reg.kind === "cost" ? "cost" : "fail";
|
|
161
162
|
const out = { ...base, actual, status, pass, message };
|
|
163
|
+
// The labelled feature a reading is about (flexRoot's worst member), whatever the verdict.
|
|
164
|
+
const feature = reg.feature?.(factsObj);
|
|
165
|
+
if (feature) out.feature = feature;
|
|
162
166
|
// A measurement caveat rides along whatever the verdict — a min-wall reading
|
|
163
167
|
// taken from a sample still passed, but the reader should know it was a sample.
|
|
164
168
|
const note = reg.note?.(factsObj);
|
|
@@ -215,8 +219,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
|
|
|
215
219
|
// The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
|
|
216
220
|
// Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
|
|
217
221
|
// that declares only these has no verdict rather than a vacuous pass.
|
|
218
|
-
const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct"]);
|
|
219
|
-
const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct and
|
|
222
|
+
const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct", "flexRoot"]);
|
|
223
|
+
const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct, footprintPct and flexRoot 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.";
|
|
220
224
|
|
|
221
225
|
// A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
|
|
222
226
|
// volunteered check warns with the reason; a declared one warns with it too, and is
|
|
@@ -278,7 +282,7 @@ function sheetRowChecks(s, declaredExp) {
|
|
|
278
282
|
// `footprintPct >= floor` on every printed sub-part.
|
|
279
283
|
// `accept` the cost ids the user accepted (`verify.accept`), stamped on each cost the
|
|
280
284
|
// process volunteered (never on one the author wrote in `expect`).
|
|
281
|
-
export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [] }) {
|
|
285
|
+
export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [], flex = [] }) {
|
|
282
286
|
const checks = [];
|
|
283
287
|
// contacts/clearance/held are pair-derived, not scalar view metrics — peel
|
|
284
288
|
// them off before the registry loop. held.js explains the exportable bound.
|
|
@@ -326,6 +330,9 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
326
330
|
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
327
331
|
...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
|
|
328
332
|
...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
|
|
333
|
+
// A declared flexing member this sub-part carries AND meets (flex-root.js): its
|
|
334
|
+
// root must be rounded. A carried member that meets nothing is flexChecks' row.
|
|
335
|
+
...(flex.length && s.flexRoots?.some((r) => r.attached) ? { flexRoot: "0" } : {}),
|
|
329
336
|
...(expect?.[s.name] ?? {}),
|
|
330
337
|
};
|
|
331
338
|
// `accept` covers only the process's own default check: a cost metric the author
|
|
@@ -363,9 +370,48 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
363
370
|
checks.push(c);
|
|
364
371
|
}
|
|
365
372
|
}
|
|
373
|
+
checks.push(...flexChecks(facts, flex));
|
|
366
374
|
return checks;
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
// Flexing members (`verify.flex`, flex-root.js). The reading itself is the registry's
|
|
378
|
+
// `flexRoot` gate, one row per sub-part carrying a member that meets the rest of the
|
|
379
|
+
// part (evaluateCase's `merged`). What is left here is per DECLARED member, the cases
|
|
380
|
+
// where no sub-part row can answer for it: a seed that did not read the member
|
|
381
|
+
// (measuredFlex) answers unevaluated on a quick lap; a member no sub-part of this view
|
|
382
|
+
// carries is a skip, not a fail — a sub-part an `enabled()` turned off, or one shown
|
|
383
|
+
// only in another view — and lint's verify-flex-unknown-label is what catches a label
|
|
384
|
+
// no build ever writes; a member carried but attached nowhere (its label covers the
|
|
385
|
+
// whole sub-part, or the member never touches the body) withholds the verdict: a clean
|
|
386
|
+
// root and no root must never read alike.
|
|
387
|
+
export { FLEX_ROOT_HINT };
|
|
388
|
+
const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
|
|
389
|
+
function flexChecks(facts, flex) {
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const g of flex) {
|
|
392
|
+
const feature = groupName(g);
|
|
393
|
+
const base = { scope: "subpart", metric: "flexRoot", kind: "gate", feature, expr: "0", subpart: null, actual: null, status: "skip", pass: null };
|
|
394
|
+
if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
|
|
395
|
+
out.push({ ...base, unevaluated: true,
|
|
396
|
+
message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const rows = facts.subparts.flatMap((s) => (s.flexRoots ?? []).filter((r) => sameGroup(r.labels, g)));
|
|
400
|
+
if (!rows.length) {
|
|
401
|
+
out.push({ ...base,
|
|
402
|
+
message: `no sub-part in this view carries the label ${g.map((l) => `"${l}"`).join(" or ")}`,
|
|
403
|
+
hint: "verify.flex names flexing members by their feature label — label the member (`.label(\"Arm\")`) apart from the body it grows from, and spell it the same in both places" });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (!rows.some((r) => r.attached)) {
|
|
407
|
+
out.push({ ...base, unevaluated: true,
|
|
408
|
+
message: `"${feature}" never meets the rest of the part: its label covers every triangle it touches, so there is no root to read`,
|
|
409
|
+
hint: "label the flexing member apart from the body it grows from (`.label(\"Arm\")` on the arm before the union, not on the union), or, if the member really is a separate piece, it has no root and does not belong in verify.flex" });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
369
415
|
// `seed` lets a caller that has ALREADY measured this part hand the result in so
|
|
370
416
|
// verify does not recompute it — see the seeding block below for the shape and
|
|
371
417
|
// the one correctness rule that governs it.
|
|
@@ -391,6 +437,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
391
437
|
const bedContactFloor = bedContactFor(part, process, { expanded });
|
|
392
438
|
const footprintFloor = footprintFor(part, process, { expanded });
|
|
393
439
|
const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
|
|
440
|
+
const flex = flexGroups(part?.verify?.flex);
|
|
394
441
|
// Reuse rule (spec §4): case B may reuse a measurement taken at params A when
|
|
395
442
|
// B agrees with A on every param that measurement READ (recorded by measure()
|
|
396
443
|
// itself). Sound for the same reason the mesh cache is: a deterministic build
|
|
@@ -464,7 +511,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
464
511
|
// sizes cannot answer a bed, so it is admitted only when this run needs no bed.
|
|
465
512
|
const needPrintBboxes = profile?.bed != null;
|
|
466
513
|
const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
|
|
467
|
-
|
|
514
|
+
// The flex half: a seed that read every declared member (a superset is free).
|
|
515
|
+
const flexMatches = flex.every((g) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, g)));
|
|
516
|
+
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
|
|
468
517
|
&& seed.result.view === view) {
|
|
469
518
|
entries.push({ params: full(seed.params), result: seed.result });
|
|
470
519
|
}
|
|
@@ -478,7 +527,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
478
527
|
// for every case buys nothing. (A seed measured WITH probes is a superset in
|
|
479
528
|
// the same way a min-wall seed is: the extra key is simply never read here.)
|
|
480
529
|
const result = measureFn(kernel, part, view, params,
|
|
481
|
-
{ minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes });
|
|
530
|
+
{ minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes, flex });
|
|
482
531
|
entries.push({ params: fp, result });
|
|
483
532
|
return result;
|
|
484
533
|
};
|
|
@@ -499,7 +548,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
499
548
|
}];
|
|
500
549
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
501
550
|
const facts = measureCase(params);
|
|
502
|
-
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept }) : notMeasured(name) };
|
|
551
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex }) : notMeasured(name) };
|
|
503
552
|
});
|
|
504
553
|
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
505
554
|
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
@@ -534,7 +583,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
534
583
|
declared++;
|
|
535
584
|
if (c.status !== "skip" && !c.unevaluated) evaluated++;
|
|
536
585
|
}
|
|
537
|
-
const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null
|
|
586
|
+
const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null || flex.length > 0
|
|
538
587
|
|| expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
|
|
539
588
|
const failures = all.filter((c) => c.status === "fail");
|
|
540
589
|
const unevaluated = all.filter((c) => c.unevaluated);
|
|
@@ -95,6 +95,30 @@ export function sampleOpacity(keys, t) {
|
|
|
95
95
|
return keys[keys.length - 1][1];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The poses an animation passes through, for framing it whole: one
|
|
99
|
+
// { name → pose } map per sampled time, each pose the matrix `apply` would hand
|
|
100
|
+
// setSubPose (keyframed transform with the rest taken out). The times are every
|
|
101
|
+
// keyframe of every track and the midpoint of each gap between them (a rotation
|
|
102
|
+
// can bulge past both its keyframes on the way), thinned evenly to at most
|
|
103
|
+
// `max`, with t = 0 and the end always kept. A baked file samples densely, so
|
|
104
|
+
// that is close to every frame it draws. Untracked sub-parts are left out of
|
|
105
|
+
// every map (they sit at rest).
|
|
106
|
+
export function animationPoses(animation, { max = 128 } = {}) {
|
|
107
|
+
const { tracks = {}, rest = {} } = animation ?? {};
|
|
108
|
+
const names = Object.keys(tracks);
|
|
109
|
+
if (!names.length) return [];
|
|
110
|
+
const restInverse = Object.fromEntries(names.map((name) => [name, invertRigid(rest[name])]));
|
|
111
|
+
const all = new Set([0]);
|
|
112
|
+
for (const name of names) for (const t of tracks[name].times) if (Number.isFinite(t)) all.add(t);
|
|
113
|
+
const keys = [...all].sort((x, y) => x - y);
|
|
114
|
+
let times = keys.flatMap((t, i) => (i === 0 ? [t] : [(keys[i - 1] + t) / 2, t]));
|
|
115
|
+
if (times.length > max) {
|
|
116
|
+
const step = (times.length - 1) / (max - 1);
|
|
117
|
+
times = Array.from({ length: max }, (_, i) => times[Math.round(i * step)]);
|
|
118
|
+
}
|
|
119
|
+
return times.map((seconds) => Object.fromEntries(names.map((name) => [name, mulMat4(sampleTrack(tracks[name], seconds), restInverse[name])])));
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
export function createPreviewPlayer(viewer, animation, { reducedMotion = false } = {}) {
|
|
99
123
|
const { duration, tracks, rest, opacity = {} } = animation;
|
|
100
124
|
const steps = animation.steps ?? [];
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
5
5
|
// [x,y,z] source), `note` (optional caveat about HOW the value was measured,
|
|
6
6
|
// attached whatever the status — a passing-but-sampled reading is exactly the
|
|
7
|
-
// case a reader needs told about)
|
|
7
|
+
// case a reader needs told about), `feature` (optional: the labelled feature the
|
|
8
|
+
// reading is about, stamped whatever the status), `unavailable` (+ `unavailableHint`:
|
|
9
|
+
// a null reading is a finding to warn about, not an inert skip). `manifoldOnly`
|
|
10
|
+
// facts are null on OCCT parts.
|
|
8
11
|
//
|
|
9
12
|
// This lives one level above framework/oracle/ deliberately: the set of legal
|
|
10
13
|
// `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
|
|
@@ -63,6 +66,13 @@ export function sidesSpecError(metric, reg, spec) {
|
|
|
63
66
|
return null;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// The flexing-member root (oracle/flex-root.js): what the failure means and what fixes it.
|
|
70
|
+
export const FLEX_ROOT_HINT = "a part that flexes (a snap arm, buckle prong, clip or cantilever tab) meets the rest of the part at a sharp inside corner here — bending concentrates the stress at that corner and the member snaps off at its root. Round the root: a fillet of at least half the member's thickness where it joins the body (`s.fillet({ r, edges: { near: [x, y, z] } })` on the joined solid, or a filleted corner in the 2-D profile both share), and keep that fillet clear of whatever the member slides into. A grip rib or barb on the member is part of it: list its label with the member's, `flex: [[\"Arm\", \"Arm rib\"]]`";
|
|
71
|
+
// The member whose root is worst on a sub-part, among those that meet the rest of it;
|
|
72
|
+
// null when none does (nothing declared, no label carried, or every member detached).
|
|
73
|
+
const worstFlexRoot = (s) => (s.flexRoots ?? []).filter((r) => r.attached)
|
|
74
|
+
.reduce((w, r) => (w == null || r.sharpLength > w.sharpLength ? r : w), null);
|
|
75
|
+
|
|
66
76
|
export const SUBPART_METRICS = {
|
|
67
77
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
68
78
|
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
@@ -132,6 +142,27 @@ export const SUBPART_METRICS = {
|
|
|
132
142
|
hint: "the share of this piece's silhouette on the bed (its outline in the print pose) that actually touches it is small — it stands on a point, a line or a curve, which reads near zero, so the first layer has almost nothing to hold on to and the piece can tip or pull loose. Lay it on its flat face, add a flat foot or pad at its lowest point, or print it with a brim (accept: [\"footprint\"] records that choice)",
|
|
133
143
|
pattern: "piece-stands-on-nothing",
|
|
134
144
|
locate: (s) => s.footprintAt },
|
|
145
|
+
// A declared flexing member's root (oracle/flex-root.js, `verify.flex`): the length in
|
|
146
|
+
// mm of sharp inside corner where the member meets the rest of the sub-part. One row
|
|
147
|
+
// per sub-part, like every print metric: `actual` is the WORST member's reading,
|
|
148
|
+
// `feature` names it, and `note` lists every member the sub-part carries. Measured on
|
|
149
|
+
// a sub-part carrying a declared label; elsewhere `extract` is null — "no root to
|
|
150
|
+
// read" rather than an inert skip, since an authored expectation with nothing
|
|
151
|
+
// declared, or on a member that never meets the body, is a mistake to point at. A
|
|
152
|
+
// GATE under any process or none: the root is the same in any orientation and the
|
|
153
|
+
// stress concentration is the same in any material.
|
|
154
|
+
flexRoot: { kind: "gate", extract: (s) => worstFlexRoot(s)?.sharpLength ?? null,
|
|
155
|
+
hint: FLEX_ROOT_HINT,
|
|
156
|
+
pattern: "flex-root-sharp",
|
|
157
|
+
locate: (s) => worstFlexRoot(s)?.at ?? null,
|
|
158
|
+
feature: (s) => worstFlexRoot(s)?.feature ?? null,
|
|
159
|
+
unavailable: "no root to read",
|
|
160
|
+
unavailableHint: "flexRoot reads the root of a member named in `verify.flex` — declare the flexing member there by its feature label, and label it (`.label(\"Arm\")`) apart from the body it grows from, so this sub-part carries a member that meets the rest of the part",
|
|
161
|
+
note: (s) => {
|
|
162
|
+
const rows = s.flexRoots ?? [];
|
|
163
|
+
if (!rows.length) return null;
|
|
164
|
+
return rows.map((r) => `"${r.feature}": ${!r.attached ? "never meets the rest of this sub-part" : r.sharpLength > 0 ? `${r.sharpLength.toFixed(1)} mm of sharp inside corner (${Math.round(r.maxTurn)}°)` : "rounded root"}`).join("; ");
|
|
165
|
+
} },
|
|
135
166
|
// `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
|
|
136
167
|
// "<import name>"`; on every other sub-part `extract` returns null, which
|
|
137
168
|
// `check()` already reports as status "skip" rather than a fail — the
|
package/src/framework/viewer.js
CHANGED
|
@@ -179,9 +179,14 @@ export function captureCurrentFromScene(
|
|
|
179
179
|
// `edgeWidth` (CSS px, default 1) and `edgeOpacity` (0 < o <= 1, default 1)
|
|
180
180
|
// style the CAD feature edge lines; a small preview reads them as heavy at the
|
|
181
181
|
// default. Fades multiply into the opacity rather than replacing it.
|
|
182
|
+
// `framePoses` (with `frameFill`, not `frameSwept`) is a list of
|
|
183
|
+
// { sub-part name → pose matrix } maps — the poses an animation passes through
|
|
184
|
+
// (preview-player's animationPoses) — and the fill framing then fits the model
|
|
185
|
+
// in every one of them as well as as shown, so a part that swings out of its
|
|
186
|
+
// first pose's outline stays in frame.
|
|
182
187
|
export function createViewer(container, part, {
|
|
183
188
|
transparent = false, contactShadow = false, zoom = true, pan = true, frameFill = null, frameSwept = false,
|
|
184
|
-
edgeWidth = 1, edgeOpacity = 1,
|
|
189
|
+
edgeWidth = 1, edgeOpacity = 1, framePoses = null,
|
|
185
190
|
} = {}) {
|
|
186
191
|
const names = Object.keys(part.parts);
|
|
187
192
|
// Membership for host-supplied names. subMesh, cadMats and part.parts are
|
|
@@ -2056,11 +2061,54 @@ export function createViewer(container, part, {
|
|
|
2056
2061
|
}
|
|
2057
2062
|
if (n > 0) center.set(sx / n, sy / n, center.z);
|
|
2058
2063
|
}
|
|
2064
|
+
// A sub-part's outermost vertices along SUPPORT_DIRECTIONS (deduplicated, in
|
|
2065
|
+
// its delivered frame): the points that decide its outline from any angle, to
|
|
2066
|
+
// within a sliver. Framing the extra `framePoses` reads these, not every
|
|
2067
|
+
// vertex, so a long animation of a dense model frames in a few milliseconds.
|
|
2068
|
+
const SUPPORT_DIRECTIONS = (() => {
|
|
2069
|
+
const out = [], n = 64, golden = Math.PI * (3 - Math.sqrt(5));
|
|
2070
|
+
for (let i = 0; i < n; i++) {
|
|
2071
|
+
const y = 1 - (2 * (i + 0.5)) / n, r = Math.sqrt(1 - y * y), a = golden * i;
|
|
2072
|
+
out.push([r * Math.cos(a), y, r * Math.sin(a)]);
|
|
2073
|
+
}
|
|
2074
|
+
return out;
|
|
2075
|
+
})();
|
|
2076
|
+
const supportCache = new WeakMap();
|
|
2077
|
+
function supportPoints(geometry) {
|
|
2078
|
+
let points = supportCache.get(geometry);
|
|
2079
|
+
if (points) return points;
|
|
2080
|
+
const pos = geometry.getAttribute("position");
|
|
2081
|
+
const best = SUPPORT_DIRECTIONS.map(() => -Infinity), at = SUPPORT_DIRECTIONS.map(() => -1);
|
|
2082
|
+
for (let i = 0; i < (pos?.count ?? 0); i++) {
|
|
2083
|
+
_sweepV.fromBufferAttribute(pos, i);
|
|
2084
|
+
for (let k = 0; k < SUPPORT_DIRECTIONS.length; k++) {
|
|
2085
|
+
const [dx, dy, dz] = SUPPORT_DIRECTIONS[k];
|
|
2086
|
+
const d = _sweepV.x * dx + _sweepV.y * dy + _sweepV.z * dz;
|
|
2087
|
+
if (d > best[k]) { best[k] = d; at[k] = i; }
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
points = [...new Set(at.filter((i) => i >= 0))].map((i) => new THREE.Vector3().fromBufferAttribute(pos, i));
|
|
2091
|
+
supportCache.set(geometry, points);
|
|
2092
|
+
return points;
|
|
2093
|
+
}
|
|
2094
|
+
const _framePose = new THREE.Matrix4();
|
|
2059
2095
|
// Every posed vertex of `names` about `center`, stood up by the pivot, as
|
|
2060
|
-
// flat x/y/z arrays (world units, pivot-relative)
|
|
2096
|
+
// flat x/y/z arrays (world units, pivot-relative) — plus, with `framePoses`,
|
|
2097
|
+
// each sub-part's support points in every one of those poses.
|
|
2061
2098
|
function posedVertices(names, center) {
|
|
2062
2099
|
let n = 0;
|
|
2063
2100
|
for (const name of names) n += subCache[name]?.getAttribute("position")?.count ?? 0;
|
|
2101
|
+
const extra = [];
|
|
2102
|
+
for (const poses of framePoses ?? []) {
|
|
2103
|
+
for (const name of names) {
|
|
2104
|
+
if (!subCache[name]) continue;
|
|
2105
|
+
const pose = poses[name] ? _framePose.fromArray(poses[name]) : subMesh[name].matrix;
|
|
2106
|
+
for (const p of supportPoints(subCache[name])) {
|
|
2107
|
+
extra.push(p.clone().applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion));
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
n += extra.length;
|
|
2064
2112
|
const px = new Float64Array(n), py = new Float64Array(n), pz = new Float64Array(n);
|
|
2065
2113
|
let k = 0;
|
|
2066
2114
|
for (const name of names) {
|
|
@@ -2072,6 +2120,7 @@ export function createViewer(container, part, {
|
|
|
2072
2120
|
px[k] = _sweepV.x; py[k] = _sweepV.y; pz[k] = _sweepV.z;
|
|
2073
2121
|
}
|
|
2074
2122
|
}
|
|
2123
|
+
for (const p of extra) { px[k] = p.x; py[k] = p.y; pz[k] = p.z; k++; }
|
|
2075
2124
|
return { px, py, pz, n };
|
|
2076
2125
|
}
|
|
2077
2126
|
|
package/types/index.d.ts
CHANGED
|
@@ -846,8 +846,9 @@ export interface MountPreviewOptions {
|
|
|
846
846
|
* clips and a round part still fills, centred on its horizontal vertex centroid (a
|
|
847
847
|
* lopsided part turns about its hub). Without a turntable (0.169.0+) the posed mesh
|
|
848
848
|
* vertices are fitted and their on-screen outline is centred (the target moves in the
|
|
849
|
-
* screen plane), spanning `fill`; a baked animation
|
|
850
|
-
* pose
|
|
849
|
+
* screen plane), spanning `fill`; a playing baked animation (0.171.0+) is fitted over
|
|
850
|
+
* every pose it passes through, so a part that swings out of its first pose's outline
|
|
851
|
+
* stays in frame and all its poses together span `fill`. Unset, the
|
|
851
852
|
* fixed framing distance. Since 0.169.0 a turntable mount is framed swept under reduced
|
|
852
853
|
* motion too, so every visitor sees the same framing.
|
|
853
854
|
*/
|
package/types/part.d.ts
CHANGED
|
@@ -581,6 +581,12 @@ export interface SubPartExpectations {
|
|
|
581
581
|
* a cost (`verify.costs`), never a failure; `accept: ["footprint"]` records a brim.
|
|
582
582
|
*/
|
|
583
583
|
footprintPct?: Expectation;
|
|
584
|
+
/**
|
|
585
|
+
* Length (mm) of sharp inside corner where a flexing member named in `verify.flex`
|
|
586
|
+
* meets the rest of this sub-part — `actual` is the worst member's, `feature` names
|
|
587
|
+
* it. Every sub-part carrying a declared member gets `"0"` by default; a gate.
|
|
588
|
+
*/
|
|
589
|
+
flexRoot?: Expectation;
|
|
584
590
|
/** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
|
|
585
591
|
refXorVolume?: Expectation;
|
|
586
592
|
/** Percent volume delta vs. the sub-part's declared `reference` import. */
|
|
@@ -660,6 +666,15 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
|
|
|
660
666
|
* (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
|
|
661
667
|
*/
|
|
662
668
|
accept?: ("supports" | "footprint")[];
|
|
669
|
+
/**
|
|
670
|
+
* The part's flexing members — snap arms, buckle prongs, clips, cantilever tabs —
|
|
671
|
+
* by feature label. Each must meet the rest of its part through a rounded root:
|
|
672
|
+
* verify fails on a sharp inside corner where a triangle of the member meets a
|
|
673
|
+
* triangle of anything else. An entry that is a list names one member made of
|
|
674
|
+
* several labels (an arm and the rib on it). Label the member apart from the body
|
|
675
|
+
* it grows from.
|
|
676
|
+
*/
|
|
677
|
+
flex?: (string | string[])[];
|
|
663
678
|
/** Which cases to check; default is `"defaults"` plus every preset name. */
|
|
664
679
|
cases?: string[];
|
|
665
680
|
/**
|
package/types/testing.d.ts
CHANGED
|
@@ -441,6 +441,13 @@ export interface SubPartFacts {
|
|
|
441
441
|
footprintPct: number | null;
|
|
442
442
|
/** The piece's lowest point, in display coordinates; `null` when not checked. */
|
|
443
443
|
footprintAt: number[] | null;
|
|
444
|
+
/**
|
|
445
|
+
* The root of each flexing member (`verify.flex`) this sub-part carries, one entry per
|
|
446
|
+
* declared member group whose label its mesh holds; `null` when none is declared, or on
|
|
447
|
+
* a sheet part. `attached` is false when the member shares no edge with the rest of the
|
|
448
|
+
* mesh (nothing to read); `sharpLength` the mm of sharp inside corner at its root.
|
|
449
|
+
*/
|
|
450
|
+
flexRoots: { feature: string; labels: string[]; found: boolean; attached: boolean; sharpLength: number; maxTurn: number | null; at: number[] | null }[] | null;
|
|
444
451
|
/**
|
|
445
452
|
* Present only when bed contact had to be read off the piece's export build (its
|
|
446
453
|
* print pose could not be read from place() alone) and that build failed.
|