partforge 0.172.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 +77 -33
- package/docs/ERROR-PATTERNS.md +15 -3
- package/package.json +1 -1
- package/src/framework/lint/rules-verify.js +25 -8
- 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 +202 -22
- package/src/framework/oracle/measure.js +15 -6
- package/src/framework/oracle/verify.js +38 -16
- package/src/framework/process/fdm/descriptor.js +13 -1
- package/src/framework/process/resin/descriptor.js +3 -1
- package/src/framework/verify-metrics.js +59 -18
- package/types/part.d.ts +44 -10
- package/types/testing.d.ts +10 -1
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), a declared flexing member's root (`flexRoot`) | 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
|
|
@@ -2018,12 +2018,20 @@ the user wants it and has accepted the supports.
|
|
|
2018
2018
|
### Flexing members
|
|
2019
2019
|
|
|
2020
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 —
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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).
|
|
2027
2035
|
|
|
2028
2036
|
Declare every such member in `verify.flex`, by its feature label, and label the member
|
|
2029
2037
|
**apart from the body** it grows from:
|
|
@@ -2033,18 +2041,41 @@ const arm = k.prism({ points: armPts, h: d.armH }).label("Right prong");
|
|
|
2033
2041
|
let s = k.union([base, arm, otherArm])
|
|
2034
2042
|
.fillet({ r: 0.5 * p.armThick, edges: { inPlane: "XZ", at: 0 } }); // the arms' roots on the base face
|
|
2035
2043
|
// …
|
|
2036
|
-
verify: {
|
|
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
|
+
},
|
|
2037
2053
|
```
|
|
2038
2054
|
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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.
|
|
2048
2079
|
|
|
2049
2080
|
### Small features and scale
|
|
2050
2081
|
|
|
@@ -4468,8 +4499,9 @@ A part can declare how it should be checked, co-located with its schema, so
|
|
|
4468
4499
|
```js
|
|
4469
4500
|
verify: {
|
|
4470
4501
|
process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an inline
|
|
4471
|
-
// { base, bed:[x,y,z], minWall, overhang, bedContact, footprint, fits, holeUndersize, bridge
|
|
4472
|
-
|
|
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)
|
|
4473
4505
|
orientation: "print", // optional; declares build() is the print pose (Z up, bed at
|
|
4474
4506
|
// the lowest Z) — with no profile it arms bed contact
|
|
4475
4507
|
cases: ["defaults", "M3"], // optional; default = defaults + every preset
|
|
@@ -4567,16 +4599,25 @@ records it. Your own `footprintPct` expectation arms it under any profile; `orie
|
|
|
4567
4599
|
does not, and `{ base: "fdm-pla", footprint: null }` switches it off. `exportable: false`
|
|
4568
4600
|
sub-parts are not judged.
|
|
4569
4601
|
|
|
4570
|
-
**Flexing members — a GATE
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
`
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
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
|
|
4580
4621
|
declaration problems are reported per member rather than per sub-part: a member no
|
|
4581
4622
|
sub-part of the verified view carries is skipped (it may be switched off in that case);
|
|
4582
4623
|
a member carried but meeting nothing — its label put on the whole union, or a piece
|
|
@@ -4603,13 +4644,16 @@ an inline `{ base: "fdm-pla", fits: { … } }` replaces the whole table.
|
|
|
4603
4644
|
| `fits.printInPlace` — printed already assembled | 0.45 | 0.50 | 0.20 |
|
|
4604
4645
|
| `holeUndersize` | 0.25 | 0.30 | 0.05 |
|
|
4605
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 | — |
|
|
4606
4650
|
|
|
4607
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" }`).
|
|
4608
4652
|
|
|
4609
4653
|
**What `expect` gives you:** per-sub-part
|
|
4610
4654
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4611
4655
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
4612
|
-
`overhangArea`, `bedContactPct`, `footprintPct`, `flexRoot`, `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
|
|
4613
4657
|
positions, written in the same `{ min, max }` shape `measure` reports them — where
|
|
4614
4658
|
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4615
4659
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -236,9 +236,21 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
236
236
|
|
|
237
237
|
## flex-root-sharp
|
|
238
238
|
|
|
239
|
-
- **Symptom:** A `✗ … flexRoot` failure from `verify` naming the member in `feature`, whose note reads e.g. `"Right prong": 12.2 mm of
|
|
240
|
-
- **Cause:** The member declared in `verify.flex` meets the rest of the part at a sharp inside corner. Bending puts the highest stress at the root, a sharp corner multiplies it several times over, and in FDM the layer bonds are weakest right there — so the member snaps at its root, often on the first few flexes.
|
|
241
|
-
- **Fix:** Round the root with a fillet of at least half the member's thickness: `s.fillet({ r, edges: { near: [x, y, z] } })` on the joined solid, one root edge at a time, or `edges: { inPlane, at }` when every root edge lies on the face the member grows from (fillet before anything else that touches that face joins, so only the roots are selected); or draw the fillet into the 2-D profile the member and body share. Keep the fillet clear of whatever the member slides into — chamfer that mouth to seat it. If a grip rib or barb on the member is labelled apart, list it with the member: `flex: [["Arm", "Arm rib"]]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Printed parts".
|
|
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".
|
|
242
254
|
|
|
243
255
|
## expect-static-across-presets
|
|
244
256
|
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ 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";
|
|
@@ -449,21 +450,36 @@ export const VERIFY_RULES = [
|
|
|
449
450
|
},
|
|
450
451
|
{
|
|
451
452
|
// `verify.flex` names the part's flexing members by feature label (oracle/flex-root.js):
|
|
452
|
-
// a list whose entries are a label,
|
|
453
|
-
//
|
|
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.
|
|
454
456
|
id: "verify-bad-flex",
|
|
455
457
|
run: ({ part }) => {
|
|
456
458
|
const flex = part?.verify?.flex;
|
|
457
459
|
if (flex == null) return [];
|
|
458
|
-
const form = "Write `flex` as a list of
|
|
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> }`.";
|
|
459
461
|
if (!Array.isArray(flex)) {
|
|
460
|
-
return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of
|
|
462
|
+
return [err("verify-bad-flex", `\`verify.flex\` is ${describe(flex)}, not a list of flexing members`, form, "verify.flex")];
|
|
461
463
|
}
|
|
462
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;
|
|
463
467
|
const out = [];
|
|
464
468
|
flex.forEach((e, i) => {
|
|
465
|
-
if (label(e) || (
|
|
466
|
-
|
|
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
|
+
}
|
|
467
483
|
});
|
|
468
484
|
return out;
|
|
469
485
|
},
|
|
@@ -485,8 +501,9 @@ export const VERIFY_RULES = [
|
|
|
485
501
|
const known = [...seen];
|
|
486
502
|
const out = [];
|
|
487
503
|
flex.forEach((e, i) => {
|
|
488
|
-
|
|
489
|
-
|
|
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;
|
|
490
507
|
const hint = safeSuggest(l, known);
|
|
491
508
|
out.push(warn("verify-flex-unknown-label",
|
|
492
509
|
`\`verify.flex\` names "${l}", but no build labels a feature "${l}" at the default parameters`,
|
|
@@ -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
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/framework/oracle/flex-root.js
|
|
2
|
-
//
|
|
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.
|
|
3
4
|
//
|
|
4
5
|
// The failure this exists for: a snap arm, a buckle prong, a clip or a cantilever tab
|
|
5
6
|
// joined to its body at a sharp inside corner. Bending concentrates the stress at the
|
|
@@ -7,7 +8,11 @@
|
|
|
7
8
|
// there — often on the first flex, in FDM plastic whose layer bonds are weakest across
|
|
8
9
|
// exactly that corner. Seen on a real forge: a side-release buckle whose prongs, each a
|
|
9
10
|
// 2 mm prism unioned onto the base, broke off flush with the base face. A fillet at the
|
|
10
|
-
// root of about half the member's thickness spreads the stress and is what fixes it
|
|
11
|
+
// 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).
|
|
11
16
|
//
|
|
12
17
|
// A mesh cannot tell a member that bends from a wall, a rib, a tooth or a pin — every
|
|
13
18
|
// one of them is a thin feature joined at a sharp corner, and a shape-only detector
|
|
@@ -20,10 +25,27 @@
|
|
|
20
25
|
// root reads clean, and so does a member whose faces only continue coplanar into the
|
|
21
26
|
// body (its top face flush with the base's top).
|
|
22
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
|
+
//
|
|
23
42
|
// One pass over the triangles to weld edges, pure, no BVH. Works on both mesh forms the
|
|
24
43
|
// oracle sees (Manifold's soup, OCCT's indexed vertices) — but only on a mesh carrying
|
|
25
44
|
// `featureIds`/`features` (the feature labels), which both backends attach.
|
|
26
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
|
+
//
|
|
27
49
|
// Known limits, stated: an inside corner WITHIN one label (the member and its body
|
|
28
50
|
// labelled alike, or a barb's own notch) is invisible — label the flexing member apart
|
|
29
51
|
// from the body it grows from. A label that covers the whole sub-part, or a member that
|
|
@@ -34,45 +56,139 @@
|
|
|
34
56
|
// with the member: `flex: [["Arm", "Arm rib"]]` treats the group as one member.
|
|
35
57
|
|
|
36
58
|
// A concave edge turning more than this (degrees) is a sharp corner. A tessellated
|
|
37
|
-
// fillet turns a few degrees per facet (3.1° per step at the preview's 116 segments
|
|
38
|
-
//
|
|
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.
|
|
39
63
|
export const SHARP_DEG = 30;
|
|
40
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);
|
|
41
69
|
// Sharp root edges shorter than this in total (mm) are boolean noise, never reported.
|
|
42
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;
|
|
43
82
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
48
99
|
}
|
|
49
100
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
|
52
159
|
|
|
53
160
|
/**
|
|
54
161
|
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, featureIds?: ArrayLike<number>, features?: string[] }} mesh
|
|
55
162
|
* @param {string[]} group the labels making up one flexing member
|
|
56
|
-
* @
|
|
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 }}
|
|
57
167
|
* `found` false when no triangle carries any of the labels (the check cannot run);
|
|
58
168
|
* `attached` false when the member shares no edge with the rest of the mesh (nothing
|
|
59
169
|
* to read either — the label covers everything, or the member touches nothing);
|
|
60
170
|
* `sharpLength` the total length (mm) of sharp concave root edges, 0 under ROOT_FLOOR;
|
|
61
171
|
* `maxTurn` the sharpest such edge's turn in degrees and `at` the midpoint of the
|
|
62
|
-
* longest one (both null when the root is clean)
|
|
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.
|
|
63
178
|
*/
|
|
64
|
-
export function flexRoot(mesh, group) {
|
|
179
|
+
export function flexRoot(mesh, group, { printMatrix = null } = {}) {
|
|
65
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 };
|
|
66
182
|
const ids = new Set();
|
|
67
183
|
if (featureIds && features) features.forEach((f, i) => { if (group.includes(f)) ids.add(i + 1); });
|
|
68
|
-
if (!ids.size) return
|
|
184
|
+
if (!ids.size) return none;
|
|
69
185
|
const nTri = I ? I.length / 3 : P.length / 9;
|
|
70
186
|
const vi = (t, j) => (I ? I[t * 3 + j] : t * 3 + j);
|
|
71
187
|
const key = (i) => `${Math.round(P[i * 3] * 1e4)},${Math.round(P[i * 3 + 1] * 1e4)},${Math.round(P[i * 3 + 2] * 1e4)}`;
|
|
72
188
|
|
|
73
|
-
// Normals and centroids, and every edge of a member triangle, keyed by welded endpoints.
|
|
189
|
+
// Normals, areas and centroids, and every edge of a member triangle, keyed by welded endpoints.
|
|
74
190
|
const inMember = (t) => ids.has(featureIds[t]);
|
|
75
|
-
const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3);
|
|
191
|
+
const normal = new Float64Array(nTri * 3), cent = new Float64Array(nTri * 3), area2 = new Float64Array(nTri);
|
|
76
192
|
const edges = new Map(); // key -> { a, b, tris: [] }
|
|
77
193
|
const tri = (t) => [vi(t, 0), vi(t, 1), vi(t, 2)];
|
|
78
194
|
for (let t = 0; t < nTri; t++) {
|
|
@@ -81,17 +197,20 @@ export function flexRoot(mesh, group) {
|
|
|
81
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];
|
|
82
198
|
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
83
199
|
const len = Math.hypot(nx, ny, nz) || 1;
|
|
200
|
+
area2[t] = len; // twice the area
|
|
84
201
|
normal[t * 3] = nx / len; normal[t * 3 + 1] = ny / len; normal[t * 3 + 2] = nz / len;
|
|
85
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;
|
|
86
203
|
}
|
|
87
204
|
// Only edges touching a member triangle matter; register those first, then let any
|
|
88
|
-
// triangle sharing such an edge join it.
|
|
205
|
+
// triangle sharing such an edge join it. The member's welded vertices feed its extents.
|
|
89
206
|
const keys = [];
|
|
207
|
+
const memberVerts = new Map(); // welded key -> index
|
|
90
208
|
for (let t = 0; t < nTri; t++) {
|
|
91
209
|
const v = tri(t);
|
|
92
210
|
keys.push(v.map(key));
|
|
93
211
|
if (!inMember(t)) continue;
|
|
94
212
|
for (let j = 0; j < 3; j++) {
|
|
213
|
+
if (!memberVerts.has(keys[t][j])) memberVerts.set(keys[t][j], v[j]);
|
|
95
214
|
const ka = keys[t][j], kb = keys[t][(j + 1) % 3];
|
|
96
215
|
const ek = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
|
97
216
|
if (!edges.has(ek)) edges.set(ek, { a: v[j], b: v[(j + 1) % 3], tris: [] });
|
|
@@ -106,23 +225,84 @@ export function flexRoot(mesh, group) {
|
|
|
106
225
|
}
|
|
107
226
|
|
|
108
227
|
let sharpLength = 0, maxTurn = null, longest = 0, at = null, attached = false;
|
|
228
|
+
const starts = []; // rounded boundary edges: a root ray each
|
|
109
229
|
for (const e of edges.values()) {
|
|
110
230
|
if (e.tris.length !== 2) continue;
|
|
111
231
|
const [t1, t2] = e.tris;
|
|
112
232
|
if (inMember(t1) === inMember(t2)) continue; // inside the member, or not on it
|
|
113
233
|
attached = true; // a boundary, at any turn
|
|
114
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];
|
|
115
|
-
if (d > SHARP_COS) continue; // shallow: a fillet facet, a flush face
|
|
116
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;
|
|
117
|
-
// Concave: the second triangle lies in front of the first one's face.
|
|
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.
|
|
118
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);
|
|
119
|
-
|
|
239
|
+
const flush = d > FLUSH_COS;
|
|
240
|
+
if (!flush && front <= 1e-9) continue; // convex: an outside edge
|
|
120
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
|
+
}
|
|
121
254
|
const turn = (Math.acos(Math.max(-1, Math.min(1, d))) * 180) / Math.PI;
|
|
122
255
|
sharpLength += len;
|
|
123
256
|
if (maxTurn == null || turn > maxTurn) maxTurn = turn;
|
|
124
257
|
if (len > longest) { longest = len; at = [mx, my, mz]; }
|
|
125
258
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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 };
|
|
128
308
|
}
|
|
@@ -14,7 +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
|
|
17
|
+
import { flexRoot } from "./flex-root.js";
|
|
18
|
+
import { flexMembers } from "./flex-members.js";
|
|
18
19
|
import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
|
|
19
20
|
import { summarizeContours } from "./shape-probe.js";
|
|
20
21
|
|
|
@@ -326,7 +327,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
326
327
|
// member, no index, so it runs on every lap. verify passes its own list; alone, measure
|
|
327
328
|
// reads the part's. The list measured is stamped as `measuredFlex`, which verify
|
|
328
329
|
// compares before reusing a seed.
|
|
329
|
-
const
|
|
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;
|
|
330
335
|
const subBounds = [];
|
|
331
336
|
// Sheet parts: a view holding one sizes each printed sub-part in its print pose
|
|
332
337
|
// (verify fits the bed per part there), and every sheet row gets its 2-D facts under
|
|
@@ -339,7 +344,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
339
344
|
// The overhang reading poses each piece for print (printPoseOverhang), which reads the
|
|
340
345
|
// resolved params through place() — recorded into the same sink, since the reading
|
|
341
346
|
// depends on them.
|
|
342
|
-
const poseParams =
|
|
347
|
+
const poseParams = posed ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
|
|
343
348
|
// The print-pose sizes are read by one check, the process bed (verify passes whether
|
|
344
349
|
// its profile has one; alone, measure asks the part's own profile), and each costs a
|
|
345
350
|
// second build of the part — so they are built only for a bed.
|
|
@@ -355,7 +360,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
355
360
|
// (printPoseOverhang) is read off place() with the resolved params — geometry-free,
|
|
356
361
|
// so it is resolved here, ahead of the memo, which must key on it.
|
|
357
362
|
const printed = part.parts[name]?.exportable !== false && !sheet;
|
|
358
|
-
const printPose =
|
|
363
|
+
const printPose = posed && printed && poseParams?.p
|
|
359
364
|
? displayToPrintPose(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
|
|
360
365
|
: null;
|
|
361
366
|
const printMatrix = printPose?.matrix ?? null;
|
|
@@ -482,8 +487,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
482
487
|
// whose label this sub-part's mesh carries: the length of sharp inside corner where
|
|
483
488
|
// the member meets the rest of the part, the sharpest turn, and where the longest
|
|
484
489
|
// stretch sits. Null when nothing is declared; a sheet part is not printed, so none.
|
|
485
|
-
flexRoots:
|
|
486
|
-
?
|
|
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)
|
|
487
496
|
: null,
|
|
488
497
|
// The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
|
|
489
498
|
sheet: sheet
|
|
@@ -2,11 +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 {
|
|
6
|
-
import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
|
|
5
|
+
import { flexMembers } from "./flex-members.js";
|
|
6
|
+
import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor, rootFilletFor, strainFor, flexTiltFor } from "./dfm-profiles.js";
|
|
7
7
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
8
8
|
import { relevanceHash } from "../param-deps.js";
|
|
9
|
-
import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT } from "../verify-metrics.js";
|
|
9
|
+
import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError, FLEX_ROOT_HINT, worstFlexRoot } from "../verify-metrics.js";
|
|
10
10
|
import { processById } from "../process/registry.js";
|
|
11
11
|
import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
|
|
12
12
|
// Re-exported for the callers that read them from here; their one home is sheet/constants.js.
|
|
@@ -219,8 +219,8 @@ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget
|
|
|
219
219
|
// The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
|
|
220
220
|
// Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
|
|
221
221
|
// that declares only these has no verdict rather than a vacuous pass.
|
|
222
|
-
const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea", "bedContactPct", "footprintPct", "flexRoot"]);
|
|
223
|
-
const PRINT_ONLY_HINT = "minWall, wall, overhangArea, bedContactPct, footprintPct 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.";
|
|
224
224
|
|
|
225
225
|
// A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
|
|
226
226
|
// volunteered check warns with the reason; a declared one warns with it too, and is
|
|
@@ -282,7 +282,11 @@ function sheetRowChecks(s, declaredExp) {
|
|
|
282
282
|
// `footprintPct >= floor` on every printed sub-part.
|
|
283
283
|
// `accept` the cost ids the user accepted (`verify.accept`), stamped on each cost the
|
|
284
284
|
// process volunteered (never on one the author wrote in `expect`).
|
|
285
|
-
|
|
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 }) {
|
|
286
290
|
const checks = [];
|
|
287
291
|
// contacts/clearance/held are pair-derived, not scalar view metrics — peel
|
|
288
292
|
// them off before the registry loop. held.js explains the exportable bound.
|
|
@@ -325,21 +329,37 @@ export function evaluateCase(facts, { profile, expect, subPartNames, exportableN
|
|
|
325
329
|
message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
|
|
326
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." });
|
|
327
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;
|
|
328
346
|
const merged = {
|
|
329
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}` } : {}),
|
|
330
353
|
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
331
354
|
...(bedContact != null && (!printedNames || printedNames.has(s.name)) ? { bedContactPct: `>=${bedContact}` } : {}),
|
|
332
355
|
...(footprint != null && (!printedNames || printedNames.has(s.name)) ? { footprintPct: `>=${footprint}` } : {}),
|
|
333
|
-
// A declared flexing member this sub-part carries AND meets (flex-root.js): its
|
|
334
|
-
// root must be rounded. A carried member that meets nothing is flexChecks' row.
|
|
335
|
-
...(flex.length && s.flexRoots?.some((r) => r.attached) ? { flexRoot: "0" } : {}),
|
|
336
356
|
...(expect?.[s.name] ?? {}),
|
|
337
357
|
};
|
|
338
358
|
// `accept` covers only the process's own default check: a cost metric the author
|
|
339
359
|
// wrote in `expect` is the user's own limit and keeps its meaning, never accepted.
|
|
340
360
|
const authored = expect?.[s.name] ?? {};
|
|
341
361
|
const acceptFor = (metric) => (Object.hasOwn(authored, metric) ? [] : accept);
|
|
342
|
-
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))) {
|
|
343
363
|
if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
|
|
344
364
|
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
345
365
|
hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
|
|
@@ -388,9 +408,8 @@ export { FLEX_ROOT_HINT };
|
|
|
388
408
|
const sameGroup = (a, b) => a.length === b.length && a.every((l, i) => l === b[i]);
|
|
389
409
|
function flexChecks(facts, flex) {
|
|
390
410
|
const out = [];
|
|
391
|
-
for (const g of flex) {
|
|
392
|
-
const
|
|
393
|
-
const base = { scope: "subpart", metric: "flexRoot", kind: "gate", feature, expr: "0", subpart: null, actual: null, status: "skip", pass: null };
|
|
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 };
|
|
394
413
|
if (!(facts.measuredFlex ?? []).some((m) => sameGroup(m, g))) {
|
|
395
414
|
out.push({ ...base, unevaluated: true,
|
|
396
415
|
message: "not measured (quick check)", hint: "re-run this check without `quick` to read the flexing member's root" });
|
|
@@ -437,7 +456,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
437
456
|
const bedContactFloor = bedContactFor(part, process, { expanded });
|
|
438
457
|
const footprintFloor = footprintFor(part, process, { expanded });
|
|
439
458
|
const accept = Array.isArray(part?.verify?.accept) ? part.verify.accept : [];
|
|
440
|
-
const flex =
|
|
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 });
|
|
441
463
|
// Reuse rule (spec §4): case B may reuse a measurement taken at params A when
|
|
442
464
|
// B agrees with A on every param that measurement READ (recorded by measure()
|
|
443
465
|
// itself). Sound for the same reason the mesh cache is: a deterministic build
|
|
@@ -512,7 +534,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
512
534
|
const needPrintBboxes = profile?.bed != null;
|
|
513
535
|
const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
|
|
514
536
|
// The flex half: a seed that read every declared member (a superset is free).
|
|
515
|
-
const flexMatches = flex.every((
|
|
537
|
+
const flexMatches = flex.every(({ labels }) => (seed?.result?.measuredFlex ?? []).some((m) => sameGroup(m, labels)));
|
|
516
538
|
if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && (quick || bedContactMatches) && (quick || flexMatches) && printBboxesMatch
|
|
517
539
|
&& seed.result.view === view) {
|
|
518
540
|
entries.push({ params: full(seed.params), result: seed.result });
|
|
@@ -548,7 +570,7 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
548
570
|
}];
|
|
549
571
|
const caseResults = expanded.map(({ name, params, expect }) => {
|
|
550
572
|
const facts = measureCase(params);
|
|
551
|
-
return { name, params, checks: facts ? evaluateCase(facts, { profile, expect, subPartNames, exportableNames, overhang: overhangAngle, bedContact: bedContactFloor, footprint: footprintFloor, accept, flex }) : 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) };
|
|
552
574
|
});
|
|
553
575
|
// VACUOUS VERIFY. A part that declares nothing — no profile, an empty or absent
|
|
554
576
|
// `expect` — used to come back `ok: true` with zero checks, and every reader
|
|
@@ -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: {},
|
|
@@ -66,12 +66,29 @@ export function sidesSpecError(metric, reg, spec) {
|
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// The flexing-member
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
+
};
|
|
75
92
|
|
|
76
93
|
export const SUBPART_METRICS = {
|
|
77
94
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
@@ -142,16 +159,16 @@ export const SUBPART_METRICS = {
|
|
|
142
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)",
|
|
143
160
|
pattern: "piece-stands-on-nothing",
|
|
144
161
|
locate: (s) => s.footprintAt },
|
|
145
|
-
// A declared flexing member's root (oracle/flex-root.js, `verify.flex`):
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
flexRoot: { kind: "gate", extract: (s) => worstFlexRoot(s)
|
|
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; },
|
|
155
172
|
hint: FLEX_ROOT_HINT,
|
|
156
173
|
pattern: "flex-root-sharp",
|
|
157
174
|
locate: (s) => worstFlexRoot(s)?.at ?? null,
|
|
@@ -160,8 +177,32 @@ export const SUBPART_METRICS = {
|
|
|
160
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",
|
|
161
178
|
note: (s) => {
|
|
162
179
|
const rows = s.flexRoots ?? [];
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
165
206
|
} },
|
|
166
207
|
// `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
|
|
167
208
|
// "<import name>"`; on every other sub-part `extract` returns null, which
|
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
|
}
|
|
@@ -582,11 +600,24 @@ export interface SubPartExpectations {
|
|
|
582
600
|
*/
|
|
583
601
|
footprintPct?: Expectation;
|
|
584
602
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
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.
|
|
588
607
|
*/
|
|
589
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;
|
|
590
621
|
/** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
|
|
591
622
|
refXorVolume?: Expectation;
|
|
592
623
|
/** Percent volume delta vs. the sub-part's declared `reference` import. */
|
|
@@ -665,16 +696,19 @@ export interface VerifyBlock<P = ResolvedParams, D = Derived> {
|
|
|
665
696
|
* process's own default check is marked accepted: a limit you wrote in `expect`
|
|
666
697
|
* (`overhangArea: "<=500"`) keeps its meaning and is never marked accepted.
|
|
667
698
|
*/
|
|
668
|
-
accept?: ("supports" | "footprint")[];
|
|
699
|
+
accept?: ("supports" | "footprint" | "standing")[];
|
|
669
700
|
/**
|
|
670
701
|
* The part's flexing members — snap arms, buckle prongs, clips, cantilever tabs —
|
|
671
|
-
* by feature label. Each must meet the rest of its part through a
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
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.
|
|
676
710
|
*/
|
|
677
|
-
flex?: (string | string[])[];
|
|
711
|
+
flex?: (string | string[] | { feature: string; labels?: string[]; deflection?: number; thickness?: number })[];
|
|
678
712
|
/** Which cases to check; default is `"defaults"` plus every preset name. */
|
|
679
713
|
cases?: string[];
|
|
680
714
|
/**
|
package/types/testing.d.ts
CHANGED
|
@@ -447,7 +447,16 @@ export interface SubPartFacts {
|
|
|
447
447
|
* a sheet part. `attached` is false when the member shares no edge with the rest of the
|
|
448
448
|
* mesh (nothing to read); `sharpLength` the mm of sharp inside corner at its root.
|
|
449
449
|
*/
|
|
450
|
-
flexRoots: {
|
|
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;
|
|
451
460
|
/**
|
|
452
461
|
* Present only when bed contact had to be read off the piece's export build (its
|
|
453
462
|
* print pose could not be read from place() alone) and that build failed.
|