partforge 0.163.0 → 0.164.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/bin/cli.js +9 -5
- package/docs/AUTHORING-PARTS.md +195 -36
- package/docs/ERROR-PATTERNS.md +12 -0
- package/package.json +1 -1
- package/src/framework/geometry/occt-backend.js +14 -1
- package/src/framework/jobs.js +7 -1
- package/src/framework/kernel-error.js +161 -0
- package/src/framework/lint/rules-verify.js +35 -2
- package/src/framework/oracle/bed-contact.js +94 -12
- package/src/framework/oracle/dfm-profiles.js +42 -51
- package/src/framework/oracle/gates.js +7 -1
- package/src/framework/oracle/measure.js +40 -15
- package/src/framework/oracle/verify.js +44 -20
- package/src/framework/process/fdm/descriptor.js +48 -0
- package/src/framework/process/registry.js +30 -0
- package/src/framework/process/resin/descriptor.js +19 -0
- package/src/framework/verify-metrics.js +17 -6
- package/src/framework/worker.js +7 -2
- package/types/part.d.ts +23 -9
- package/types/testing.d.ts +17 -3
|
@@ -7,7 +7,7 @@ import { SUBPART_METRICS, VIEW_METRICS, BOUNDS_FORM, teachableMetrics, sidesSpec
|
|
|
7
7
|
import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
|
|
8
8
|
import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
|
|
9
9
|
import { suggest } from "../geometry/op-options.js";
|
|
10
|
-
import { PROCESS_IDS } from "../process/registry.js";
|
|
10
|
+
import { PRINT_PROCESSES, PROCESS_IDS, printProcessFor } from "../process/registry.js";
|
|
11
11
|
import { SHEET_DOC_ID } from "../sheet/constants.js";
|
|
12
12
|
|
|
13
13
|
// Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
|
|
@@ -410,8 +410,41 @@ export const VERIFY_RULES = [
|
|
|
410
410
|
const hint = safeSuggest(orientation, ORIENTATIONS);
|
|
411
411
|
return [err("verify-unknown-orientation",
|
|
412
412
|
`\`verify.orientation\` is ${describe(orientation)}, which is not a known orientation`,
|
|
413
|
-
`Use ${ORIENTATIONS.map((o) => `"${o}"`).join(", ")}${hint ? ` — did you mean "${hint}"?` : ""} (declares
|
|
413
|
+
`Use ${ORIENTATIONS.map((o) => `"${o}"`).join(", ")}${hint ? ` — did you mean "${hint}"?` : ""} (declares that \`build()\` is the print pose: Z up, bed at the lowest Z), or omit the key.`,
|
|
414
414
|
"verify.orientation")];
|
|
415
415
|
},
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
// `verify.accept` lists cost ids the user accepted. verify() reads it but
|
|
419
|
+
// never validates it, so a typo would be silently ignored without this rule.
|
|
420
|
+
// A process that does not resolve is verify-unknown-process's finding, not ours.
|
|
421
|
+
id: "verify-unknown-accept",
|
|
422
|
+
run: ({ part }) => {
|
|
423
|
+
const verify = part?.verify;
|
|
424
|
+
if (verify?.accept == null) return [];
|
|
425
|
+
if (!Array.isArray(verify.accept)) {
|
|
426
|
+
return [err("verify-unknown-accept",
|
|
427
|
+
`\`verify.accept\` is ${describe(verify.accept)}, not a list of cost ids`,
|
|
428
|
+
"Write `accept` as an array of cost ids, e.g. `accept: [\"supports\"]`, or omit the key.",
|
|
429
|
+
"verify.accept")];
|
|
430
|
+
}
|
|
431
|
+
// With no print process resolved, a cost can still fire (an authored expectation,
|
|
432
|
+
// an inline profile with no base), so check against every print process's ids.
|
|
433
|
+
const proc = printProcessFor(verify.process);
|
|
434
|
+
const known = proc ? Object.keys(proc.costs) : [...new Set(PRINT_PROCESSES.flatMap((d) => Object.keys(d.costs)))];
|
|
435
|
+
const out = [];
|
|
436
|
+
verify.accept.forEach((id, i) => {
|
|
437
|
+
if (typeof id === "string" && known.includes(id)) return;
|
|
438
|
+
out.push(err("verify-unknown-accept",
|
|
439
|
+
`\`verify.accept[${i}]\` is ${describe(id)}, which is not a cost ${proc ? "this process" : "any print process"} defines`,
|
|
440
|
+
!proc
|
|
441
|
+
? `No print process is declared; the cost ids any print process knows are: ${known.join(", ")}.`
|
|
442
|
+
: known.length
|
|
443
|
+
? `Known cost ids for this process: ${known.join(", ")}.`
|
|
444
|
+
: "This process defines no acceptable costs; remove the entry.",
|
|
445
|
+
`verify.accept[${i}]`));
|
|
446
|
+
});
|
|
447
|
+
return out;
|
|
448
|
+
},
|
|
449
|
+
},
|
|
417
450
|
];
|
|
@@ -65,6 +65,59 @@ export const HOVER_FLOOR = 1;
|
|
|
65
65
|
// The footprint slack never exceeds this (mm): well under a first layer, and under the
|
|
66
66
|
// smallest hover worth reporting, so a tall piece is judged the way a short one is.
|
|
67
67
|
export const MAX_BED_EPS = 0.05;
|
|
68
|
+
// The silhouette raster (the footprint's denominator): the finest cell pitch (mm), and
|
|
69
|
+
// the most cells along the longer side — so a large piece costs a 512×512 bitmap at most.
|
|
70
|
+
const SILHOUETTE_MIN_PITCH = 0.25;
|
|
71
|
+
const SILHOUETTE_CELLS = 512;
|
|
72
|
+
|
|
73
|
+
// Mark one downward triangle's xy shadow on the silhouette grid: every cell whose centre
|
|
74
|
+
// lies inside it. Each grid row is filled as one span — the x-range where the row's
|
|
75
|
+
// centre line crosses the triangle — so the work is the cells the triangle covers plus
|
|
76
|
+
// one intersection per row, never its whole bounding rectangle (a fine fan of slivers
|
|
77
|
+
// over the same floor used to rescan that rectangle once per sliver). A triangle too
|
|
78
|
+
// thin to contain any centre (a wall thinner than a cell) marks the cells along its
|
|
79
|
+
// outline instead (sampled at half a pitch), so it is never dropped between centres.
|
|
80
|
+
// Marking edge cells for EVERY triangle was tried and over-covered a 1 mm tube wall by
|
|
81
|
+
// a third; centre sampling is unbiased along an edge.
|
|
82
|
+
function shadow(P, a, b, c, minX, minY, pitch, gx, gy, grid, mark) {
|
|
83
|
+
const ax = P[a], ay = P[a + 1], bx = P[b], by = P[b + 1], cx = P[c], cy = P[c + 1];
|
|
84
|
+
const area2 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
85
|
+
let hit = false;
|
|
86
|
+
if (area2 !== 0) {
|
|
87
|
+
const j0 = Math.max(0, Math.floor((Math.min(ay, by, cy) - minY) / pitch));
|
|
88
|
+
const j1 = Math.min(gy - 1, Math.floor((Math.max(ay, by, cy) - minY) / pitch));
|
|
89
|
+
for (let j = j0; j <= j1; j++) {
|
|
90
|
+
const y = minY + (j + 0.5) * pitch;
|
|
91
|
+
// Where this row's centre line enters and leaves the triangle: the extreme x of its
|
|
92
|
+
// crossings with the three edges (an edge lying on the line contributes both ends).
|
|
93
|
+
let lo = Infinity, hi = -Infinity;
|
|
94
|
+
const cross = (x0, y0, x1, y1) => {
|
|
95
|
+
if (!((y0 <= y && y <= y1) || (y1 <= y && y <= y0))) return;
|
|
96
|
+
if (y0 === y1) { lo = Math.min(lo, x0, x1); hi = Math.max(hi, x0, x1); return; }
|
|
97
|
+
const x = x0 + ((y - y0) * (x1 - x0)) / (y1 - y0);
|
|
98
|
+
if (x < lo) lo = x;
|
|
99
|
+
if (x > hi) hi = x;
|
|
100
|
+
};
|
|
101
|
+
cross(ax, ay, bx, by); cross(bx, by, cx, cy); cross(cx, cy, ax, ay);
|
|
102
|
+
if (lo > hi) continue;
|
|
103
|
+
const i0 = Math.max(0, Math.ceil((lo - minX) / pitch - 0.5));
|
|
104
|
+
const i1 = Math.min(gx - 1, Math.floor((hi - minX) / pitch - 0.5));
|
|
105
|
+
for (let i = i0; i <= i1; i++) grid[j * gx + i] = 1;
|
|
106
|
+
if (i1 >= i0) hit = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (hit) return;
|
|
110
|
+
// The fallback samples the triangle shrunk by half toward its centroid, not its own
|
|
111
|
+
// edges: a fan sliver of a disc's bottom then marks cells inside the disc, never the
|
|
112
|
+
// cells its rim crosses (which over-covered a Ø5 pin by a tenth).
|
|
113
|
+
const gxc = (ax + bx + cx) / 3, gyc = (ay + by + cy) / 3;
|
|
114
|
+
const h = ([x, y]) => [(x + gxc) / 2, (y + gyc) / 2];
|
|
115
|
+
const [pa, pb, pc] = [h([ax, ay]), h([bx, by]), h([cx, cy])];
|
|
116
|
+
for (const [x0, y0, x1, y1] of [[...pa, ...pb], [...pb, ...pc], [...pc, ...pa]]) {
|
|
117
|
+
const steps = Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (pitch / 2)) + 1;
|
|
118
|
+
for (let t = 0; t <= steps; t++) mark(x0 + ((x1 - x0) * t) / steps, y0 + ((y1 - y0) * t) / steps);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
68
121
|
|
|
69
122
|
/**
|
|
70
123
|
* @param {{ positions: ArrayLike<number>, indices?: ArrayLike<number>, triangles: number }} mesh
|
|
@@ -73,38 +126,64 @@ export const MAX_BED_EPS = 0.05;
|
|
|
73
126
|
* scanned); `bedEps` the footprint slack (default 0.1% of the mesh height, floored at
|
|
74
127
|
* 0.01 mm and capped at MAX_BED_EPS); `bedBand` the height above the bed within which a flat face hovers
|
|
75
128
|
* (default 1 mm)
|
|
76
|
-
* @returns {{ pct: number, contactArea: number, hoverArea: number, gap: number|null, at: number[]|null }|null}
|
|
129
|
+
* @returns {{ pct: number, contactArea: number, hoverArea: number, downArea: number, gap: number|null, at: number[]|null, base: number[], lowestAt: number[] }|null}
|
|
77
130
|
* `pct` 0..100, the share of the near-bed flat underside on the bed; the two areas in
|
|
78
131
|
* mm²; `gap` how far above the bed the largest hovering face sits, and `at` its
|
|
79
|
-
* centroid (null when nothing hovers past the floor);
|
|
132
|
+
* centroid (null when nothing hovers past the floor); `downArea` the piece's
|
|
133
|
+
* silhouette on the bed (mm²): the UNION of every downward-facing triangle's shadow,
|
|
134
|
+
* rasterized (a closed mesh's silhouette is exactly that union, since every point of
|
|
135
|
+
* it has a bottom-most face, which faces down), and
|
|
136
|
+
* `lowestAt` the mesh's lowest vertex — measure.js's footprint cost, the contact area
|
|
137
|
+
* over `downArea`, catches what `pct` cannot: a piece with almost no flat underside
|
|
138
|
+
* at all (a sphere-tipped rod on its end) hovers nothing and reads 100. `base`, the
|
|
139
|
+
* mesh's [dx, dy] extent, is reported but no longer divided by: a tube on its rim, a
|
|
140
|
+
* frame or a diagonal bar fills little of its bounding box yet stands on all of its
|
|
141
|
+
* underside. Null for an empty mesh.
|
|
80
142
|
*/
|
|
81
143
|
export function bedContact(mesh, { bedZ, bedEps, bedBand = 1 } = {}) {
|
|
82
144
|
const { positions, indices } = mesh;
|
|
83
145
|
const n = indices ? indices.length : positions.length / 3;
|
|
84
146
|
if (n < 3) return null;
|
|
85
147
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
148
|
+
// One scan for the extents: the bed (lowest Z) and the slack's height when the caller
|
|
149
|
+
// did not hand them in, and always the posed base (its xy extent, reported) and the
|
|
150
|
+
// lowest vertex (where the footprint cost is located).
|
|
151
|
+
let minZ = Infinity, maxZ = -Infinity, minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, low = 0;
|
|
152
|
+
for (let i = 0; i + 2 < positions.length; i += 3) {
|
|
153
|
+
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
|
|
154
|
+
if (x < minX) minX = x;
|
|
155
|
+
if (x > maxX) maxX = x;
|
|
156
|
+
if (y < minY) minY = y;
|
|
157
|
+
if (y > maxY) maxY = y;
|
|
158
|
+
if (z < minZ) { minZ = z; low = i; }
|
|
159
|
+
if (z > maxZ) maxZ = z;
|
|
93
160
|
}
|
|
161
|
+
const base = [maxX - minX, maxY - minY];
|
|
162
|
+
const lowestAt = [positions[low], positions[low + 1], positions[low + 2]];
|
|
94
163
|
const bed = bedZ ?? minZ;
|
|
95
164
|
const eps = bedEps ?? Math.min(MAX_BED_EPS, Math.max(0.01, 1e-3 * (maxZ - minZ)));
|
|
96
165
|
const bandTop = bed + bedBand;
|
|
166
|
+
// The silhouette raster: an xy grid over the base, at most ~512 cells a side.
|
|
167
|
+
const pitch = Math.max(SILHOUETTE_MIN_PITCH, Math.max(base[0], base[1]) / SILHOUETTE_CELLS);
|
|
168
|
+
const gx = Math.max(1, Math.ceil(base[0] / pitch) + 1), gy = Math.max(1, Math.ceil(base[1] / pitch) + 1);
|
|
169
|
+
const grid = new Uint8Array(gx * gy);
|
|
170
|
+
const mark = (x, y) => {
|
|
171
|
+
const i = Math.min(gx - 1, Math.max(0, Math.floor((x - minX) / pitch)));
|
|
172
|
+
const j = Math.min(gy - 1, Math.max(0, Math.floor((y - minY) / pitch)));
|
|
173
|
+
grid[j * gx + i] = 1;
|
|
174
|
+
};
|
|
97
175
|
|
|
98
176
|
let contactArea = 0, hoverArea = 0, largest = 0, gap = null, at = null;
|
|
99
177
|
for (let i = 0; i < n; i += 3) {
|
|
100
178
|
const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
|
|
101
179
|
const az = positions[a + 2], bz = positions[b + 2], cz = positions[c + 2];
|
|
102
180
|
const cenZ = (az + bz + cz) / 3;
|
|
103
|
-
if (cenZ > bandTop) continue; // above the band: not the underside
|
|
104
181
|
const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = bz - az;
|
|
105
182
|
const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = cz - az;
|
|
106
183
|
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
107
184
|
if (nz >= 0) continue; // not facing down
|
|
185
|
+
shadow(positions, a, b, c, minX, minY, pitch, gx, gy, grid, mark); // its shadow joins the silhouette
|
|
186
|
+
if (cenZ > bandTop) continue; // above the band: not the underside
|
|
108
187
|
const len = Math.hypot(nx, ny, nz);
|
|
109
188
|
if (len < 1e-9) continue; // degenerate: no normal
|
|
110
189
|
const triArea = len / 2;
|
|
@@ -122,6 +201,9 @@ export function bedContact(mesh, { bedZ, bedEps, bedBand = 1 } = {}) {
|
|
|
122
201
|
cenZ];
|
|
123
202
|
}
|
|
124
203
|
}
|
|
125
|
-
|
|
126
|
-
|
|
204
|
+
let cells = 0;
|
|
205
|
+
for (let i = 0; i < grid.length; i++) cells += grid[i];
|
|
206
|
+
const downArea = cells * pitch * pitch;
|
|
207
|
+
if (hoverArea < HOVER_FLOOR) return { pct: 100, contactArea, hoverArea, downArea, gap: null, at: null, base, lowestAt };
|
|
208
|
+
return { pct: (100 * contactArea) / (contactArea + hoverArea), contactArea, hoverArea, downArea, gap, at, base, lowestAt };
|
|
127
209
|
}
|
|
@@ -1,41 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// (a warn, armed only by the part — see overhangAngleFor; resin prints on
|
|
6
|
-
// supports, so it carries none); `bedContact` is the least share (%) of a printed
|
|
7
|
-
// piece's near-bed flat underside that must rest on the bed (a GATE, armed by the
|
|
8
|
-
// profile alone — see bedContactFor; resin prints on supports, so it carries none);
|
|
9
|
-
// `clearance` mm is carried for a future gap check (not enforced yet).
|
|
10
|
-
//
|
|
11
|
-
// `fits`, `holeUndersize` and `bridge` are the numbers an author (or the cloud
|
|
12
|
-
// agent) copies into derive() for a mating feature; nothing in this repo reads
|
|
13
|
-
// them yet. They are guide defaults from the FDM design guides, not measurements:
|
|
14
|
-
// a calibrated printer overrides them through an inline `{ base, fits: {...} }`
|
|
15
|
-
// profile, which REPLACES the fits table whole. `fits` are diametral offsets in
|
|
16
|
-
// mm (negative = interference): press (a pin that never comes out), push (a cap
|
|
17
|
-
// pressed on by hand), slip (a lid that lifts off), moving (a hinge pin or axle),
|
|
18
|
-
// printInPlace (parts printed already assembled). `holeUndersize` is added to a
|
|
19
|
-
// horizontal bore's diameter, since the top of a printed arch sags inward.
|
|
20
|
-
// `bridge` is the longest flat span the process prints unsupported; resin prints
|
|
21
|
-
// on supports, so it carries none.
|
|
22
|
-
export const PROFILES = {
|
|
23
|
-
"fdm-pla": {
|
|
24
|
-
bed: [220, 220, 250], minWall: 1.2, clearance: 0.2, overhang: 45, bedContact: 50,
|
|
25
|
-
fits: { press: -0.1, push: 0.05, slip: 0.25, moving: 0.35, printInPlace: 0.45 },
|
|
26
|
-
holeUndersize: 0.25, bridge: 10,
|
|
27
|
-
},
|
|
28
|
-
"fdm-petg": {
|
|
29
|
-
bed: [220, 220, 250], minWall: 1.5, clearance: 0.3, overhang: 45, bedContact: 50,
|
|
30
|
-
fits: { press: -0.1, push: 0.05, slip: 0.3, moving: 0.4, printInPlace: 0.5 },
|
|
31
|
-
holeUndersize: 0.3, bridge: 10,
|
|
32
|
-
},
|
|
33
|
-
"resin": {
|
|
34
|
-
bed: [120, 68, 160], minWall: 0.6, clearance: 0.1,
|
|
35
|
-
fits: { press: -0.05, push: 0.05, slip: 0.1, moving: 0.15, printInPlace: 0.2 },
|
|
36
|
-
holeUndersize: 0.05,
|
|
37
|
-
},
|
|
38
|
-
};
|
|
1
|
+
// The profiles live on the print process descriptors (process/fdm, process/resin);
|
|
2
|
+
// PROFILES is the registry's merged map, re-exported so every import keeps working.
|
|
3
|
+
import { PRINT_PROFILES as PROFILES } from "../process/registry.js";
|
|
4
|
+
export { PROFILES };
|
|
39
5
|
|
|
40
6
|
export function resolveProfile(spec) {
|
|
41
7
|
if (typeof spec === "string") {
|
|
@@ -62,11 +28,11 @@ export const ORIENTATIONS = ["print"];
|
|
|
62
28
|
export const DEFAULT_OVERHANG_ANGLE = 45;
|
|
63
29
|
|
|
64
30
|
// Which overhang angle a part is checked against, or null when it is not checked.
|
|
65
|
-
// Two ways in
|
|
66
|
-
//
|
|
67
|
-
// not
|
|
68
|
-
//
|
|
69
|
-
//
|
|
31
|
+
// Two ways in:
|
|
32
|
+
// - the print process alone: a profile carrying `overhang` arms it (a profile
|
|
33
|
+
// without one, resin, checks nothing). The reading is a COST, not a warning, so a
|
|
34
|
+
// part still being shaped is told what supports would cost, never nagged;
|
|
35
|
+
// `verify.orientation` no longer takes part in the decision;
|
|
70
36
|
// - an `overhangArea` expectation the author wrote themselves, in any case's
|
|
71
37
|
// `expect` (`expanded`, from gates.js's expandExpectations) — measured against
|
|
72
38
|
// the profile's angle, or DEFAULT_OVERHANG_ANGLE when the profile has none,
|
|
@@ -82,10 +48,10 @@ export function overhangAngleFor(part, process, { expanded = [] } = {}) {
|
|
|
82
48
|
const spec = process ?? part?.verify?.process;
|
|
83
49
|
const fromProfile = spec ? resolveProfile(spec).overhang : undefined;
|
|
84
50
|
const angle = typeof fromProfile === "number" && Number.isFinite(fromProfile) ? fromProfile : null;
|
|
85
|
-
if (
|
|
51
|
+
if (angle != null) return angle;
|
|
86
52
|
const asserted = expanded.some(({ expect }) =>
|
|
87
53
|
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "overhangArea" in o));
|
|
88
|
-
return asserted ?
|
|
54
|
+
return asserted ? DEFAULT_OVERHANG_ANGLE : null;
|
|
89
55
|
}
|
|
90
56
|
|
|
91
57
|
// The `bedContactPct` floor an author's own expectation or `orientation: "print"` arms
|
|
@@ -93,12 +59,11 @@ export function overhangAngleFor(part, process, { expanded = [] } = {}) {
|
|
|
93
59
|
export const DEFAULT_BED_CONTACT = 50;
|
|
94
60
|
|
|
95
61
|
// The least share of a printed piece's near-bed flat underside that must touch the bed
|
|
96
|
-
// (oracle/bed-contact.js), or null when it is not checked.
|
|
97
|
-
// PROFILE ALONE arms it
|
|
98
|
-
//
|
|
99
|
-
// piece
|
|
100
|
-
//
|
|
101
|
-
// profile is checked from its first build. Three ways in:
|
|
62
|
+
// (oracle/bed-contact.js), or null when it is not checked. Like overhangAngleFor the
|
|
63
|
+
// PROFILE ALONE arms it, but as a GATE, not a cost: a flat underside hovering a fraction
|
|
64
|
+
// of a millimetre above the piece's lowest point is a defect in whatever orientation the
|
|
65
|
+
// piece is exported — every exporter and slicer lays it on that lowest point — so a part
|
|
66
|
+
// carrying an FDM profile is checked from its first build. Three ways in:
|
|
102
67
|
// - a profile carrying `bedContact` (the FDM profiles; resin carries none);
|
|
103
68
|
// - `verify.orientation: "print"` with NO profile. A named profile decides alone:
|
|
104
69
|
// resin prints on supports, so orientation must not arm it there (overhangAngleFor's
|
|
@@ -118,3 +83,29 @@ export function bedContactFor(part, process, { expanded = [] } = {}) {
|
|
|
118
83
|
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "bedContactPct" in o));
|
|
119
84
|
return asserted ? DEFAULT_BED_CONTACT : null;
|
|
120
85
|
}
|
|
86
|
+
|
|
87
|
+
// The `footprintPct` floor an author's own expectation arms the measurement against
|
|
88
|
+
// when the profile names none (a resin part, or no profile at all).
|
|
89
|
+
export const DEFAULT_FOOTPRINT = 10;
|
|
90
|
+
|
|
91
|
+
// The least share of a printed piece's silhouette on the bed that must actually touch
|
|
92
|
+
// it (the footprint, read by bed contact's pass — measure.js `footprintPct`), or null when it
|
|
93
|
+
// is not checked. A COST, like overhang: a piece standing on a point, a line or a curl
|
|
94
|
+
// prints with a brim or a raft, which the user may well choose. Two ways in:
|
|
95
|
+
// - a profile carrying `footprint` (the FDM profiles; resin carries none, since it
|
|
96
|
+
// prints on supports), and `{ base: "fdm-pla", footprint: null }` stays off;
|
|
97
|
+
// - a `footprintPct` expectation the author wrote themselves, in any case's `expect`.
|
|
98
|
+
// `verify.orientation` takes no part (overhangAngleFor's rule), but an invalid value
|
|
99
|
+
// still throws, as it does there.
|
|
100
|
+
export function footprintFor(part, process, { expanded = [] } = {}) {
|
|
101
|
+
const orientation = part?.verify?.orientation;
|
|
102
|
+
if (orientation != null && !ORIENTATIONS.includes(orientation)) {
|
|
103
|
+
throw new Error(`unknown verify.orientation: ${JSON.stringify(orientation)} (known: ${ORIENTATIONS.join(", ")})`);
|
|
104
|
+
}
|
|
105
|
+
const spec = process ?? part?.verify?.process;
|
|
106
|
+
const fromProfile = spec ? resolveProfile(spec).footprint : undefined;
|
|
107
|
+
if (typeof fromProfile === "number" && Number.isFinite(fromProfile)) return fromProfile;
|
|
108
|
+
const asserted = expanded.some(({ expect }) =>
|
|
109
|
+
Object.entries(expect ?? {}).some(([name, o]) => name !== "_view" && o && typeof o === "object" && "footprintPct" in o));
|
|
110
|
+
return asserted ? DEFAULT_FOOTPRINT : null;
|
|
111
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// conservative direction, since that is the full-resolution behaviour every part
|
|
13
13
|
// had before budgets existed. The real error surfaces from verify, which is where
|
|
14
14
|
// a reader can act on it.
|
|
15
|
-
import { resolveProfile, overhangAngleFor, bedContactFor } from "./dfm-profiles.js";
|
|
15
|
+
import { resolveProfile, overhangAngleFor, bedContactFor, footprintFor } from "./dfm-profiles.js";
|
|
16
16
|
import { expandCases } from "./cases.js";
|
|
17
17
|
import { resolveParams } from "../part-model.js";
|
|
18
18
|
import { parseAssertion } from "./assert-dsl.js";
|
|
@@ -56,6 +56,12 @@ export function partBedContact(part, { process, expanded } = {}) {
|
|
|
56
56
|
try { return bedContactFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// The footprint floor a part is checked against, or null when not checked
|
|
60
|
+
// (dfm-profiles.js footprintFor holds the rule). Total, like partBedContact.
|
|
61
|
+
export function partFootprint(part, { process, expanded } = {}) {
|
|
62
|
+
try { return footprintFor(part, process, { expanded: expanded ?? expandExpectations(part) }); } catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
export function partGatesMinWall(part, { process, expanded } = {}) {
|
|
60
66
|
try {
|
|
61
67
|
const spec = process ?? part?.verify?.process;
|
|
@@ -14,7 +14,7 @@ import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
|
14
14
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
15
15
|
import { overhang } from "./overhang.js";
|
|
16
16
|
import { bedContact } from "./bed-contact.js";
|
|
17
|
-
import { partBedContact, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
|
|
17
|
+
import { partBedContact, partFootprint, partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
|
|
18
18
|
import { summarizeContours } from "./shape-probe.js";
|
|
19
19
|
|
|
20
20
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
@@ -133,15 +133,15 @@ const transformPoint = (m, [x, y, z]) => [
|
|
|
133
133
|
// Bed contact read off the piece's real EXPORT build, for a sub-part whose print pose
|
|
134
134
|
// the probes could not read (a place() that queries its solid): judging the display
|
|
135
135
|
// mesh there would pass a bad print orientation or fail a good one. One extra build,
|
|
136
|
-
// in its own cache round like printPoseBboxes, only for such a piece. `at`
|
|
137
|
-
// with the pose unreadable there is no honest way back to
|
|
138
|
-
// export pose that does not build answers `{ error }`, which
|
|
139
|
-
// unevaluated rather than guessing.
|
|
136
|
+
// in its own cache round like printPoseBboxes, only for such a piece. `at` and
|
|
137
|
+
// `lowestAt` are withheld: with the pose unreadable there is no honest way back to
|
|
138
|
+
// display coordinates. An export pose that does not build answers `{ error }`, which
|
|
139
|
+
// verify reports as unevaluated rather than guessing.
|
|
140
140
|
function exportPoseBedContact(kernel, part, name, view, { p, d }) {
|
|
141
141
|
kernel.beginSubPart?.(`oracle:print:${view}`);
|
|
142
142
|
try {
|
|
143
143
|
const out = bedContact(buildPosed(kernel, part, name, { purpose: "export", view, p, d }).toMesh());
|
|
144
|
-
return out ? { ...out, at: null } : out;
|
|
144
|
+
return out ? { ...out, at: null, lowestAt: null } : out;
|
|
145
145
|
} catch (e) {
|
|
146
146
|
return { error: String(e?.message || e).slice(0, 200) };
|
|
147
147
|
} finally { kernel.endSubPart?.(); }
|
|
@@ -153,8 +153,9 @@ function exportPoseBedContact(kernel, part, name, view, { p, d }) {
|
|
|
153
153
|
// box) is not how it prints: judging its underside there flagged the wrong faces and
|
|
154
154
|
// missed the real ones. The display mesh is carried into the export pose by a rigid
|
|
155
155
|
// matrix — no second build, and one copy shared by both readings — and the bed is that
|
|
156
|
-
// pose's own lowest Z; each `at` comes back in display
|
|
157
|
-
// finding of this view sits. An unreadable or identical
|
|
156
|
+
// pose's own lowest Z; each `at` (and bed contact's `lowestAt`) comes back in display
|
|
157
|
+
// coordinates, where every other finding of this view sits. An unreadable or identical
|
|
158
|
+
// pose measures as shown.
|
|
158
159
|
// `m` is displayToPrintMatrix's answer, resolved by the caller: the measure memo
|
|
159
160
|
// keys on it, since the pose comes from place() and is not part of the solid's hash.
|
|
160
161
|
function printPoseUnderside(mesh, m, { maxAngle, bed, displayBedZ }) {
|
|
@@ -163,7 +164,15 @@ function printPoseUnderside(mesh, m, { maxAngle, bed, displayBedZ }) {
|
|
|
163
164
|
posed = { positions: Float32Array.from(mesh.positions), indices: mesh.indices };
|
|
164
165
|
transformPositions(posed.positions, m);
|
|
165
166
|
}
|
|
166
|
-
const back = (out) =>
|
|
167
|
+
const back = (out) => {
|
|
168
|
+
if (!m || !out) return out;
|
|
169
|
+
const inv = invertRigid(m);
|
|
170
|
+
return {
|
|
171
|
+
...out,
|
|
172
|
+
...(out.at ? { at: transformPoint(inv, out.at) } : {}),
|
|
173
|
+
...(out.lowestAt ? { lowestAt: transformPoint(inv, out.lowestAt) } : {}),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
167
176
|
return {
|
|
168
177
|
oh: maxAngle != null ? back(posed ? overhang(posed, { maxAngle }) : overhang(mesh, { maxAngle, bedZ: displayBedZ })) : null,
|
|
169
178
|
bc: bed ? back(posed ? bedContact(posed) : bedContact(mesh, { bedZ: displayBedZ })) : null,
|
|
@@ -293,9 +302,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
293
302
|
// Declared wall bands, per sub-part, for exactly these params (Task 5). Resolved
|
|
294
303
|
// once per measure; a bad declaration throws here, which is a measure error.
|
|
295
304
|
const wallBands = opts.minWall ? partWallBands(part, params, reads) : {};
|
|
296
|
-
// Overhang is measured
|
|
297
|
-
// overhangAngleFor
|
|
298
|
-
//
|
|
305
|
+
// Overhang is measured for a part whose print process arms it (dfm-profiles.js
|
|
306
|
+
// overhangAngleFor: a profile carrying `overhang`, or the author's own
|
|
307
|
+
// `overhangArea` expectation) — everything else reads null. verify hands the angle
|
|
308
|
+
// it resolved in (a `process` override changes it); `null` there is an explicit
|
|
299
309
|
// "not checked", not a fallback. The angle used is stamped on the result as
|
|
300
310
|
// `measuredOverhang`, and verify refuses a seed whose stamp disagrees with the
|
|
301
311
|
// angle it needs — the min-wall superset rule's counterpart, since a reading
|
|
@@ -304,8 +314,11 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
304
314
|
// Bed contact is measured for a part whose profile (or orientation, or its own
|
|
305
315
|
// expectation) arms it — dfm-profiles.js bedContactFor. Unlike overhang the reading
|
|
306
316
|
// does not depend on the threshold, so the stamp is a boolean: `measuredBedContact`.
|
|
307
|
-
// verify passes its own answer (a `process` override can arm or disarm it).
|
|
308
|
-
|
|
317
|
+
// verify passes its own answer (a `process` override can arm or disarm it). The same
|
|
318
|
+
// pass reads the footprint cost (dfm-profiles.js footprintFor), so a part whose
|
|
319
|
+
// footprint alone is checked arms it too — here and in verify — and one stamp, and
|
|
320
|
+
// one memo-key bit, covers both readings.
|
|
321
|
+
const bedArmed = (opts.bedContact !== undefined ? opts.bedContact : (partBedContact(part) ?? partFootprint(part))) != null;
|
|
309
322
|
const underside = overhangAngle != null || bedArmed;
|
|
310
323
|
const subBounds = [];
|
|
311
324
|
// Sheet parts: a view holding one sizes each printed sub-part in its print pose
|
|
@@ -341,7 +354,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
341
354
|
const printMatrix = printPose?.matrix ?? null;
|
|
342
355
|
// A pose the probes could not read is not the identity: bed contact then reads the
|
|
343
356
|
// export build itself (exportPoseBedContact). Overhang keeps its display-pose
|
|
344
|
-
// fallback — a
|
|
357
|
+
// fallback — a cost, and unchanged here.
|
|
345
358
|
const exportBed = bedArmed && printPose && !printPose.trusted;
|
|
346
359
|
// Memo key = the inputs this sub-part's facts actually depend on, besides its
|
|
347
360
|
// own geometry hash: whether min-wall ran, at what sample budget and against
|
|
@@ -447,6 +460,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
447
460
|
bedHoverArea: bc ? bc.hoverArea : null,
|
|
448
461
|
bedHoverGap: bc?.gap ?? null,
|
|
449
462
|
bedHoverAt: bc?.at ?? null,
|
|
463
|
+
// The footprint (same pass): the share of the piece's silhouette on the bed that
|
|
464
|
+
// actually touches it, in percent (clamped at 100: the silhouette is a
|
|
465
|
+
// raster, a cell or so off along its edges) — near 0 for a
|
|
466
|
+
// piece standing on a point, a line or a curl, which bedContactPct passes since
|
|
467
|
+
// nothing flat hovers, and near 100 for anything that stands on its whole
|
|
468
|
+
// underside, however little of its bounding box that fills (a tube on its rim, a
|
|
469
|
+
// frame, a diagonal bar). Null when not measured or the silhouette is empty. Located at
|
|
470
|
+
// the piece's lowest point, in display coordinates.
|
|
471
|
+
footprintPct: bc && bc.downArea > 0 ? Math.min(100, (100 * bc.contactArea) / bc.downArea) : null,
|
|
472
|
+
footprintAt: bc?.lowestAt ?? null,
|
|
450
473
|
...(exportBc?.error ? { bedContactError: exportBc.error } : {}),
|
|
451
474
|
// The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
|
|
452
475
|
sheet: sheet
|
|
@@ -537,6 +560,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
537
560
|
measuredOverhang: overhangAngle ?? null,
|
|
538
561
|
// Whether the bed-contact pass ran — the same kind of stamp, read by the same gate.
|
|
539
562
|
measuredBedContact: bedArmed,
|
|
563
|
+
// The footprint is read by the same pass, so the same stamp.
|
|
564
|
+
measuredFootprint: bedArmed,
|
|
540
565
|
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
541
566
|
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
542
567
|
measuredGaps,
|