partforge 0.171.0 → 0.173.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +105 -10
- package/docs/ERROR-PATTERNS.md +18 -0
- package/package.json +1 -1
- package/src/framework/lint/rules-verify.js +68 -1
- package/src/framework/oracle/dfm-profiles.js +55 -0
- package/src/framework/oracle/flex-members.js +35 -0
- package/src/framework/oracle/flex-root.js +308 -0
- package/src/framework/oracle/measure.js +29 -3
- package/src/framework/oracle/verify.js +82 -11
- package/src/framework/process/fdm/descriptor.js +13 -1
- package/src/framework/process/resin/descriptor.js +3 -1
- package/src/framework/verify-metrics.js +73 -1
- package/types/part.d.ts +50 -1
- package/types/testing.d.ts +16 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1953,9 +1953,9 @@ 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 |
|
|
1957
|
-
| **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`) | never |
|
|
1958
|
-
| **advisory** — a reading that may not matter | min wall (`minWall`), `wall` | never; a warning |
|
|
1956
|
+
| **gate** — a defect in any orientation | bed fit (`bbox` within `bed`), bed contact (`bedContactPct` ≥ 50), a declared flexing member's root fillet (`flexRoot` ≥ half its thickness) | fails it |
|
|
1957
|
+
| **cost** — a design choice with a price | overhang (`overhangArea`, id `supports`), footprint (`footprintPct` ≥ 10, id `footprint`), a flexing member printed standing (`flexTilt` ≤ 45°, id `standing`) | never |
|
|
1958
|
+
| **advisory** — a reading that may not matter | min wall (`minWall`), `wall`, a flexing member's root strain at its declared deflection (`flexStrain`) | never; a warning |
|
|
1959
1959
|
|
|
1960
1960
|
A **gate** is fixed before the work is done: the piece cannot be printed this way
|
|
1961
1961
|
whatever the user wants. A **cost** is a fact with a number and a location — mm² of
|
|
@@ -1976,7 +1976,7 @@ A threshold the user named ("under 500 mm² of supports") is an `expect`:
|
|
|
1976
1976
|
`overhangArea: "<=500"` — still a cost, reported in `costs` and never failing `ok`;
|
|
1977
1977
|
the user's own limit keeps its meaning, so it is never marked accepted, whatever
|
|
1978
1978
|
`accept` says (`accept` covers only the process's default check). Lint refuses an `accept` entry the process does not define
|
|
1979
|
-
(`verify-unknown-accept`): `fdm` defines `supports` and `
|
|
1979
|
+
(`verify-unknown-accept`): `fdm` defines `supports`, `footprint` and `standing`; `resin` prints on
|
|
1980
1980
|
supports and defines none.
|
|
1981
1981
|
|
|
1982
1982
|
**What arms what.** Under `fdm-pla`/`fdm-petg` the profile alone arms every check in
|
|
@@ -2015,6 +2015,68 @@ 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 — fails in four ways, and the first three read off the
|
|
2022
|
+
same declaration. It must meet the body it grows from through a **fillet at its root of
|
|
2023
|
+
at least half its thickness**: bending puts the highest stress at the root, a sharp
|
|
2024
|
+
inside corner multiplies it several times over, and an FDM print's layers are weakest
|
|
2025
|
+
across exactly that corner, so an unrounded arm snaps off flush with the body, often on
|
|
2026
|
+
the first few flexes. Decide the root fillet with the arm, not after it, and keep it
|
|
2027
|
+
clear of whatever the arm slides into (chamfer that mouth so the fillet seats). Its
|
|
2028
|
+
**root strain** at the travel it is designed for must stay under what the material takes
|
|
2029
|
+
— for a straight cantilever that is 1.5 × thickness × deflection / length², so a short,
|
|
2030
|
+
thick arm with a deep undercut yields or cracks whatever its root looks like: lengthen
|
|
2031
|
+
it, thin it, shorten the travel, or taper it (half the thickness at the tip bends about
|
|
2032
|
+
60% further). It must **lie flat on the bed**: an arm standing up in the print pose has
|
|
2033
|
+
its layers stacked across its root, and a layer bond is a fraction of the in-plane
|
|
2034
|
+
strength. And it must not be thinner than the process prints (`minWall`, as for anything).
|
|
2035
|
+
|
|
2036
|
+
Declare every such member in `verify.flex`, by its feature label, and label the member
|
|
2037
|
+
**apart from the body** it grows from:
|
|
2038
|
+
|
|
2039
|
+
```js
|
|
2040
|
+
const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
|
|
2041
|
+
let s = k.union([base, arm, otherArm])
|
|
2042
|
+
.fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
|
|
2043
|
+
// …
|
|
2044
|
+
verify: {
|
|
2045
|
+
process: "fdm-pla",
|
|
2046
|
+
flex: [
|
|
2047
|
+
{ feature: "Right prong", deflection: p.undercut }, // the travel it snaps through
|
|
2048
|
+
{ feature: "Left prong", deflection: p.undercut },
|
|
2049
|
+
["Latch", "Latch barb"], // one member made of two labels
|
|
2050
|
+
],
|
|
2051
|
+
expect: { … },
|
|
2052
|
+
},
|
|
2053
|
+
```
|
|
2054
|
+
|
|
2055
|
+
An entry is a label, a list of labels that make one member (an arm and the rib or barb
|
|
2056
|
+
on it, which otherwise counts as "the rest of the part"), or an object with `feature`,
|
|
2057
|
+
optional `labels`, the `deflection` in mm that arms the strain reading, and a
|
|
2058
|
+
`thickness` in mm when the mesh would misread the bending thickness (the member's
|
|
2059
|
+
smallest extent, which a wide hook or a tapered arm can fool). verify then reports, per
|
|
2060
|
+
sub-part carrying a member:
|
|
2061
|
+
|
|
2062
|
+
- `flexRoot` — the root's fillet radius in mm, 0 where any of it is sharp, against the
|
|
2063
|
+
process's `rootFillet` share of the member's thickness (0.5 everywhere). A gate,
|
|
2064
|
+
under any process or none: a fillet's faces are tangent where they meet the member
|
|
2065
|
+
and the body, a face that only continues flush into the body is not a corner, and a
|
|
2066
|
+
45° chamfer is still a corner.
|
|
2067
|
+
- `flexStrain` — the root strain in percent at the declared deflection, against the
|
|
2068
|
+
process's `strain` (1.5 on PLA, 3 on PETG, 2 on resin). An advisory, since the
|
|
2069
|
+
cantilever formula is a first approximation; a member with no `deflection` is not read.
|
|
2070
|
+
- `flexTilt` — the degrees the member's long axis rises off the bed in the print pose,
|
|
2071
|
+
against the FDM profiles' `flexTilt` (45°). A cost, `standing`, that the user may
|
|
2072
|
+
accept (`accept: ["standing"]`); resin is isotropic and never asks.
|
|
2073
|
+
|
|
2074
|
+
Only what you declare is checked: the oracle cannot tell an arm that bends from a wall,
|
|
2075
|
+
a tooth or a pin of the same shape. A name no build labels is lint's
|
|
2076
|
+
`verify-flex-unknown-label` warning, a member no sub-part of the verified view carries is
|
|
2077
|
+
reported as skipped, and a member whose label meets nothing (the label put on the whole
|
|
2078
|
+
union) withholds the verdict.
|
|
2079
|
+
|
|
2018
2080
|
### Small features and scale
|
|
2019
2081
|
|
|
2020
2082
|
A feature thinner than the profile's `minWall` (1.2 mm PLA, 1.5 mm PETG, 0.6 mm
|
|
@@ -2028,7 +2090,8 @@ in the control's label ("below 150 mm the pins get thin").
|
|
|
2028
2090
|
Size a mating feature from the profile's fit class — `press`, `push`, `slip`,
|
|
2029
2091
|
`moving`, `printInPlace` — copied into `derive()` with a comment naming it
|
|
2030
2092
|
(`// 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
|
|
2093
|
+
snap is a contact; its fit is `push` or `slip`, and its arm gets a root fillet
|
|
2094
|
+
("Flexing members" above). The numbers, and how a diametral fit
|
|
2032
2095
|
becomes a `clearance` expectation, are in the `verify` section below.
|
|
2033
2096
|
|
|
2034
2097
|
### First layer
|
|
@@ -4132,8 +4195,9 @@ runtime a throwing probe only degrades quietly to `{ error }`.
|
|
|
4132
4195
|
|
|
4133
4196
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
4134
4197
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
4135
|
-
`verify-unknown-orientation`, `verify-unknown-accept`,
|
|
4136
|
-
`verify-expect-throws` (all errors)
|
|
4198
|
+
`verify-unknown-orientation`, `verify-unknown-accept`, `verify-bad-flex`,
|
|
4199
|
+
`verify-expect-throws` (all errors), and `verify-flex-unknown-label` (a warning: a
|
|
4200
|
+
`verify.flex` label no build writes at the default parameters). Note `_view` also accepts the pair-wise
|
|
4137
4201
|
`contacts` / `clearance` / `held` keys, which are not scalar view metrics; they are
|
|
4138
4202
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
4139
4203
|
|
|
@@ -4435,8 +4499,9 @@ A part can declare how it should be checked, co-located with its schema, so
|
|
|
4435
4499
|
```js
|
|
4436
4500
|
verify: {
|
|
4437
4501
|
process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an inline
|
|
4438
|
-
// { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge
|
|
4439
|
-
|
|
4502
|
+
// { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge,
|
|
4503
|
+
// rootFillet, strain, flexTilt } object
|
|
4504
|
+
accept: ["supports"], // optional; the costs the user chose to pay (fdm: supports, footprint, standing)
|
|
4440
4505
|
orientation: "print", // optional; declares build() is the print pose (Z up, bed at
|
|
4441
4506
|
// the lowest Z) — with no profile it arms bed contact
|
|
4442
4507
|
cases: ["defaults", "M3"], // optional; default = defaults + every preset
|
|
@@ -4534,6 +4599,33 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
|
|
|
4534
4599
|
does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
|
|
4535
4600
|
sub-parts are not judged.
|
|
4536
4601
|
|
|
4602
|
+
**Flexing members — `flexRoot` a GATE armed by `verify.flex` alone, `flexStrain` an
|
|
4603
|
+
advisory from a declared deflection, `flexTilt` the `standing` cost under FDM.** `flex`
|
|
4604
|
+
lists the part's flexing members by feature label (`flex: ["Right prong", "Left prong"]`;
|
|
4605
|
+
a list entry, `["Arm", "Arm rib"]`, is one member made of several labels; an object,
|
|
4606
|
+
`{ feature: "Arm", deflection: 1.2, thickness: 2 }`, adds the travel in mm the member
|
|
4607
|
+
is designed to flex through and, when the mesh would misread it, its bending
|
|
4608
|
+
thickness). Every printed sub-part whose mesh carries a member's label gets one row per
|
|
4609
|
+
reading (your own expectation on that sub-part replaces the default): `flexRoot` is the
|
|
4610
|
+
root fillet radius in mm of the member whose root is worst (least radius over what it
|
|
4611
|
+
needs), 0 where any of it is sharp — a concave edge turning more than 30° between one of
|
|
4612
|
+
its triangles and one that is not — against `>=` the profile's `rootFillet` share of that
|
|
4613
|
+
member's thickness (`feature` names the member, `location` is the longest sharp stretch,
|
|
4614
|
+
`note` lists every member the sub-part carries with its radius and need); `flexStrain`
|
|
4615
|
+
is the worst member's root strain in percent at its declared deflection, 1.5 × thickness ×
|
|
4616
|
+
deflection / length² for a straight cantilever, against `<=` the profile's `strain`;
|
|
4617
|
+
`flexTilt` is the degrees the worst member's long axis rises off the bed in the print
|
|
4618
|
+
pose, against `<=` the profile's `flexTilt`, a cost (`standing`). The root and strain
|
|
4619
|
+
readings are independent of the process and of the print pose; the tilt is read in the
|
|
4620
|
+
print pose, like bed contact, and withheld when that pose cannot be read. Three
|
|
4621
|
+
declaration problems are reported per member rather than per sub-part: a member no
|
|
4622
|
+
sub-part of the verified view carries is skipped (it may be switched off in that case);
|
|
4623
|
+
a member carried but meeting nothing — its label put on the whole union, or a piece
|
|
4624
|
+
that never touches the body — withholds the verdict (`unevaluated`), since a clean root
|
|
4625
|
+
and no root must never read alike; and a seed that did not read a member answers
|
|
4626
|
+
unevaluated on a quick lap. See "Flexing members" under "Printed parts" for why, and
|
|
4627
|
+
how to round a root.
|
|
4628
|
+
|
|
4537
4629
|
**What the profile tells you.** Three more fields carry the numbers a mating
|
|
4538
4630
|
feature needs. Nothing checks them; they are the one place the value is spelled,
|
|
4539
4631
|
so `derive()` copies it with a comment naming the fit (`// slip fit: fdm-pla 0.25`).
|
|
@@ -4552,13 +4644,16 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
|
|
|
4552
4644
|
| `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
|
|
4553
4645
|
| `holeUndersize` | 0.25 | 0.30 | 0.05 |
|
|
4554
4646
|
| `bridge` (mm) | 10 | 10 | — |
|
|
4647
|
+
| `rootFillet` — a flexing member's root fillet, as a share of its thickness (a gate) | 0.5 | 0.5 | 0.5 |
|
|
4648
|
+
| `strain` — the root strain (%) a one-time snap may reach (an advisory) | 1.5 | 3 | 2 |
|
|
4649
|
+
| `flexTilt` — the steepest a flexing member may stand off the bed, degrees (a cost, `standing`) | 45 | 45 | — |
|
|
4555
4650
|
|
|
4556
4651
|
`fits` are diametral; the `clearance` expectation below reads a pair's minimum surface distance, so a pin in a bore is declared at half the fit (`fits.slip` 0.25 → `clearance: { "pin×body": "0.08..0.18" }`).
|
|
4557
4652
|
|
|
4558
4653
|
**What `expect` gives you:** per-sub-part
|
|
4559
4654
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4560
4655
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
4561
|
-
`overhangArea`, `bedContactPct`, `footprintPct`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4656
|
+
`overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `flexStrain`, `flexTilt`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4562
4657
|
positions, written in the same `{ min, max }` shape `measure` reports them — where
|
|
4563
4658
|
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4564
4659
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -234,6 +234,24 @@ 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": sharp root (90°, 12.2 mm of corner)` or `"Right prong": root radius 0.4 mm, needs ≥ 1 (2 mm thick)`; 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, or through a fillet smaller than the process's `rootFillet` share of its thickness (half, on every built-in profile). 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"]]`. If the mesh misreads the member's bending thickness (a wide hook reads its width), declare it: `flex: [{ feature: "Arm", thickness: 2 }]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
242
|
+
|
|
243
|
+
## flex-strain-over
|
|
244
|
+
|
|
245
|
+
- **Symptom:** A `⚠ … flexStrain` warning from `verify`, e.g. `"Arm": 2.67% at 2 mm (15 mm long, 2 mm thick)` against `<=1.5`; or, in use, a snap arm that whitens, takes a set or cracks at its root the first time it is pressed home.
|
|
246
|
+
- **Cause:** At the deflection declared for it (`flex: [{ feature, deflection }]`) the member's root strain — 1.5 × thickness × deflection / length² for a straight cantilever — is over what the process's material takes for a one-time snap (`strain`: 1.5% PLA, 3% PETG, 2% resin). A short, thick arm with a deep undercut is the usual shape.
|
|
247
|
+
- **Fix:** Lengthen the arm, thin it, or reduce its travel (a shallower undercut; a lead-in chamfer so the snap spreads over more of the stroke). Tapering the arm to half its thickness at the tip lets it bend about 60% further at the same root strain. Switch to PETG where the design is fixed. The formula is a first approximation — a curved or hooked member bends differently — so declare the real bending `thickness` when the mesh misreads it. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
248
|
+
|
|
249
|
+
## flex-member-standing
|
|
250
|
+
|
|
251
|
+
- **Symptom:** A `flexTilt` cost from `verify` under an FDM profile, e.g. `"Arm": 90° off the bed` against `<=45`; or, in use, a snap arm that breaks cleanly along a layer line at its root.
|
|
252
|
+
- **Cause:** In the print pose the member's long axis stands up off the bed, so its layers are stacked across its root; a layer bond is a fraction of the in-plane strength, and bending loads exactly that bond.
|
|
253
|
+
- **Fix:** Pose the piece in `place(..., { purpose: "export" })` so the arm lies along the bed (its root corner then sits within one layer), or split the part so the arm prints flat and joins afterwards. A standing arm the user keeps is a cost they accept (`accept: ["standing"]`): use PETG or anneal, and round the root generously. Resin is isotropic and never reports this. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
254
|
+
|
|
237
255
|
## expect-static-across-presets
|
|
238
256
|
|
|
239
257
|
- **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,10 +2,11 @@
|
|
|
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";
|
|
9
|
+
import { flexMembers } from "../oracle/flex-members.js";
|
|
9
10
|
import { suggest } from "../geometry/op-options.js";
|
|
10
11
|
import { PRINT_PROCESSES, PROCESS_IDS, printProcessFor } from "../process/registry.js";
|
|
11
12
|
import { SHEET_DOC_ID } from "../sheet/constants.js";
|
|
@@ -447,4 +448,70 @@ export const VERIFY_RULES = [
|
|
|
447
448
|
return out;
|
|
448
449
|
},
|
|
449
450
|
},
|
|
451
|
+
{
|
|
452
|
+
// `verify.flex` names the part's flexing members by feature label (oracle/flex-root.js):
|
|
453
|
+
// a list whose entries are a label, a list of labels read as one member, or a member
|
|
454
|
+
// object `{ feature, labels?, deflection?, thickness? }`. verify() drops a malformed
|
|
455
|
+
// entry silently, so the shape is checked here.
|
|
456
|
+
id: "verify-bad-flex",
|
|
457
|
+
run: ({ part }) => {
|
|
458
|
+
const flex = part?.verify?.flex;
|
|
459
|
+
if (flex == null) return [];
|
|
460
|
+
const form = "Write `flex` as a list of flexing members, each a feature label (`\"Left arm\"`), a list of labels that make one member (`[\"Arm\", \"Arm rib\"]`), or an object `{ feature: \"Arm\", labels?: [...], deflection?: <mm of travel>, thickness?: <mm> }`.";
|
|
461
|
+
if (!Array.isArray(flex)) {
|
|
462
|
+
return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of flexing members`, form, "verify.flex")];
|
|
463
|
+
}
|
|
464
|
+
const label = (l) => typeof l === "string" && l.length > 0;
|
|
465
|
+
const labels = (ls) => Array.isArray(ls) && ls.length > 0 && ls.every(label);
|
|
466
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
467
|
+
const out = [];
|
|
468
|
+
flex.forEach((e, i) => {
|
|
469
|
+
if (label(e) || labels(e)) return;
|
|
470
|
+
const at = `verify.flex[${i}]`;
|
|
471
|
+
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
|
472
|
+
out.push(err("verify-bad-flex", `\`${at}\` is ${describe(e)}, not a feature label, a list of them or a member object`, form, at));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!label(e.feature)) out.push(err("verify-bad-flex", `\`${at}.feature\` is ${describe(e.feature)}, not a feature label`, form, `${at}.feature`));
|
|
476
|
+
if (e.labels !== undefined && !labels(e.labels)) out.push(err("verify-bad-flex", `\`${at}.labels\` is ${describe(e.labels)}, not a list of feature labels`, form, `${at}.labels`));
|
|
477
|
+
for (const k of ["deflection", "thickness"]) {
|
|
478
|
+
if (e[k] !== undefined && !num(e[k])) out.push(err("verify-bad-flex", `\`${at}.${k}\` is ${describe(e[k])}, not a positive number of mm`, form, `${at}.${k}`));
|
|
479
|
+
}
|
|
480
|
+
for (const k of Object.keys(e)) {
|
|
481
|
+
if (!["feature", "labels", "deflection", "thickness"].includes(k)) out.push(err("verify-bad-flex", `\`${at}.${k}\` is not a flexing-member field`, form, `${at}.${k}`));
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
return out;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
// A flex label no build writes is a check that never runs: verify skips a member no
|
|
489
|
+
// sub-part carries (it may be switched off in that case), so a typo would pass
|
|
490
|
+
// silently. The geometry-free probe records every `.label()` call at the default
|
|
491
|
+
// params; a label set only under other params reads as missing, hence a warning.
|
|
492
|
+
id: "verify-flex-unknown-label",
|
|
493
|
+
run: ({ part, probe }) => {
|
|
494
|
+
const flex = part?.verify?.flex;
|
|
495
|
+
if (!Array.isArray(flex) || !flex.length) return [];
|
|
496
|
+
const run = probe();
|
|
497
|
+
if (run.runaway || run.throws.length) return []; // an unfinished build saw only some labels
|
|
498
|
+
const seen = new Set(run.calls.filter((c) => c.op === "label")
|
|
499
|
+
.map((c) => { try { return JSON.parse(c.args[0]); } catch { return null; } })
|
|
500
|
+
.filter((l) => typeof l === "string"));
|
|
501
|
+
const known = [...seen];
|
|
502
|
+
const out = [];
|
|
503
|
+
flex.forEach((e, i) => {
|
|
504
|
+
const [m] = flexMembers([e]); // a bad shape is verify-bad-flex's
|
|
505
|
+
for (const l of m?.labels ?? []) {
|
|
506
|
+
if (seen.has(l)) continue;
|
|
507
|
+
const hint = safeSuggest(l, known);
|
|
508
|
+
out.push(warn("verify-flex-unknown-label",
|
|
509
|
+
`\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
|
|
510
|
+
`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.`,
|
|
511
|
+
`verify.flex[${i}]`));
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
return out;
|
|
515
|
+
},
|
|
516
|
+
},
|
|
450
517
|
];
|
|
@@ -109,3 +109,58 @@ export function footprintFor(part, process, { expanded = [] } = {}) {
|
|
|
109
109
|
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "footprintPct" in o));
|
|
110
110
|
return asserted ? DEFAULT_FOOTPRINT : null;
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
// ── flexing members (`verify.flex`, oracle/flex-root.js) ─────────────────────
|
|
114
|
+
// The root fillet a flexing member needs, as a share of its thickness; the strain its
|
|
115
|
+
// material takes at the root for a one-time snap, in percent; the steepest its long
|
|
116
|
+
// axis may rise off the bed, in degrees. Guide defaults for a part that names no
|
|
117
|
+
// profile — the root rule and the strain rule hold in any material.
|
|
118
|
+
export const DEFAULT_ROOT_FILLET = 0.5;
|
|
119
|
+
export const DEFAULT_STRAIN = 1.5;
|
|
120
|
+
export const DEFAULT_FLEX_TILT = 45;
|
|
121
|
+
|
|
122
|
+
const orientationOk = (part) => {
|
|
123
|
+
const orientation = part?.verify?.orientation;
|
|
124
|
+
if (orientation != null && !ORIENTATIONS.includes(orientation)) {
|
|
125
|
+
throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const asserts = (expanded, metric) => expanded.some(({ expect }) =>
|
|
129
|
+
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && metric in o));
|
|
130
|
+
|
|
131
|
+
// The least root fillet, as a share of the member's thickness, a declared flexing
|
|
132
|
+
// member is held to (a GATE), or null when switched off. Armed by `verify.flex` alone —
|
|
133
|
+
// no profile needed, the rule holds in any material — so with no profile it is the
|
|
134
|
+
// default; the profile's `rootFillet` sets it, `{ base, rootFillet: null }` switches it off.
|
|
135
|
+
export function rootFilletFor(part, process) {
|
|
136
|
+
orientationOk(part);
|
|
137
|
+
const spec = process ?? part?.verify?.process;
|
|
138
|
+
const v = spec ? resolveProfile(spec).rootFillet : undefined;
|
|
139
|
+
if (v === null) return null;
|
|
140
|
+
return typeof v === "number" && Number.isFinite(v) ? v : DEFAULT_ROOT_FILLET;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The largest root strain, in percent, a flexing member may see at its declared
|
|
144
|
+
// deflection (an advisory), or null when switched off. Read for a member that declares
|
|
145
|
+
// a `deflection`, or where the author asserts `flexStrain`; the profile's `strain` sets
|
|
146
|
+
// the limit, the default stands in with no profile, `{ base, strain: null }` switches it off.
|
|
147
|
+
export function strainFor(part, process) {
|
|
148
|
+
orientationOk(part);
|
|
149
|
+
const spec = process ?? part?.verify?.process;
|
|
150
|
+
const v = spec ? resolveProfile(spec).strain : undefined;
|
|
151
|
+
if (v === null) return null;
|
|
152
|
+
return typeof v === "number" && Number.isFinite(v) ? v : DEFAULT_STRAIN;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The steepest a flexing member's long axis may rise off the bed, in degrees, before
|
|
156
|
+
// its layers stack across the root (a COST, `standing`), or null when not checked. Like
|
|
157
|
+
// overhangAngleFor: the PROFILE alone arms it (the FDM profiles carry `flexTilt`; resin
|
|
158
|
+
// is isotropic and carries none), or an authored `flexTilt` expectation, read against
|
|
159
|
+
// the default when the profile has none.
|
|
160
|
+
export function flexTiltFor(part, process, { expanded = [] } = {}) {
|
|
161
|
+
orientationOk(part);
|
|
162
|
+
const spec = process ?? part?.verify?.process;
|
|
163
|
+
const v = spec ? resolveProfile(spec).flexTilt : undefined;
|
|
164
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
165
|
+
return asserts(expanded, "flexTilt") ? DEFAULT_FLEX_TILT : null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/framework/oracle/flex-members.js
|
|
2
|
+
// The shape of `verify.flex`: the part's flexing members, as declared, normalized to one
|
|
3
|
+
// record each. Import-free and pure — the linter reads it at boot (rules-verify.js's
|
|
4
|
+
// verify-bad-flex and verify-flex-unknown-label), so it must stay a leaf the worker can
|
|
5
|
+
// load eagerly, apart from flex-root.js's reading, which loads with the rest of the
|
|
6
|
+
// oracle on the first inspect job (test/worker-layering.test.js).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The flexing members `verify.flex` declares, one record each: `{ feature, labels,
|
|
10
|
+
* deflection, thickness }`. An entry is a label, a list of labels read as one member
|
|
11
|
+
* (named by its first), or an object `{ feature, labels?, deflection?, thickness? }` —
|
|
12
|
+
* `deflection` the travel in mm the member is designed to flex through (arms the strain
|
|
13
|
+
* reading), `thickness` its bending thickness in mm when the author knows it better than
|
|
14
|
+
* the mesh does. A malformed entry is dropped (lint's verify-bad-flex reports it).
|
|
15
|
+
*/
|
|
16
|
+
export function flexMembers(flex) {
|
|
17
|
+
if (!Array.isArray(flex)) return [];
|
|
18
|
+
const label = (l) => typeof l === "string" && l.length > 0;
|
|
19
|
+
const labels = (ls) => Array.isArray(ls) && ls.length > 0 && ls.every(label);
|
|
20
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null);
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const e of flex) {
|
|
23
|
+
if (label(e)) out.push({ feature: e, labels: [e], deflection: null, thickness: null });
|
|
24
|
+
else if (labels(e)) out.push({ feature: e[0], labels: [...e], deflection: null, thickness: null });
|
|
25
|
+
else if (e && typeof e === "object" && !Array.isArray(e) && label(e.feature)) {
|
|
26
|
+
out.push({ feature: e.feature, labels: labels(e.labels) ? [...e.labels] : [e.feature],
|
|
27
|
+
deflection: num(e.deflection), thickness: num(e.thickness) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The label groups of `flex`, as measure() stamps them (`measuredFlex`). */
|
|
34
|
+
export const flexGroups = (flex) => flexMembers(flex).map((m) => m.labels);
|
|
35
|
+
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// src/framework/oracle/flex-root.js
|
|
2
|
+
// A flexing member, read off the mesh: how it meets the rest of its part, how big it is,
|
|
3
|
+
// and how it lies on the print bed.
|
|
4
|
+
//
|
|
5
|
+
// The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
|
|
6
|
+
// joined to its body at a sharp inside corner. Bending concentrates the stress at the
|
|
7
|
+
// root, and a sharp inside corner multiplies it several times over, so the member snaps
|
|
8
|
+
// there — often on the first flex, in FDM plastic whose layer bonds are weakest across
|
|
9
|
+
// exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
|
|
10
|
+
// 2 mm prism unioned onto the base, broke off flush with the base face. A fillet at the
|
|
11
|
+
// root of about half the member's thickness spreads the stress and is what fixes it —
|
|
12
|
+
// and the three other ways such a member fails read off the same labelled triangles:
|
|
13
|
+
// too much strain for its length (verify's flexStrain, from a declared deflection),
|
|
14
|
+
// printed standing so the layers stack across its root (flexTilt), and thinner than
|
|
15
|
+
// the process prints (minWall, already).
|
|
16
|
+
//
|
|
17
|
+
// A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
|
|
18
|
+
// one of them is a thin feature joined at a sharp corner, and a shape-only detector
|
|
19
|
+
// flagged most parts it was tried on. So the part SAYS which members flex
|
|
20
|
+
// (`verify.flex`, by feature label), and this reads only those: every edge where a
|
|
21
|
+
// triangle of the member meets a triangle of anything else. A root is sharp where that
|
|
22
|
+
// edge is concave and turns by more than SHARP_DEG. A fillet's own faces are new,
|
|
23
|
+
// unlabelled surfaces (or the sub-part's), and a tessellated fillet turns by a few
|
|
24
|
+
// degrees per facet, tangent where it meets the member and the body — so a filleted
|
|
25
|
+
// root reads clean, and so does a member whose faces only continue coplanar into the
|
|
26
|
+
// body (its top face flush with the base's top).
|
|
27
|
+
//
|
|
28
|
+
// THE ROOT RADIUS. A fillet tangent to the member's face is a circle through the
|
|
29
|
+
// member's boundary (where its label ends), tangent to the face there. A ray from that
|
|
30
|
+
// boundary, parallel to the face and lifted a small ε off it into the air, meets the
|
|
31
|
+
// fillet at the point where the arc has risen ε off the face: the chord from the tangent
|
|
32
|
+
// point to that hit is 2·r·sin(φ/2), and 2·r·(1 − cos φ) = ε, so r = chord² / (2·ε),
|
|
33
|
+
// exact on a circle and read off the member and the air alone — whatever the fillet's
|
|
34
|
+
// tessellation (mesh-fillet starts its arc past an extension sliver on the face plane, so
|
|
35
|
+
// nothing about the first chord row can be trusted), and whatever angle the body meets
|
|
36
|
+
// the member at. A root that continues flush meets nothing: it reads as the ray's reach.
|
|
37
|
+
// A sharp edge is r = 0; the member's radius is a low quantile, by length, of its
|
|
38
|
+
// boundary's readings (a corner patch reads noisily), and 0 wherever ROOT_FLOOR of
|
|
39
|
+
// sharp root exists at all. The rays are few — one per boundary edge, a handful per
|
|
40
|
+
// member — and are tested against the triangles their segments' box reaches, no index.
|
|
41
|
+
//
|
|
42
|
+
// One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
|
|
43
|
+
// oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
|
|
44
|
+
// `featureIds`/`features` (the feature labels), which both backends attach.
|
|
45
|
+
//
|
|
46
|
+
// The declaration itself (`verify.flex` → member records) is flex-members.js, a leaf
|
|
47
|
+
// the linter shares; this module is the reading, loaded with the rest of the oracle.
|
|
48
|
+
//
|
|
49
|
+
// Known limits, stated: an inside corner WITHIN one label (the member and its body
|
|
50
|
+
// labelled alike, or a barb's own notch) is invisible — label the flexing member apart
|
|
51
|
+
// from the body it grows from. A label that covers the whole sub-part, or a member that
|
|
52
|
+
// never touches the rest of it, therefore has NO root to read: that is `attached` false,
|
|
53
|
+
// which verify withholds a verdict on rather than passing (a clean root and no root
|
|
54
|
+
// must never read alike). And a feature touching the member (a grip rib on the arm)
|
|
55
|
+
// counts as "the rest of the part", so its corners read as roots unless it is listed
|
|
56
|
+
// with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
|
|
57
|
+
|
|
58
|
+
// A concave edge turning more than this (degrees) is a sharp corner. A tessellated
|
|
59
|
+
// fillet turns a few degrees per facet (3.1° per step at the preview's 116 segments;
|
|
60
|
+
// 22.5° at mesh-fillet's 16-segment floor for radii under 1 mm, and the boundary edge
|
|
61
|
+
// turns half of one), a 45° chamfer turns 45° twice — a chamfer is a corner too, if a
|
|
62
|
+
// softer one.
|
|
63
|
+
export const SHARP_DEG = 30;
|
|
64
|
+
const SHARP_COS = Math.cos((SHARP_DEG * Math.PI) / 180);
|
|
65
|
+
// A concave edge turning less than this (degrees) is flush: a face continuing into the
|
|
66
|
+
// body, not a fillet's first chord. Below it the chord formula divides by ~0.
|
|
67
|
+
export const FLUSH_DEG = 0.25;
|
|
68
|
+
const FLUSH_COS = Math.cos((FLUSH_DEG * Math.PI) / 180);
|
|
69
|
+
// Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
|
|
70
|
+
export const ROOT_FLOOR = 0.1;
|
|
71
|
+
// The radius reported is the reading this share of the rounded boundary's length sits
|
|
72
|
+
// under — a low quantile, so a noisy corner patch neither sets nor hides it.
|
|
73
|
+
const RADIUS_QUANTILE = 0.1;
|
|
74
|
+
// A root ray is followed this many member thicknesses (at least RAY_MIN mm): a fillet
|
|
75
|
+
// larger than that passes any ratio a profile asks for, and reads as that length.
|
|
76
|
+
const RAY_REACH = 10, RAY_MIN = 2;
|
|
77
|
+
// The ray's lift off the member's face, as a share of its thickness (at least LIFT_MIN
|
|
78
|
+
// mm): small against any fillet worth having, large against a chord's sagitta.
|
|
79
|
+
const LIFT = 0.05, LIFT_MIN = 0.02;
|
|
80
|
+
// Hits closer than this (mm) are the ray's own edge.
|
|
81
|
+
const HIT_EPS = 1e-4;
|
|
82
|
+
|
|
83
|
+
// Möller–Trumbore: the distance along the ray (o, d) to triangle (a, b, c), or null.
|
|
84
|
+
function rayTri(P, o, d, a, b, c) {
|
|
85
|
+
const e1x = P[b * 3] - P[a * 3], e1y = P[b * 3 + 1] - P[a * 3 + 1], e1z = P[b * 3 + 2] - P[a * 3 + 2];
|
|
86
|
+
const e2x = P[c * 3] - P[a * 3], e2y = P[c * 3 + 1] - P[a * 3 + 1], e2z = P[c * 3 + 2] - P[a * 3 + 2];
|
|
87
|
+
const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x;
|
|
88
|
+
const det = e1x * px + e1y * py + e1z * pz;
|
|
89
|
+
if (Math.abs(det) < 1e-12) return null;
|
|
90
|
+
const inv = 1 / det;
|
|
91
|
+
const tx = o[0] - P[a * 3], ty = o[1] - P[a * 3 + 1], tz = o[2] - P[a * 3 + 2];
|
|
92
|
+
const u = (tx * px + ty * py + tz * pz) * inv;
|
|
93
|
+
if (u < -1e-9 || u > 1 + 1e-9) return null;
|
|
94
|
+
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
|
|
95
|
+
const v = (d[0] * qx + d[1] * qy + d[2] * qz) * inv;
|
|
96
|
+
if (v < -1e-9 || u + v > 1 + 1e-9) return null;
|
|
97
|
+
const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
|
|
98
|
+
return t > HIT_EPS ? t : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The eigenvectors of a symmetric 3×3 (cyclic Jacobi), columns of `v`, with the
|
|
102
|
+
// eigenvalues in `w` — a handful of sweeps on a 3×3 converges to machine precision.
|
|
103
|
+
function eigen3(m) {
|
|
104
|
+
const a = m.map((r) => r.slice());
|
|
105
|
+
const v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
106
|
+
for (let sweep = 0; sweep < 32; sweep++) {
|
|
107
|
+
let off = 0;
|
|
108
|
+
for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) off += a[p][q] * a[p][q];
|
|
109
|
+
if (off < 1e-24) break;
|
|
110
|
+
for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) {
|
|
111
|
+
if (Math.abs(a[p][q]) < 1e-300) continue;
|
|
112
|
+
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
|
|
113
|
+
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
|
114
|
+
const c = 1 / Math.sqrt(t * t + 1), s = t * c;
|
|
115
|
+
for (let k = 0; k < 3; k++) {
|
|
116
|
+
const akp = a[k][p], akq = a[k][q];
|
|
117
|
+
a[k][p] = c * akp - s * akq; a[k][q] = s * akp + c * akq;
|
|
118
|
+
}
|
|
119
|
+
for (let k = 0; k < 3; k++) {
|
|
120
|
+
const apk = a[p][k], aqk = a[q][k];
|
|
121
|
+
a[p][k] = c * apk - s * aqk; a[q][k] = s * apk + c * aqk;
|
|
122
|
+
}
|
|
123
|
+
for (let k = 0; k < 3; k++) {
|
|
124
|
+
const vkp = v[k][p], vkq = v[k][q];
|
|
125
|
+
v[k][p] = c * vkp - s * vkq; v[k][q] = s * vkp + c * vkq;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { w: [a[0][0], a[1][1], a[2][2]], v };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The member's size along its own axes: the principal axes of its vertices, and the
|
|
133
|
+
// extent of the vertices along each — `length` the largest, `thickness` the smallest
|
|
134
|
+
// (the direction a cantilever bends in), `axis` the unit direction of the largest.
|
|
135
|
+
function extents(P, verts) {
|
|
136
|
+
const n = verts.length;
|
|
137
|
+
if (n < 3) return { length: 0, thickness: 0, axis: null };
|
|
138
|
+
const c = [0, 0, 0];
|
|
139
|
+
for (const i of verts) for (let k = 0; k < 3; k++) c[k] += P[i * 3 + k] / n;
|
|
140
|
+
const cov = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
|
141
|
+
for (const i of verts) {
|
|
142
|
+
const d = [P[i * 3] - c[0], P[i * 3 + 1] - c[1], P[i * 3 + 2] - c[2]];
|
|
143
|
+
for (let r = 0; r < 3; r++) for (let s = 0; s < 3; s++) cov[r][s] += d[r] * d[s];
|
|
144
|
+
}
|
|
145
|
+
const { w, v } = eigen3(cov);
|
|
146
|
+
const order = [0, 1, 2].sort((i, j) => w[j] - w[i]);
|
|
147
|
+
const ext = order.map((col) => {
|
|
148
|
+
const ax = [v[0][col], v[1][col], v[2][col]];
|
|
149
|
+
let lo = Infinity, hi = -Infinity;
|
|
150
|
+
for (const i of verts) {
|
|
151
|
+
const s = ax[0] * P[i * 3] + ax[1] * P[i * 3 + 1] + ax[2] * P[i * 3 + 2];
|
|
152
|
+
if (s < lo) lo = s;
|
|
153
|
+
if (s > hi) hi = s;
|
|
154
|
+
}
|
|
155
|
+
return { ax, size: hi - lo };
|
|
156
|
+
});
|
|
157
|
+
return { length: ext[0].size, thickness: ext[2].size, axis: ext[0].ax };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
|
|
162
|
+
* @param {string[]} group the labels making up one flexing member
|
|
163
|
+
* @param {{ printMatrix?: number[]|null }} [opts] the display→print mat4 (column-major,
|
|
164
|
+
* pose.js) the member is posed by for `tilt`; null when the display pose is the print pose
|
|
165
|
+
* @returns {{ found: boolean, attached: boolean, sharpLength: number, maxTurn: number|null, at: number[]|null,
|
|
166
|
+
* rootRadius: number|null, thickness: number, length: number, axis: number[]|null, tilt: number|null }}
|
|
167
|
+
* `found` false when no triangle carries any of the labels (the check cannot run);
|
|
168
|
+
* `attached` false when the member shares no edge with the rest of the mesh (nothing
|
|
169
|
+
* to read either — the label covers everything, or the member touches nothing);
|
|
170
|
+
* `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
|
|
171
|
+
* `maxTurn` the sharpest such edge's turn in degrees and `at` the midpoint of the
|
|
172
|
+
* longest one (both null when the root is clean);
|
|
173
|
+
* `rootRadius` the root's fillet radius in mm — 0 when any of it is sharp, null when
|
|
174
|
+
* the member meets the body flush everywhere (no inside corner at all);
|
|
175
|
+
* `thickness`, `length`, `axis` the member's own extents and long direction;
|
|
176
|
+
* `tilt` the degrees that direction rises off the print bed (0 lying flat, 90
|
|
177
|
+
* standing), in the print pose.
|
|
178
|
+
*/
|
|
179
|
+
export function flexRoot(mesh, group, { printMatrix = null } = {}) {
|
|
180
|
+
const { positions: P, indices: I, featureIds, features } = mesh;
|
|
181
|
+
const none = { found: false, attached: false, sharpLength: 0, maxTurn: null, at: null, rootRadius: null, thickness: 0, length: 0, axis: null, tilt: null };
|
|
182
|
+
const ids = new Set();
|
|
183
|
+
if (featureIds && features) features.forEach((f, i) => { if (group.includes(f)) ids.add(i + 1); });
|
|
184
|
+
if (!ids.size) return none;
|
|
185
|
+
const nTri = I ? I.length / 3 : P.length / 9;
|
|
186
|
+
const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
|
|
187
|
+
const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
|
|
188
|
+
|
|
189
|
+
// Normals, areas and centroids, and every edge of a member triangle, keyed by welded endpoints.
|
|
190
|
+
const inMember = (t) => ids.has(featureIds[t]);
|
|
191
|
+
const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3), area2 = new Float64Array(nTri);
|
|
192
|
+
const edges = new Map(); // key -> { a, b, tris: [] }
|
|
193
|
+
const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
|
|
194
|
+
for (let t = 0; t < nTri; t++) {
|
|
195
|
+
const [a, b, c] = tri(t);
|
|
196
|
+
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];
|
|
197
|
+
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];
|
|
198
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
199
|
+
const len = Math.hypot(nx, ny, nz) || 1;
|
|
200
|
+
area2[t] = len; // twice the area
|
|
201
|
+
normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
|
|
202
|
+
for (let k = 0; k < 3; k++) cent[t * 3 + k] = (P[a * 3 + k] + P[b * 3 + k] + P[c * 3 + k]) / 3;
|
|
203
|
+
}
|
|
204
|
+
// Only edges touching a member triangle matter; register those first, then let any
|
|
205
|
+
// triangle sharing such an edge join it. The member's welded vertices feed its extents.
|
|
206
|
+
const keys = [];
|
|
207
|
+
const memberVerts = new Map(); // welded key -> index
|
|
208
|
+
for (let t = 0; t < nTri; t++) {
|
|
209
|
+
const v = tri(t);
|
|
210
|
+
keys.push(v.map(key));
|
|
211
|
+
if (!inMember(t)) continue;
|
|
212
|
+
for (let j = 0; j < 3; j++) {
|
|
213
|
+
if (!memberVerts.has(keys[t][j])) memberVerts.set(keys[t][j], v[j]);
|
|
214
|
+
const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
|
|
215
|
+
const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
|
216
|
+
if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (let t = 0; t < nTri; t++) {
|
|
220
|
+
for (let j = 0; j < 3; j++) {
|
|
221
|
+
const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
|
|
222
|
+
const e = edges.get(ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`);
|
|
223
|
+
if (e) e.tris.push(t);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
|
|
228
|
+
const starts = []; // rounded boundary edges: a root ray each
|
|
229
|
+
for (const e of edges.values()) {
|
|
230
|
+
if (e.tris.length !== 2) continue;
|
|
231
|
+
const [t1, t2] = e.tris;
|
|
232
|
+
if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
|
|
233
|
+
attached = true; // a boundary, at any turn
|
|
234
|
+
const d = normal[t1 * 3] * normal[t2 * 3] + normal[t1 * 3 + 1] * normal[t2 * 3 + 1] + normal[t1 * 3 + 2] * normal[t2 * 3 + 2];
|
|
235
|
+
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;
|
|
236
|
+
// Concave: the second triangle lies in front of the first one's face. Flush (the
|
|
237
|
+
// face continuing into the body, or mesh-fillet's extension sliver on it) is neither.
|
|
238
|
+
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);
|
|
239
|
+
const flush = d > FLUSH_COS;
|
|
240
|
+
if (!flush && front <= 1e-9) continue; // convex: an outside edge
|
|
241
|
+
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]);
|
|
242
|
+
if (!(len > 0)) continue;
|
|
243
|
+
if (flush || d > SHARP_COS) { // rounded or flush: a root ray
|
|
244
|
+
const tm = inMember(t1) ? t1 : t2;
|
|
245
|
+
const nm = [normal[tm * 3], normal[tm * 3 + 1], normal[tm * 3 + 2]];
|
|
246
|
+
// In the member face's plane, perpendicular to the edge, away from the member:
|
|
247
|
+
// n × e, flipped if it points at the triangle's own centroid.
|
|
248
|
+
const ex = (P[e.b * 3] - P[e.a * 3]) / len, ey = (P[e.b * 3 + 1] - P[e.a * 3 + 1]) / len, ez = (P[e.b * 3 + 2] - P[e.a * 3 + 2]) / len;
|
|
249
|
+
let dx = nm[1] * ez - nm[2] * ey, dy = nm[2] * ex - nm[0] * ez, dz = nm[0] * ey - nm[1] * ex;
|
|
250
|
+
if (dx * (cent[tm * 3] - mx) + dy * (cent[tm * 3 + 1] - my) + dz * (cent[tm * 3 + 2] - mz) > 0) { dx = -dx; dy = -dy; dz = -dz; }
|
|
251
|
+
starts.push({ o: [mx, my, mz], d: [dx, dy, dz], n: nm, len });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
|
|
255
|
+
sharpLength += len;
|
|
256
|
+
if (maxTurn == null || turn > maxTurn) maxTurn = turn;
|
|
257
|
+
if (len > longest) { longest = len; at = [mx, my, mz]; }
|
|
258
|
+
}
|
|
259
|
+
const size = extents(P, [...memberVerts.values()]);
|
|
260
|
+
let tilt = null;
|
|
261
|
+
if (size.axis) {
|
|
262
|
+
const [x, y, z] = size.axis, m = printMatrix;
|
|
263
|
+
const pz = m ? m[2] * x + m[6] * y + m[10] * z : z;
|
|
264
|
+
tilt = (Math.asin(Math.min(1, Math.abs(pz))) * 180) / Math.PI;
|
|
265
|
+
}
|
|
266
|
+
const base = { found: true, attached, thickness: size.thickness, length: size.length, axis: size.axis, tilt };
|
|
267
|
+
if (!attached) return { ...base, sharpLength: 0, maxTurn: null, at: null, rootRadius: null };
|
|
268
|
+
if (sharpLength >= ROOT_FLOOR) return { ...base, sharpLength, maxTurn, at, rootRadius: 0 };
|
|
269
|
+
let rootRadius = null;
|
|
270
|
+
if (starts.length) {
|
|
271
|
+
const reach = Math.max(RAY_REACH * size.thickness, RAY_MIN);
|
|
272
|
+
const lift = Math.max(LIFT * size.thickness, LIFT_MIN);
|
|
273
|
+
// The non-member triangles any ray's segment box reaches, gathered once.
|
|
274
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
275
|
+
for (const { o, d, n } of starts) for (let k = 0; k < 3; k++) {
|
|
276
|
+
const a = o[k] + n[k] * lift;
|
|
277
|
+
lo[k] = Math.min(lo[k], a, a + d[k] * reach); hi[k] = Math.max(hi[k], a, a + d[k] * reach);
|
|
278
|
+
}
|
|
279
|
+
const near = [];
|
|
280
|
+
for (let t = 0; t < nTri; t++) {
|
|
281
|
+
if (inMember(t)) continue;
|
|
282
|
+
const [a, b, c] = tri(t);
|
|
283
|
+
let out = false;
|
|
284
|
+
for (let k = 0; k < 3 && !out; k++) {
|
|
285
|
+
const x = Math.min(P[a * 3 + k], P[b * 3 + k], P[c * 3 + k]), y = Math.max(P[a * 3 + k], P[b * 3 + k], P[c * 3 + k]);
|
|
286
|
+
out = y < lo[k] - 1e-6 || x > hi[k] + 1e-6;
|
|
287
|
+
}
|
|
288
|
+
if (!out) near.push(t);
|
|
289
|
+
}
|
|
290
|
+
const readings = []; // [radius, length]
|
|
291
|
+
for (const { o, d, n, len } of starts) {
|
|
292
|
+
const origin = [o[0] + n[0] * lift, o[1] + n[1] * lift, o[2] + n[2] * lift];
|
|
293
|
+
let best = Infinity;
|
|
294
|
+
for (const t of near) {
|
|
295
|
+
const [a, b, c] = tri(t);
|
|
296
|
+
const dist = rayTri(P, origin, d, a, b, c);
|
|
297
|
+
if (dist != null && dist < best) best = dist;
|
|
298
|
+
}
|
|
299
|
+
// chord² = s² + lift², and 2·r·lift = chord² on the circle tangent at the boundary.
|
|
300
|
+
readings.push([best > reach ? reach : Math.min(reach, (best * best + lift * lift) / (2 * lift)), len]);
|
|
301
|
+
}
|
|
302
|
+
readings.sort((p, q) => p[0] - q[0]);
|
|
303
|
+
const total = readings.reduce((s, [, l]) => s + l, 0);
|
|
304
|
+
let acc = 0;
|
|
305
|
+
for (const [r, l] of readings) { acc += l; rootRadius = r; if (acc >= RADIUS_QUANTILE * total) break; }
|
|
306
|
+
}
|
|
307
|
+
return { ...base, sharpLength: 0, maxTurn: null, at: null, rootRadius };
|
|
308
|
+
}
|
|
@@ -14,6 +14,8 @@ 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 } from "./flex-root.js";
|
|
18
|
+
import { flexMembers } from "./flex-members.js";
|
|
17
19
|
import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
|
|
18
20
|
import { summarizeContours } from "./shape-probe.js";
|
|
19
21
|
|
|
@@ -320,6 +322,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
320
322
|
// one memo-key bit, covers both readings.
|
|
321
323
|
const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
|
|
322
324
|
const underside = overhangAngle != null || bedArmed;
|
|
325
|
+
// The flexing members the part declares (`verify.flex`, by feature label — flex-root.js):
|
|
326
|
+
// each gets a root reading on every printed sub-part. One pass over the triangles per
|
|
327
|
+
// member, no index, so it runs on every lap. verify passes its own list; alone, measure
|
|
328
|
+
// reads the part's. The list measured is stamped as `measuredFlex`, which verify
|
|
329
|
+
// compares before reusing a seed.
|
|
330
|
+
const members = flexMembers(opts.flex !== undefined ? opts.flex : part.verify?.flex);
|
|
331
|
+
const flex = members.map((m) => m.labels);
|
|
332
|
+
// A flexing member is read in its print pose too (its tilt off the bed), so the pose
|
|
333
|
+
// is resolved whenever one is declared, as it is for the underside readings.
|
|
334
|
+
const posed = underside || members.length > 0;
|
|
323
335
|
const subBounds = [];
|
|
324
336
|
// Sheet parts: a view holding one sizes each printed sub-part in its print pose
|
|
325
337
|
// (verify fits the bed per part there), and every sheet row gets its 2-D facts under
|
|
@@ -332,7 +344,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
332
344
|
// The overhang reading poses each piece for print (printPoseOverhang), which reads the
|
|
333
345
|
// resolved params through place() — recorded into the same sink, since the reading
|
|
334
346
|
// depends on them.
|
|
335
|
-
const poseParams =
|
|
347
|
+
const poseParams = posed ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
|
|
336
348
|
// The print-pose sizes are read by one check, the process bed (verify passes whether
|
|
337
349
|
// its profile has one; alone, measure asks the part's own profile), and each costs a
|
|
338
350
|
// second build of the part — so they are built only for a bed.
|
|
@@ -348,7 +360,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
348
360
|
// (printPoseOverhang) is read off place() with the resolved params — geometry-free,
|
|
349
361
|
// so it is resolved here, ahead of the memo, which must key on it.
|
|
350
362
|
const printed = part.parts[name]?.exportable !== false && !sheet;
|
|
351
|
-
const printPose =
|
|
363
|
+
const printPose = posed && printed && poseParams?.p
|
|
352
364
|
? displayToPrintPose(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
|
|
353
365
|
: null;
|
|
354
366
|
const printMatrix = printPose?.matrix ?? null;
|
|
@@ -372,7 +384,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
372
384
|
const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash && !exportBed
|
|
373
385
|
? JSON.stringify({
|
|
374
386
|
mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
|
|
375
|
-
oh: overhangAngle ?? null, bc: bedArmed, ex: printed,
|
|
387
|
+
oh: overhangAngle ?? null, bc: bedArmed, ex: printed, fx: flex,
|
|
376
388
|
pm: printMatrix ? Array.from(printMatrix) : null,
|
|
377
389
|
})
|
|
378
390
|
: null;
|
|
@@ -471,6 +483,17 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
471
483
|
footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
|
|
472
484
|
footprintAt: bc?.lowestAt ?? null,
|
|
473
485
|
...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
|
|
486
|
+
// Each declared flexing member's root (flex-root.js), one entry per member group
|
|
487
|
+
// whose label this sub-part's mesh carries: the length of sharp inside corner where
|
|
488
|
+
// the member meets the rest of the part, the sharpest turn, and where the longest
|
|
489
|
+
// stretch sits. Null when nothing is declared; a sheet part is not printed, so none.
|
|
490
|
+
flexRoots: members.length && !sheet
|
|
491
|
+
? members.map((m) => {
|
|
492
|
+
const r = flexRoot(mesh, m.labels, { printMatrix });
|
|
493
|
+
// A pose the probes could not read is not the display pose: no tilt to report.
|
|
494
|
+
return { feature: m.feature, labels: m.labels, ...r, ...(printPose && !printPose.trusted ? { tilt: null } : {}) };
|
|
495
|
+
}).filter((r) => r.found)
|
|
496
|
+
: null,
|
|
474
497
|
// The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
|
|
475
498
|
sheet: sheet
|
|
476
499
|
? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
|
|
@@ -562,6 +585,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
562
585
|
measuredBedContact: bedArmed,
|
|
563
586
|
// The footprint is read by the same pass, so the same stamp.
|
|
564
587
|
measuredFootprint: bedArmed,
|
|
588
|
+
// The flexing-member groups whose roots were read (flex-root.js), as measured —
|
|
589
|
+
// verify reuses a seed only when it read every group this run declares.
|
|
590
|
+
measuredFlex: flex,
|
|
565
591
|
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
566
592
|
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
567
593
|
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 {
|
|
5
|
+
import { flexMembers } from "./flex-members.js";
|
|
6
|
+
import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor, rootFilletFor, strainFor, flexTiltFor } 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, worstFlexRoot } 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", "flexStrain", "flexTilt"]);
|
|
223
|
+
const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct, footprintPct, flexRoot, flexStrain and flexTilt 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,11 @@ 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
|
-
|
|
285
|
+
// `flex` the declared flexing members (flex-root.js flexMembers), `rootFillet` the
|
|
286
|
+
// share of a member's thickness its root fillet must reach (null: off), `strain` the
|
|
287
|
+
// percent root strain allowed at a member's declared deflection (null: off), `flexTilt`
|
|
288
|
+
// the degrees a member's axis may rise off the bed (null: not checked).
|
|
289
|
+
export function evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang = null, bedContact = null, footprint = null, accept = [], flex = [], rootFillet = null, strain = null, flexTilt = null }) {
|
|
282
290
|
const checks = [];
|
|
283
291
|
// contacts/clearance/held are pair-derived, not scalar view metrics — peel
|
|
284
292
|
// them off before the registry loop. held.js explains the exportable bound.
|
|
@@ -321,8 +329,27 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
321
329
|
message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
|
|
322
330
|
hint: "build() should make each piece as it prints — a printed piece shown in an assembly gets its assembled pose from its `views` entry, never from build(). Its export pose failed to build, so its print size — and its STL/3MF export — is unavailable. Fix the pose; the bed check then runs." });
|
|
323
331
|
}
|
|
332
|
+
// The flexing members this sub-part carries, each stamped with what verify asks of
|
|
333
|
+
// it: the root radius it needs (the process's share of its thickness — declared over
|
|
334
|
+
// measured) and, from a declared deflection, its root strain. The registry's worst-
|
|
335
|
+
// member pickers read these stamps, so the row's expr names that member's need.
|
|
336
|
+
const flexEval = (s.flexRoots ?? []).map((r) => {
|
|
337
|
+
const m = flex.find((x) => sameGroup(x.labels, r.labels));
|
|
338
|
+
const t = m?.thickness ?? r.thickness;
|
|
339
|
+
return { ...r, thicknessUsed: t,
|
|
340
|
+
required: rootFillet != null ? rootFillet * t : null,
|
|
341
|
+
deflection: m?.deflection ?? null,
|
|
342
|
+
strain: m?.deflection != null && r.length > 0 ? (100 * 1.5 * t * m.deflection) / (r.length * r.length) : null };
|
|
343
|
+
});
|
|
344
|
+
const sf = flexEval.length ? { ...s, flexRoots: flexEval } : s;
|
|
345
|
+
const worstRoot = rootFillet != null ? worstFlexRoot(sf) : null;
|
|
324
346
|
const merged = {
|
|
325
347
|
...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
|
|
348
|
+
...(worstRoot ? { flexRoot: `>=${Math.round(worstRoot.required * 100) / 100}` } : {}),
|
|
349
|
+
...(strain != null && flexEval.some((r) => r.strain != null) ? { flexStrain: `<=${strain}` } : {}),
|
|
350
|
+
// A member whose print pose could not be read has no tilt: the row then skips
|
|
351
|
+
// "unavailable" rather than vanishing, so the cost is seen to be unread.
|
|
352
|
+
...(flexTilt != null && flexEval.some((r) => r.attached) ? { flexTilt: `<=${flexTilt}` } : {}),
|
|
326
353
|
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
327
354
|
...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
|
|
328
355
|
...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
|
|
@@ -332,7 +359,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
332
359
|
// wrote in `expect` is the user's own limit and keeps its meaning, never accepted.
|
|
333
360
|
const authored = expect?.[s.name] ?? {};
|
|
334
361
|
const acceptFor = (metric) => (Object.hasOwn(authored, metric) ? [] : accept);
|
|
335
|
-
for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS,
|
|
362
|
+
for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, sf, acceptFor(metric))) {
|
|
336
363
|
if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
|
|
337
364
|
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
338
365
|
hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
|
|
@@ -363,9 +390,47 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
363
390
|
checks.push(c);
|
|
364
391
|
}
|
|
365
392
|
}
|
|
393
|
+
checks.push(...flexChecks(facts, flex));
|
|
366
394
|
return checks;
|
|
367
395
|
}
|
|
368
396
|
|
|
397
|
+
// Flexing members (`verify.flex`, flex-root.js). The reading itself is the registry's
|
|
398
|
+
// `flexRoot` gate, one row per sub-part carrying a member that meets the rest of the
|
|
399
|
+
// part (evaluateCase's `merged`). What is left here is per DECLARED member, the cases
|
|
400
|
+
// where no sub-part row can answer for it: a seed that did not read the member
|
|
401
|
+
// (measuredFlex) answers unevaluated on a quick lap; a member no sub-part of this view
|
|
402
|
+
// carries is a skip, not a fail — a sub-part an `enabled()` turned off, or one shown
|
|
403
|
+
// only in another view — and lint's verify-flex-unknown-label is what catches a label
|
|
404
|
+
// no build ever writes; a member carried but attached nowhere (its label covers the
|
|
405
|
+
// whole sub-part, or the member never touches the body) withholds the verdict: a clean
|
|
406
|
+
// root and no root must never read alike.
|
|
407
|
+
export { FLEX_ROOT_HINT };
|
|
408
|
+
const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
|
|
409
|
+
function flexChecks(facts, flex) {
|
|
410
|
+
const out = [];
|
|
411
|
+
for (const { feature, labels: g } of flex) {
|
|
412
|
+
const base = { scope: "subpart", metric: "flexRoot", kind: "gate", feature, expr: "rounded root", subpart: null, actual: null, status: "skip", pass: null };
|
|
413
|
+
if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
|
|
414
|
+
out.push({ ...base, unevaluated: true,
|
|
415
|
+
message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const rows = facts.subparts.flatMap((s) => (s.flexRoots ?? []).filter((r) => sameGroup(r.labels, g)));
|
|
419
|
+
if (!rows.length) {
|
|
420
|
+
out.push({ ...base,
|
|
421
|
+
message: `no sub-part in this view carries the label ${g.map((l) => `"${l}"`).join(" or ")}`,
|
|
422
|
+
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" });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (!rows.some((r) => r.attached)) {
|
|
426
|
+
out.push({ ...base, unevaluated: true,
|
|
427
|
+
message: `"${feature}" never meets the rest of the part: its label covers every triangle it touches, so there is no root to read`,
|
|
428
|
+
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" });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
369
434
|
// `seed` lets a caller that has ALREADY measured this part hand the result in so
|
|
370
435
|
// verify does not recompute it — see the seeding block below for the shape and
|
|
371
436
|
// the one correctness rule that governs it.
|
|
@@ -391,6 +456,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
391
456
|
const bedContactFloor = bedContactFor(part, process, { expanded });
|
|
392
457
|
const footprintFloor = footprintFor(part, process, { expanded });
|
|
393
458
|
const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
|
|
459
|
+
const flex = flexMembers(part?.verify?.flex);
|
|
460
|
+
const rootFillet = flex.length ? rootFilletFor(part, process) : null;
|
|
461
|
+
const strainLimit = flex.length ? strainFor(part, process) : null;
|
|
462
|
+
const flexTilt = flexTiltFor(part, process, { expanded });
|
|
394
463
|
// Reuse rule (spec §4): case B may reuse a measurement taken at params A when
|
|
395
464
|
// B agrees with A on every param that measurement READ (recorded by measure()
|
|
396
465
|
// itself). Sound for the same reason the mesh cache is: a deterministic build
|
|
@@ -464,7 +533,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
464
533
|
// sizes cannot answer a bed, so it is admitted only when this run needs no bed.
|
|
465
534
|
const needPrintBboxes = profile?.bed != null;
|
|
466
535
|
const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
|
|
467
|
-
|
|
536
|
+
// The flex half: a seed that read every declared member (a superset is free).
|
|
537
|
+
const flexMatches = flex.every(({ labels }) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, labels)));
|
|
538
|
+
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
|
|
468
539
|
&& seed.result.view === view) {
|
|
469
540
|
entries.push({ params: full(seed.params), result: seed.result });
|
|
470
541
|
}
|
|
@@ -478,7 +549,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
478
549
|
// for every case buys nothing. (A seed measured WITH probes is a superset in
|
|
479
550
|
// the same way a min-wall seed is: the extra key is simply never read here.)
|
|
480
551
|
const result = measureFn(kernel, part, view, params,
|
|
481
|
-
{ minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes });
|
|
552
|
+
{ minWall: needMinWall, probes: false, overhang: overhangAngle, bedContact: armsBedPass, printBboxes: needPrintBboxes, flex });
|
|
482
553
|
entries.push({ params: fp, result });
|
|
483
554
|
return result;
|
|
484
555
|
};
|
|
@@ -499,7 +570,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
499
570
|
}];
|
|
500
571
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
501
572
|
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) };
|
|
573
|
+
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex, rootFillet, strain: strainLimit, flexTilt }) : notMeasured(name) };
|
|
503
574
|
});
|
|
504
575
|
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
505
576
|
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
@@ -534,7 +605,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
534
605
|
declared++;
|
|
535
606
|
if (c.status !== "skip" && !c.unevaluated) evaluated++;
|
|
536
607
|
}
|
|
537
|
-
const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null
|
|
608
|
+
const declaresAnything = profile != null || overhangAngle != null || bedContactFloor != null || footprintFloor != null || flex.length > 0
|
|
538
609
|
|| expanded.some(({ expect }) => Object.values(expect ?? {}).some((o) => o && typeof o === "object" && Object.keys(o).length > 0));
|
|
539
610
|
const failures = all.filter((c) => c.status === "fail");
|
|
540
611
|
const unevaluated = all.filter((c) => c.unevaluated);
|
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
// `footprint` is the percent of a printed piece's posed base area resting on the
|
|
24
24
|
// bed below which the footprint cost fires.
|
|
25
25
|
//
|
|
26
|
+
// A flexing member (`verify.flex`, oracle/flex-root.js) reads three more: `rootFillet`
|
|
27
|
+
// is the least root fillet radius as a share of the member's thickness (a GATE, 0.5
|
|
28
|
+
// from every snap-fit guide; the stress riser is the same in any material, so resin
|
|
29
|
+
// carries it too); `strain` the largest root strain, in percent, the material takes
|
|
30
|
+
// for a one-time snap (an advisory, read from a declared deflection: PLA is brittle,
|
|
31
|
+
// PETG forgiving); `flexTilt` the steepest the member's long axis may rise off the bed,
|
|
32
|
+
// in degrees, before its layers stack across the root (a COST, `standing`: the layer
|
|
33
|
+
// bond is a fraction of the in-plane strength, so a standing arm snaps along a layer;
|
|
34
|
+
// resin is isotropic and carries none).
|
|
35
|
+
//
|
|
26
36
|
// Import-free, like every process descriptor: lint and the oracle read it through
|
|
27
37
|
// process/registry.js.
|
|
28
38
|
export const FDM = {
|
|
@@ -35,14 +45,16 @@ export const FDM = {
|
|
|
35
45
|
bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50, footprint: 10,
|
|
36
46
|
fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
|
|
37
47
|
holeUndersize: 0.25, bridge: 10,
|
|
48
|
+
rootFillet: 0.5, strain: 1.5, flexTilt: 45,
|
|
38
49
|
},
|
|
39
50
|
"fdm-petg": {
|
|
40
51
|
bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50, footprint: 10,
|
|
41
52
|
fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
|
|
42
53
|
holeUndersize: 0.3, bridge: 10,
|
|
54
|
+
rootFillet: 0.5, strain: 3, flexTilt: 45,
|
|
43
55
|
},
|
|
44
56
|
},
|
|
45
|
-
costs: { supports: "overhangArea", footprint: "footprintPct" },
|
|
57
|
+
costs: { supports: "overhangArea", footprint: "footprintPct", standing: "flexTilt" },
|
|
46
58
|
metrics: {},
|
|
47
59
|
lintRules: [],
|
|
48
60
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Resin printing as DATA: see process/fdm/descriptor.js for what each profile field
|
|
2
2
|
// means. Resin prints on supports, so it carries no overhang, bedContact, footprint
|
|
3
|
-
// or bridge, and no costs
|
|
3
|
+
// or bridge, and no costs; it is isotropic, so no flexTilt either. A flexing member's
|
|
4
|
+
// root fillet and strain apply in any material. Import-free, like every process descriptor.
|
|
4
5
|
export const RESIN = {
|
|
5
6
|
id: "resin",
|
|
6
7
|
label: "Resin 3D printing",
|
|
@@ -11,6 +12,7 @@ export const RESIN = {
|
|
|
11
12
|
bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
|
|
12
13
|
fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
|
|
13
14
|
holeUndersize: 0.05,
|
|
15
|
+
rootFillet: 0.5, strain: 2,
|
|
14
16
|
},
|
|
15
17
|
},
|
|
16
18
|
costs: {},
|
|
@@ -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,30 @@ export function sidesSpecError(metric, reg, spec) {
|
|
|
63
66
|
return null;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// The flexing-member readings (oracle/flex-root.js, `verify.flex`): what each failure
|
|
70
|
+
// means and what fixes it.
|
|
71
|
+
export const FLEX_ROOT_HINT = "a part that flexes (a snap arm, buckle prong, clip or cantilever tab) meets the rest of the part through too small a root fillet, or none — bending concentrates the stress at that corner and the member snaps off at its root. Round the root to at least the process's share of the member's thickness (half, on every built-in profile): `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\"]]`";
|
|
72
|
+
export const FLEX_STRAIN_HINT = "at its declared deflection this member's root strain (1.5 × thickness × deflection / length², a straight cantilever) is over what the material takes for a one-time snap — it yields or cracks at the root. Lengthen the member, thin it, or reduce its travel (a shallower undercut, a lead-in that spreads the snap over more of the stroke); a member tapered to half its thickness at the tip bends ~60% further at the same root strain. If the mesh misreads its bending thickness, declare it: `flex: [{ feature, deflection, thickness }]`";
|
|
73
|
+
export const FLEX_TILT_HINT = "this member's long axis stands up off the bed in the print pose, so its layers stack across its root — and a layer bond is a fraction of the in-plane strength, so it snaps along a layer line. Lay the member flat: pose the piece in `place(..., { purpose: \"export\" })` so the arm lies along the bed (its root corner then sits in one layer), or split the part so the arm prints flat; a standing arm the user keeps is a cost (`accept: [\"standing\"]`) — use PETG or anneal, and round the root generously";
|
|
74
|
+
// The flexing member a sub-part's row answers for: the one whose root is worst (least
|
|
75
|
+
// radius over what it needs — `required`, stamped by verify from the profile's share of
|
|
76
|
+
// its thickness), among those that meet the rest of the sub-part and have a corner to
|
|
77
|
+
// read; null when none does. Exported for verify, whose expr must name that member's need.
|
|
78
|
+
export const worstFlexRoot = (s) => (s.flexRoots ?? []).filter((r) => r.attached && r.rootRadius != null)
|
|
79
|
+
.reduce((w, r) => (w == null || (r.required ?? 1) > 0 && r.rootRadius / (r.required ?? 1) < w.rootRadius / (w.required ?? 1) ? r : w), null);
|
|
80
|
+
const worstFlexStrain = (s) => (s.flexRoots ?? []).filter((r) => r.strain != null)
|
|
81
|
+
.reduce((w, r) => (w == null || r.strain > w.strain ? r : w), null);
|
|
82
|
+
const worstFlexTilt = (s) => (s.flexRoots ?? []).filter((r) => r.attached && r.tilt != null)
|
|
83
|
+
.reduce((w, r) => (w == null || r.tilt > w.tilt ? r : w), null);
|
|
84
|
+
const mm = (v) => Math.round(v * 100) / 100;
|
|
85
|
+
const memberLine = (r) => {
|
|
86
|
+
if (!r.attached) return "never meets the rest of this sub-part";
|
|
87
|
+
if (r.rootRadius === 0) return `sharp root (${Math.round(r.maxTurn)}°, ${r.sharpLength.toFixed(1)} mm of corner)`;
|
|
88
|
+
if (r.rootRadius == null) return "no inside corner at its root";
|
|
89
|
+
const need = r.required != null ? `, needs ≥ ${mm(r.required)} (${mm(r.thicknessUsed ?? r.thickness)} mm thick)` : "";
|
|
90
|
+
return `root radius ${mm(r.rootRadius)} mm${need}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
66
93
|
export const SUBPART_METRICS = {
|
|
67
94
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
68
95
|
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 +159,51 @@ export const SUBPART_METRICS = {
|
|
|
132
159
|
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
160
|
pattern: "piece-stands-on-nothing",
|
|
134
161
|
locate: (s) => s.footprintAt },
|
|
162
|
+
// A declared flexing member's root (oracle/flex-root.js, `verify.flex`): its fillet
|
|
163
|
+
// radius in mm, 0 where it is sharp. One row per sub-part, like every print metric:
|
|
164
|
+
// `actual` is the WORST member's reading (least radius over what it needs), `feature`
|
|
165
|
+
// names it, and `note` lists every member the sub-part carries. Measured on a sub-part
|
|
166
|
+
// carrying a declared label; elsewhere `extract` is null — "no root to read" rather than
|
|
167
|
+
// an inert skip, since an authored expectation with nothing declared, or on a member
|
|
168
|
+
// that never meets the body, is a mistake to point at. A GATE under any process or
|
|
169
|
+
// none: the root is the same in any orientation and the stress concentration is the
|
|
170
|
+
// same in any material.
|
|
171
|
+
flexRoot: { kind: "gate", extract: (s) => { const w = worstFlexRoot(s); return w ? mm(w.rootRadius) : null; },
|
|
172
|
+
hint: FLEX_ROOT_HINT,
|
|
173
|
+
pattern: "flex-root-sharp",
|
|
174
|
+
locate: (s) => worstFlexRoot(s)?.at ?? null,
|
|
175
|
+
feature: (s) => worstFlexRoot(s)?.feature ?? null,
|
|
176
|
+
unavailable: "no root to read",
|
|
177
|
+
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",
|
|
178
|
+
note: (s) => {
|
|
179
|
+
const rows = s.flexRoots ?? [];
|
|
180
|
+
return rows.length ? rows.map((r) => `"${r.feature}": ${memberLine(r)}`).join("; ") : null;
|
|
181
|
+
} },
|
|
182
|
+
// A declared flexing member's root strain at its declared deflection, in percent
|
|
183
|
+
// (verify stamps `strain` from the member's measured length and thickness — 1.5·t·y/L²,
|
|
184
|
+
// a straight cantilever). The worst member per sub-part. An ADVISORY: the formula is a
|
|
185
|
+
// first approximation (a taper, a curve or a hook changes it), read against the
|
|
186
|
+
// process's `strain`.
|
|
187
|
+
flexStrain: { kind: "warn", extract: (s) => { const w = worstFlexStrain(s); return w ? Math.round(w.strain * 100) / 100 : null; },
|
|
188
|
+
hint: FLEX_STRAIN_HINT,
|
|
189
|
+
pattern: "flex-strain-over",
|
|
190
|
+
feature: (s) => worstFlexStrain(s)?.feature ?? null,
|
|
191
|
+
note: (s) => {
|
|
192
|
+
const rows = (s.flexRoots ?? []).filter((r) => r.strain != null);
|
|
193
|
+
return rows.length ? rows.map((r) => `"${r.feature}": ${(Math.round(r.strain * 100) / 100).toFixed(2)}% at ${r.deflection} mm (${mm(r.length)} mm long, ${mm(r.thicknessUsed ?? r.thickness)} mm thick)`).join("; ") : null;
|
|
194
|
+
} },
|
|
195
|
+
// How far a declared flexing member's long axis rises off the bed in the print pose,
|
|
196
|
+
// in degrees (0 lying flat, 90 standing) — the worst member per sub-part. A COST
|
|
197
|
+
// (`standing`), armed by an FDM profile's `flexTilt`: an arm the user prints standing is
|
|
198
|
+
// a choice with a price, not a defect, and resin (isotropic) never asks.
|
|
199
|
+
flexTilt: { kind: "cost", costId: "standing", extract: (s) => { const w = worstFlexTilt(s); return w ? Math.round(w.tilt * 10) / 10 : null; },
|
|
200
|
+
hint: FLEX_TILT_HINT,
|
|
201
|
+
pattern: "flex-member-standing",
|
|
202
|
+
feature: (s) => worstFlexTilt(s)?.feature ?? null,
|
|
203
|
+
note: (s) => {
|
|
204
|
+
const rows = (s.flexRoots ?? []).filter((r) => r.attached && r.tilt != null);
|
|
205
|
+
return rows.length ? rows.map((r) => `"${r.feature}": ${Math.round(r.tilt)}° off the bed`).join("; ") : null;
|
|
206
|
+
} },
|
|
135
207
|
// `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
|
|
136
208
|
// "<import name>"`; on every other sub-part `extract` returns null, which
|
|
137
209
|
// `check()` already reports as status "skip" rather than a fail — the
|
package/types/part.d.ts
CHANGED
|
@@ -510,6 +510,24 @@ export interface DfmProfile {
|
|
|
510
510
|
holeUndersize?: number;
|
|
511
511
|
/** Longest flat span printed unsupported, mm. Absent on a process that prints on supports. */
|
|
512
512
|
bridge?: number;
|
|
513
|
+
/**
|
|
514
|
+
* The least root fillet a flexing member (`verify.flex`) must meet its body through,
|
|
515
|
+
* as a share of the member's thickness (0.5 on every built-in profile) — a GATE,
|
|
516
|
+
* armed by `verify.flex` alone; `null` switches it off under a named base.
|
|
517
|
+
*/
|
|
518
|
+
rootFillet?: number | null;
|
|
519
|
+
/**
|
|
520
|
+
* The largest root strain, in percent, the material takes for a one-time snap (1.5 on
|
|
521
|
+
* PLA, 3 on PETG, 2 on resin) — an advisory, read for a flexing member that declares
|
|
522
|
+
* a `deflection`; `null` switches it off under a named base.
|
|
523
|
+
*/
|
|
524
|
+
strain?: number | null;
|
|
525
|
+
/**
|
|
526
|
+
* The steepest a flexing member's long axis may rise off the bed in the print pose, in
|
|
527
|
+
* degrees (45 on the FDM profiles; absent on resin, which is isotropic), before its
|
|
528
|
+
* layers stack across the root. A cost (`standing`), armed by the profile alone.
|
|
529
|
+
*/
|
|
530
|
+
flexTilt?: number | null;
|
|
513
531
|
/** Inherit from a named profile and override the rest. */
|
|
514
532
|
base?: DfmProfileName | DfmProfile;
|
|
515
533
|
}
|
|
@@ -581,6 +599,25 @@ export interface SubPartExpectations {
|
|
|
581
599
|
* a cost (`verify.costs`), never a failure; `accept: ["footprint"]` records a brim.
|
|
582
600
|
*/
|
|
583
601
|
footprintPct?: Expectation;
|
|
602
|
+
/**
|
|
603
|
+
* The root fillet radius (mm, 0 where sharp) of the flexing member named in
|
|
604
|
+
* `verify.flex` whose root is worst on this sub-part — `feature` names it. Every
|
|
605
|
+
* sub-part carrying a declared member gets `>=` the profile's share of the member's
|
|
606
|
+
* thickness by default; a gate.
|
|
607
|
+
*/
|
|
608
|
+
flexRoot?: Expectation;
|
|
609
|
+
/**
|
|
610
|
+
* Root strain (%) of the worst declared flexing member on this sub-part at its declared
|
|
611
|
+
* deflection — 1.5 × thickness × deflection / length², a straight cantilever. Under a
|
|
612
|
+
* profile every member with a `deflection` gets `<=` the profile's `strain`; a warning.
|
|
613
|
+
*/
|
|
614
|
+
flexStrain?: Expectation;
|
|
615
|
+
/**
|
|
616
|
+
* Degrees the worst declared flexing member's long axis rises off the bed in the
|
|
617
|
+
* print pose (0 lying flat, 90 standing). Under an FDM profile every member gets
|
|
618
|
+
* `<=45`; reported as a cost (`standing`), never a failure.
|
|
619
|
+
*/
|
|
620
|
+
flexTilt?: Expectation;
|
|
584
621
|
/** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
|
|
585
622
|
refXorVolume?: Expectation;
|
|
586
623
|
/** Percent volume delta vs. the sub-part's declared `reference` import. */
|
|
@@ -659,7 +696,19 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
|
|
|
659
696
|
* process's own default check is marked accepted: a limit you wrote in `expect`
|
|
660
697
|
* (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
|
|
661
698
|
*/
|
|
662
|
-
accept?: ("supports" | "footprint")[];
|
|
699
|
+
accept?: ("supports" | "footprint" | "standing")[];
|
|
700
|
+
/**
|
|
701
|
+
* The part's flexing members — snap arms, buckle prongs, clips, cantilever tabs —
|
|
702
|
+
* by feature label. Each must meet the rest of its part through a root fillet of at
|
|
703
|
+
* least the process's share of its thickness (`flexRoot`, a gate); one that declares
|
|
704
|
+
* its `deflection` (mm of travel) is also read for root strain (`flexStrain`, an
|
|
705
|
+
* advisory), and under an FDM profile every member is read for how far it stands up
|
|
706
|
+
* off the bed (`flexTilt`, the `standing` cost). An entry is a label, a list of labels
|
|
707
|
+
* that make one member (an arm and the rib on it), or a member object; `thickness`
|
|
708
|
+
* (mm) overrides the bending thickness the mesh reads. Label the member apart from
|
|
709
|
+
* the body it grows from.
|
|
710
|
+
*/
|
|
711
|
+
flex?: (string | string[] | { feature: string; labels?: string[]; deflection?: number; thickness?: number })[];
|
|
663
712
|
/** Which cases to check; default is `"defaults"` plus every preset name. */
|
|
664
713
|
cases?: string[];
|
|
665
714
|
/**
|
package/types/testing.d.ts
CHANGED
|
@@ -441,6 +441,22 @@ 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: {
|
|
451
|
+
feature: string; labels: string[]; found: boolean; attached: boolean;
|
|
452
|
+
sharpLength: number; maxTurn: number | null; at: number[] | null;
|
|
453
|
+
/** The root's fillet radius in mm: 0 where sharp, `null` where the member meets the body flush everywhere. */
|
|
454
|
+
rootRadius: number | null;
|
|
455
|
+
/** The member's own extents (mm) along its principal axes, and the unit direction of the longest. */
|
|
456
|
+
thickness: number; length: number; axis: number[] | null;
|
|
457
|
+
/** Degrees the long axis rises off the bed in the print pose; `null` when that pose could not be read. */
|
|
458
|
+
tilt: number | null;
|
|
459
|
+
}[] | null;
|
|
444
460
|
/**
|
|
445
461
|
* Present only when bed contact had to be read off the piece's export build (its
|
|
446
462
|
* print pose could not be read from place() alone) and that build failed.
|