partforge 0.154.0 → 0.156.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 +28 -10
- package/package.json +1 -1
- package/src/framework/geometry/probe.js +28 -9
- package/src/framework/gltf/decode-preview.js +38 -14
- package/src/framework/gltf/encode-preview.js +263 -17
- package/src/framework/lint/rules-build.js +18 -4
- package/src/framework/lint/rules-verify.js +67 -24
- package/src/framework/mount-preview.js +2 -1
- package/src/framework/oracle/assert-dsl.js +33 -1
- package/src/framework/oracle/verify.js +19 -6
- package/src/framework/verify-metrics.js +64 -12
- package/types/gltf.d.ts +5 -2
- package/types/part.d.ts +18 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -3937,7 +3937,21 @@ shares build's `(k, p, d)` contract — against a geometry-free probe —
|
|
|
3937
3937
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
3938
3938
|
`probe-throws`, `derive-throws`, `manifold-backend-uses-occt-op`,
|
|
3939
3939
|
`build-runaway` (errors); `nondeterministic-build` (warning, from diffing two
|
|
3940
|
-
probe runs).
|
|
3940
|
+
probe runs). The probe has no geometry, so every **query** answers with a
|
|
3941
|
+
placeholder — `boundingBox()` a unit box, `area()` and `volume()` 1,
|
|
3942
|
+
`Shape2D.contains()` always `false`, `corners()`/`toRegions()` a unit square,
|
|
3943
|
+
`isEmpty()` `false`. A `build()` that throws *after* reading one of those may
|
|
3944
|
+
only have believed the placeholder — a helper that scans `contains()` to place
|
|
3945
|
+
ribs finds none and throws its own guard — so that throw is reported as a
|
|
3946
|
+
`build-throws` **note** naming the queries it read ("could not be evaluated
|
|
3947
|
+
geometry-free"), never an error: it does not block loading, `measure`, or
|
|
3948
|
+
`--strict`. A throw with no query read before it is the build's own logic and
|
|
3949
|
+
stays an error. Lint cannot tell which throws depended on the placeholder, so any
|
|
3950
|
+
throw after a query is a note; a real fault still surfaces when the real build
|
|
3951
|
+
runs. The ops the build would have called after the throw were never run, so lint
|
|
3952
|
+
did not check them (unknown ops, option keys, the Manifold pin, determinism). A
|
|
3953
|
+
declared probe that throws stays a `probe-throws` error whatever it read — at
|
|
3954
|
+
runtime a throwing probe only degrades quietly to `{ error }`.
|
|
3941
3955
|
|
|
3942
3956
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
3943
3957
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
@@ -4267,11 +4281,11 @@ are skipped. Switch it off under an FDM profile with an inline
|
|
|
4267
4281
|
`{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
|
|
4268
4282
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4269
4283
|
`volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
|
|
4270
|
-
`overhangArea`, `wall` (a range — see below), `
|
|
4271
|
-
|
|
4272
|
-
`bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4284
|
+
`overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4285
|
+
positions, written in the same `{ min, max }` shape `measure` reports them — where
|
|
4286
|
+
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4273
4287
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
4274
|
-
`volume`, `overlaps`, `centerOfMass`, `
|
|
4288
|
+
`volume`, `overlaps`, `centerOfMass`, `bounds`, plus the pair-wise
|
|
4275
4289
|
`contacts` / `clearance` below.
|
|
4276
4290
|
|
|
4277
4291
|
Passing these checks does **not** prove structural strength, fatigue life, stability,
|
|
@@ -4283,9 +4297,13 @@ unverified.
|
|
|
4283
4297
|
|
|
4284
4298
|
**Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
|
|
4285
4299
|
`"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
|
|
4286
|
-
`bbox`, `centerOfMass
|
|
4287
|
-
`">=[x,y,z]"`
|
|
4288
|
-
|
|
4300
|
+
`bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector `"<=[x,y,z]"` /
|
|
4301
|
+
`">=[x,y,z]"` — exactly 3 components, each a plain number in mm or `*` to skip an axis.
|
|
4302
|
+
A vector is always one-sided: a bare `[40,40,10]` (meaning "exactly this size") is
|
|
4303
|
+
rejected, since a mesh's box shifts with tessellation and no tolerance would be honest
|
|
4304
|
+
for every part — pick `<=` for a limit or `>=` for a floor. `bounds` takes an object
|
|
4305
|
+
with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
|
|
4306
|
+
"<=[220,220,250]" }`. The parser is strict — a malformed assertion fails loudly.
|
|
4289
4307
|
|
|
4290
4308
|
**A part whose typeface is a parameter needs band assertions, not points.** Glyph
|
|
4291
4309
|
advance widths differ by family, so a `text2d` sub-part's `bbox`/`volume` shifts with
|
|
@@ -4297,8 +4315,8 @@ need this, but a part whose default already names a specific face does.
|
|
|
4297
4315
|
|
|
4298
4316
|
```js
|
|
4299
4317
|
verify: { expect: {
|
|
4300
|
-
stand: {
|
|
4301
|
-
_view: {
|
|
4318
|
+
stand: { bounds: { min: ">=[0,0,0]" }, centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
|
|
4319
|
+
_view: { bounds: { max: "<=[220,220,250]" } }, // whole assembly fits the bed
|
|
4302
4320
|
} }
|
|
4303
4321
|
```
|
|
4304
4322
|
|
package/package.json
CHANGED
|
@@ -42,8 +42,11 @@ export class ProbeRunawayError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Shared proxy construction. `onCall(scope, op, args)` observes every op; queries
|
|
45
|
-
// return realistic dummy values the build may read
|
|
46
|
-
|
|
45
|
+
// return realistic dummy values the build may read, and `onQuery(scope, op)` hears
|
|
46
|
+
// each one — every answer in the three query tables below is FABRICATED (a unit
|
|
47
|
+
// box, area 1, contains() always false, …), so a build that read one and then threw
|
|
48
|
+
// may only have believed the placeholder (lint's build-throws reads this).
|
|
49
|
+
function makeProbe(onCall, onQuery = () => {}) {
|
|
47
50
|
const solidQueries = {
|
|
48
51
|
boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
|
|
49
52
|
volume: () => 1,
|
|
@@ -78,6 +81,7 @@ function makeProbe(onCall) {
|
|
|
78
81
|
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
79
82
|
cleanup: () => {},
|
|
80
83
|
};
|
|
84
|
+
const NOT_DATA = new Set(["cleanup"]); // answers nothing a build could act on
|
|
81
85
|
|
|
82
86
|
// `ignore` keeps the proxy from masquerading as a thenable/internal/serializable
|
|
83
87
|
// handle: symbols, `then` (so it's never await-unwrapped), `_`-prefixed internals,
|
|
@@ -100,7 +104,13 @@ function makeProbe(onCall) {
|
|
|
100
104
|
const opProxy = (queries, scope, routeFor) => new Proxy({}, {
|
|
101
105
|
get(_t, key) {
|
|
102
106
|
if (ignore(key)) return undefined;
|
|
103
|
-
if (key in queries)
|
|
107
|
+
if (key in queries) {
|
|
108
|
+
return (...args) => {
|
|
109
|
+
onCall(scope, key, args);
|
|
110
|
+
if (!NOT_DATA.has(key)) onQuery(scope, key);
|
|
111
|
+
return queries[key](...args);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
104
114
|
return (...args) => { onCall(scope, key, args); return routeFor(key); };
|
|
105
115
|
},
|
|
106
116
|
});
|
|
@@ -135,6 +145,8 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
|
135
145
|
const issues = [];
|
|
136
146
|
const used = new Set();
|
|
137
147
|
const solidUsed = new Set(); // same split as createProbeKernel: non-Shape2D-handle ops
|
|
148
|
+
const queried = []; // fabricated data queries read, as "scope.op", first-read order;
|
|
149
|
+
// runValidatingProbe empties it before each build/probe call
|
|
138
150
|
let count = 0;
|
|
139
151
|
let solidProxy = null;
|
|
140
152
|
let shape2dProxy = null;
|
|
@@ -169,17 +181,23 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
|
169
181
|
calls.push({ scope, op, args: args.map(describe) });
|
|
170
182
|
};
|
|
171
183
|
|
|
172
|
-
const
|
|
184
|
+
const onQuery = (scope, op) => {
|
|
185
|
+
const name = `${scope === "shape2d" ? "Shape2D" : scope === "kernel" ? "k" : "Solid"}.${op}()`;
|
|
186
|
+
if (!queried.includes(name)) queried.push(name);
|
|
187
|
+
};
|
|
188
|
+
const { kernel, proxy, shape2d } = makeProbe(onCall, onQuery);
|
|
173
189
|
solidProxy = proxy;
|
|
174
190
|
shape2dProxy = shape2d;
|
|
175
|
-
return { kernel, calls, issues, used, solidUsed };
|
|
191
|
+
return { kernel, calls, issues, used, solidUsed, queried };
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
/**
|
|
179
195
|
* Execute every sub-part's build() — and every declared probe (`part.probes`,
|
|
180
196
|
* same (k, p, d) contract, see oracle/measure.js) — against a validating probe.
|
|
181
197
|
* Never throws: a build error becomes an entry in `throws` (probe entries land
|
|
182
|
-
* in `probeThrows`), a runaway sets `runaway`.
|
|
198
|
+
* in `probeThrows`), a runaway sets `runaway`. Each entry carries `queried`: the
|
|
199
|
+
* fabricated data queries THAT call read before it threw (tracked per call, so one
|
|
200
|
+
* sub-part's reads never excuse another's throw).
|
|
183
201
|
*/
|
|
184
202
|
export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
|
|
185
203
|
const probe = createValidatingProbe({ maxOps });
|
|
@@ -187,22 +205,23 @@ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {})
|
|
|
187
205
|
const probeThrows = [];
|
|
188
206
|
let runaway = false;
|
|
189
207
|
const run = (fn, onThrow) => {
|
|
208
|
+
probe.queried.length = 0;
|
|
190
209
|
try {
|
|
191
210
|
fn(probe.kernel, p, d);
|
|
192
211
|
} catch (e) {
|
|
193
212
|
if (e instanceof ProbeRunawayError) { runaway = true; return false; }
|
|
194
|
-
onThrow(e?.message || String(e));
|
|
213
|
+
onThrow(e?.message || String(e), [...probe.queried]);
|
|
195
214
|
}
|
|
196
215
|
return true;
|
|
197
216
|
};
|
|
198
217
|
for (const [name, sp] of Object.entries(part?.parts ?? {})) {
|
|
199
218
|
if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
|
|
200
|
-
if (!run(sp.build, (m) => throws.push({ subpart: name, message: m }))) break;
|
|
219
|
+
if (!run(sp.build, (m, queried) => throws.push({ subpart: name, message: m, queried }))) break;
|
|
201
220
|
}
|
|
202
221
|
if (!runaway) {
|
|
203
222
|
for (const [name, fn] of Object.entries(part?.probes ?? {})) {
|
|
204
223
|
if (typeof fn !== "function") continue; // invalid-probes already reports this
|
|
205
|
-
if (!run(fn, (m) => probeThrows.push({ probe: name, message: m }))) break;
|
|
224
|
+
if (!run(fn, (m, queried) => probeThrows.push({ probe: name, message: m, queried }))) break;
|
|
206
225
|
}
|
|
207
226
|
}
|
|
208
227
|
return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, probeThrows, runaway };
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
// rotation; parenting the glTF root under the pivot would turn the part twice.
|
|
12
12
|
// Undoing the root here hands the viewer geometry in the frame it already owns.
|
|
13
13
|
//
|
|
14
|
-
// Feature edges (encoder version 2): a sub-part's mesh may carry a second
|
|
15
|
-
// primitive, mode LINES, holding the kernel's edge segments
|
|
14
|
+
// Feature edges (encoder version 2+): a sub-part's mesh may carry a second
|
|
15
|
+
// primitive, mode LINES, holding the kernel's edge segments — with its own
|
|
16
|
+
// vertices in version 2, as indices into the surface's vertex array in version 3
|
|
17
|
+
// (which may also carry, past the surface's own vertices, endpoints that lie on
|
|
18
|
+
// none of them: the payload's positions stop before those). GLTFLoader then
|
|
16
19
|
// makes the node a Group of a Mesh and a LineSegments (the node's extras and
|
|
17
20
|
// name on the Group, not the Mesh), so each surface and its lines are matched
|
|
18
21
|
// through the glTF NODE they belong to. Lines go back to model frame through
|
|
@@ -36,11 +39,12 @@ function toArrayBuffer(bytes) {
|
|
|
36
39
|
throw new TypeError("decodePreviewGlb: the preview must be a Uint8Array or an ArrayBuffer");
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
// Read a (possibly quantized, normalized) vec3 attribute out as Float32,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
// Read a (possibly quantized, normalized) vec3 attribute out as Float32,
|
|
43
|
+
// transformed — its first `count` elements (all of them by default).
|
|
44
|
+
function readVec3(attr, matrix, isNormal, count = attr.count) {
|
|
45
|
+
const out = new Float32Array(count * 3);
|
|
42
46
|
const v = new Vector3();
|
|
43
|
-
for (let i = 0; i <
|
|
47
|
+
for (let i = 0; i < count; i++) {
|
|
44
48
|
v.fromBufferAttribute(attr, i); // denormalizes a KHR_mesh_quantization attribute
|
|
45
49
|
if (isNormal) v.applyMatrix3(matrix).normalize();
|
|
46
50
|
else v.applyMatrix4(matrix);
|
|
@@ -70,19 +74,38 @@ function nodeObjectOf(obj) {
|
|
|
70
74
|
return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
// Line segments as endpoint pairs in model frame
|
|
74
|
-
//
|
|
77
|
+
// Line segments as endpoint pairs in model frame: an indexed LINES primitive
|
|
78
|
+
// expanded back to pairs. Version 3's position attribute is the SURFACE's
|
|
79
|
+
// (GLTFLoader hands both objects the same BufferAttribute), so only the
|
|
80
|
+
// vertices the line indices name are read — never the whole array.
|
|
75
81
|
function readSegments(geometry, matrix) {
|
|
76
82
|
const position = geometry.getAttribute("position");
|
|
77
83
|
if (!position || position.count === 0) return null;
|
|
78
|
-
|
|
79
|
-
if (!geometry.index) return points;
|
|
84
|
+
if (!geometry.index) return readVec3(position, matrix, false);
|
|
80
85
|
const index = geometry.index.array;
|
|
81
86
|
const out = new Float32Array(index.length * 3);
|
|
82
|
-
|
|
87
|
+
const v = new Vector3();
|
|
88
|
+
for (let i = 0; i < index.length; i++) {
|
|
89
|
+
v.fromBufferAttribute(position, index[i]).applyMatrix4(matrix);
|
|
90
|
+
out[i * 3] = v.x;
|
|
91
|
+
out[i * 3 + 1] = v.y;
|
|
92
|
+
out[i * 3 + 2] = v.z;
|
|
93
|
+
}
|
|
83
94
|
return out;
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
// How many leading vertices the surface's triangles use. Version 3 appends edge
|
|
98
|
+
// endpoints that lie on no surface vertex AFTER the surface's own (which weld +
|
|
99
|
+
// reorder leave all referenced, in first-use order); they belong to the lines,
|
|
100
|
+
// so the payload stops before them. For versions 1–2 this is every vertex.
|
|
101
|
+
function surfaceVertexCount(position, index) {
|
|
102
|
+
if (!index) return position.count;
|
|
103
|
+
let max = -1;
|
|
104
|
+
const a = index.array;
|
|
105
|
+
for (let i = 0; i < a.length; i++) if (a[i] > max) max = a[i];
|
|
106
|
+
return max + 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
export async function decodePreviewGlb(bytes) {
|
|
87
110
|
const data = toArrayBuffer(bytes);
|
|
88
111
|
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
|
@@ -112,15 +135,16 @@ export async function decodePreviewGlb(bytes) {
|
|
|
112
135
|
normalMatrix.getNormalMatrix(toModel);
|
|
113
136
|
const normal = geometry.getAttribute("normal");
|
|
114
137
|
const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
|
|
138
|
+
const count = surfaceVertexCount(position, geometry.index);
|
|
115
139
|
const node = nodeObjectOf(obj);
|
|
116
140
|
const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
|
|
117
141
|
const payload = {
|
|
118
142
|
// The writer's own record of the key, then the unsanitized glTF node name.
|
|
119
143
|
name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
|
|
120
|
-
positions: readVec3(position, toModel, false),
|
|
121
|
-
normals: normal ? readVec3(normal, normalMatrix, true) : null,
|
|
144
|
+
positions: readVec3(position, toModel, false, count),
|
|
145
|
+
normals: normal ? readVec3(normal, normalMatrix, true, count) : null,
|
|
122
146
|
indices,
|
|
123
|
-
triangles: (indices ? indices.length :
|
|
147
|
+
triangles: (indices ? indices.length : count) / 3,
|
|
124
148
|
edges: new Float32Array(0), // filled below from the node's LINES primitive, if any
|
|
125
149
|
display: partforgeDisplay && typeof partforgeDisplay === "object" ? partforgeDisplay : null,
|
|
126
150
|
};
|
|
@@ -6,12 +6,27 @@
|
|
|
6
6
|
// - one node + mesh per sub-part, both named by the sub-part key; the mesh's
|
|
7
7
|
// FIRST primitive is the surface (TRIANGLES: POSITION + NORMAL, + indices
|
|
8
8
|
// when the kernel sent them); a sub-part with feature edges adds a SECOND
|
|
9
|
-
// primitive, mode LINES,
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// primitive, mode LINES, no material, whose POSITION is the SAME accessor as
|
|
10
|
+
// the surface's (version 3) and whose indices are the segments' endpoint
|
|
11
|
+
// pairs. Each endpoint is the surface vertex at its position — bit-exact
|
|
12
|
+
// first, else the nearest within one 14-bit quantization step of the mesh —
|
|
13
|
+
// and an endpoint on no vertex is APPENDED to that shared array, where no
|
|
14
|
+
// triangle references it, so the surface's triangles are unchanged and no
|
|
15
|
+
// edge is dropped.
|
|
16
|
+
// A sub-part whose `edges` is empty or absent has no line primitive; a reader
|
|
17
|
+
// takes that as "no feature edges" (never "derive them"). A reader must take
|
|
18
|
+
// the line primitive's INDICES, not its whole vertex array.
|
|
19
|
+
// Version 1 had no line primitives; version 2 gave the lines their own
|
|
20
|
+
// POSITION accessor on the same mesh (both still decode). Rare fallback, also
|
|
21
|
+
// in version 3: a sub-part whose lines would need either
|
|
22
|
+
// (a) an appended endpoint OUTSIDE the surface's bounds — it would widen
|
|
23
|
+
// the mesh's quantization volume and coarsen the surface — or
|
|
24
|
+
// (b) so many appended endpoints that quantize() compacts the surface
|
|
25
|
+
// (its condition: index count < vertex count / 2, the appended
|
|
26
|
+
// vertices counted), dropping them,
|
|
27
|
+
// writes its lines with vertices of their own (unindexed, float, added after
|
|
28
|
+
// quantize() in the node's quantized frame), so the surface quantizes
|
|
29
|
+
// exactly as it would with no edges at all.
|
|
15
30
|
// - every sub-part node under ONE root node, "partforge", rotated −90° about X:
|
|
16
31
|
// partforge models Z-up in millimetres and glTF is Y-up. Units stay mm — the
|
|
17
32
|
// file is for partforge's own viewer; metres would only matter for AR.
|
|
@@ -19,22 +34,29 @@
|
|
|
19
34
|
// and each node's extras carrying the sub-part's own `display` block, so a
|
|
20
35
|
// part-less viewer (mountPreview) can rebuild the exact look, realistic too
|
|
21
36
|
// - top-level extras { partforgeView, partforgeEncoder, partforgeMeta? }
|
|
22
|
-
// - weld() →
|
|
23
|
-
//
|
|
24
|
-
//
|
|
37
|
+
// - weld() → reorder() → quantize() → EXT_meshopt_compression ("medium",
|
|
38
|
+
// i.e. what meshopt({ level: "medium" }) does), 5–8 bytes a triangle, the
|
|
39
|
+
// recipe measured in partforge-cloud's local_glb_benchmark.md. The line
|
|
40
|
+
// primitives are added AFTER quantize(): weld(), reorder() and quantize()
|
|
41
|
+
// each clone a primitive's accessors (compactPrimitive / per-index-set
|
|
42
|
+
// compaction / attribute.clone()), which would split a shared POSITION back
|
|
43
|
+
// into two arrays. Endpoints are mapped after weld+reorder (the final vertex
|
|
44
|
+
// order) and appended before quantize (so they are quantized with the rest).
|
|
45
|
+
// Not encoded: textures, patterns. (Feature edges since version 2, shared
|
|
46
|
+
// vertices since version 3.)
|
|
25
47
|
//
|
|
26
48
|
// Runs in Node, a page and a Web Worker: no three.js, no DOM, no Blob
|
|
27
49
|
// (test/gltf-layering.test.js). Deterministic: the same input writes the same
|
|
28
50
|
// bytes, and nothing here mutates the input (exportPreview passes the viewer's
|
|
29
51
|
// live buffers). Bump PREVIEW_ENCODER_VERSION whenever the bytes for the same
|
|
30
52
|
// input change — downstream caches key on it.
|
|
31
|
-
import { Document, Logger, Primitive, WebIO } from "@gltf-transform/core";
|
|
53
|
+
import { Document, Logger, Node, Primitive, WebIO } from "@gltf-transform/core";
|
|
32
54
|
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
|
33
|
-
import {
|
|
55
|
+
import { quantize, reorder, weld } from "@gltf-transform/functions";
|
|
34
56
|
import { MeshoptEncoder } from "meshoptimizer/encoder";
|
|
35
57
|
import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
|
|
36
58
|
|
|
37
|
-
export const PREVIEW_ENCODER_VERSION =
|
|
59
|
+
export const PREVIEW_ENCODER_VERSION = 3; // 2: feature edges as a LINES primitive; 3: sharing the surface's vertices
|
|
38
60
|
|
|
39
61
|
// −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
|
|
40
62
|
const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
|
|
@@ -66,6 +88,166 @@ function metaExtras(meta) {
|
|
|
66
88
|
return Object.keys(out).length ? out : null;
|
|
67
89
|
}
|
|
68
90
|
|
|
91
|
+
// quantize()'s default POSITION precision; one step of it over the mesh's widest
|
|
92
|
+
// extent is the snapping tolerance — closer than that, quantization itself may
|
|
93
|
+
// put the two points on one grid value.
|
|
94
|
+
const POSITION_BITS = 14;
|
|
95
|
+
// quantize() compacts a primitive whose index count is under half its vertex
|
|
96
|
+
// count — which would drop the appended endpoints no triangle references.
|
|
97
|
+
const compactsAt = (indexCount, vertexCount) => indexCount < vertexCount / 2;
|
|
98
|
+
|
|
99
|
+
const f32 = new Float32Array(1);
|
|
100
|
+
const u32 = new Uint32Array(f32.buffer);
|
|
101
|
+
const bitsOf = (x) => { f32[0] = x === 0 ? 0 : x; return u32[0]; }; // -0 and 0 alike
|
|
102
|
+
|
|
103
|
+
// A bit-exact position → vertex lookup (open addressing; deterministic).
|
|
104
|
+
function exactTable(capacity) {
|
|
105
|
+
let size = 16;
|
|
106
|
+
while (size < capacity * 2) size *= 2;
|
|
107
|
+
const keys = new Uint32Array(size * 3);
|
|
108
|
+
const values = new Int32Array(size).fill(-1);
|
|
109
|
+
const slot = (a, b, c) => (Math.imul(a, 73856093) ^ Math.imul(b, 19349663) ^ Math.imul(c, 83492791)) & (size - 1);
|
|
110
|
+
return {
|
|
111
|
+
get(a, b, c) {
|
|
112
|
+
for (let s = slot(a, b, c); values[s] !== -1; s = (s + 1) & (size - 1)) {
|
|
113
|
+
if (keys[s * 3] === a && keys[s * 3 + 1] === b && keys[s * 3 + 2] === c) return values[s];
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
},
|
|
117
|
+
set(a, b, c, v) {
|
|
118
|
+
let s = slot(a, b, c);
|
|
119
|
+
while (values[s] !== -1) s = (s + 1) & (size - 1);
|
|
120
|
+
keys.set([a, b, c], s * 3);
|
|
121
|
+
values[s] = v;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Map every edge endpoint to a vertex of `positions` (the welded, reordered
|
|
127
|
+
// surface): bit-exact, else the nearest within `tol`, else a NEW vertex,
|
|
128
|
+
// returned in `appended` (deduplicated, so two segments meeting off the surface
|
|
129
|
+
// share one). Returns { pairs: Uint32Array of endpoint indices, appended: number[] }.
|
|
130
|
+
function mapEdges(positions, edges, tol) {
|
|
131
|
+
const vertexCount = positions.length / 3;
|
|
132
|
+
const endpoints = edges.length / 3;
|
|
133
|
+
const exact = exactTable(vertexCount + endpoints);
|
|
134
|
+
for (let v = 0; v < vertexCount; v++) { // the first vertex at a position wins
|
|
135
|
+
const a = bitsOf(positions[v * 3]), b = bitsOf(positions[v * 3 + 1]), c = bitsOf(positions[v * 3 + 2]);
|
|
136
|
+
if (exact.get(a, b, c) === -1) exact.set(a, b, c, v);
|
|
137
|
+
}
|
|
138
|
+
let grid = null; // cell → vertex indices, built on the first miss
|
|
139
|
+
const cellOf = (x) => Math.floor(x / tol);
|
|
140
|
+
const cellKey = (i, j, k) => `${i},${j},${k}`;
|
|
141
|
+
const nearest = (x, y, z) => {
|
|
142
|
+
if (!(tol > 0)) return -1;
|
|
143
|
+
if (!grid) {
|
|
144
|
+
grid = new Map();
|
|
145
|
+
for (let v = 0; v < vertexCount; v++) {
|
|
146
|
+
const key = cellKey(cellOf(positions[v * 3]), cellOf(positions[v * 3 + 1]), cellOf(positions[v * 3 + 2]));
|
|
147
|
+
const list = grid.get(key);
|
|
148
|
+
if (list) list.push(v); else grid.set(key, [v]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const ci = cellOf(x), cj = cellOf(y), ck = cellOf(z);
|
|
152
|
+
let best = -1;
|
|
153
|
+
let bestD = tol * tol;
|
|
154
|
+
for (let di = -1; di <= 1; di++) for (let dj = -1; dj <= 1; dj++) for (let dk = -1; dk <= 1; dk++) {
|
|
155
|
+
for (const v of grid.get(cellKey(ci + di, cj + dj, ck + dk)) ?? []) {
|
|
156
|
+
const dx = positions[v * 3] - x, dy = positions[v * 3 + 1] - y, dz = positions[v * 3 + 2] - z;
|
|
157
|
+
const d = dx * dx + dy * dy + dz * dz;
|
|
158
|
+
if (d < bestD || (d === bestD && best !== -1 && v < best)) { bestD = d; best = v; }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return best;
|
|
162
|
+
};
|
|
163
|
+
const pairs = new Uint32Array(endpoints);
|
|
164
|
+
const appended = [];
|
|
165
|
+
for (let e = 0; e < endpoints; e++) {
|
|
166
|
+
const x = edges[e * 3], y = edges[e * 3 + 1], z = edges[e * 3 + 2];
|
|
167
|
+
const a = bitsOf(x), b = bitsOf(y), c = bitsOf(z);
|
|
168
|
+
let v = exact.get(a, b, c);
|
|
169
|
+
if (v === -1) v = nearest(x, y, z);
|
|
170
|
+
if (v === -1) {
|
|
171
|
+
v = vertexCount + appended.length / 3;
|
|
172
|
+
appended.push(x, y, z);
|
|
173
|
+
exact.set(a, b, c, v);
|
|
174
|
+
}
|
|
175
|
+
pairs[e] = v;
|
|
176
|
+
}
|
|
177
|
+
return { pairs: sortSegments(pairs), appended };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Segments as (low, high) pairs in ascending order: the same set of lines, but an
|
|
181
|
+
// index stream meshopt's delta coding compresses far better than the kernel's order.
|
|
182
|
+
export function sortSegments(pairs) {
|
|
183
|
+
const n = pairs.length / 2;
|
|
184
|
+
const keys = new BigUint64Array(n); // (low << 32) | high, exact for every Uint32 index
|
|
185
|
+
for (let s = 0; s < n; s++) {
|
|
186
|
+
const a = pairs[s * 2], b = pairs[s * 2 + 1];
|
|
187
|
+
keys[s] = (BigInt(Math.min(a, b)) << 32n) | BigInt(Math.max(a, b));
|
|
188
|
+
}
|
|
189
|
+
keys.sort();
|
|
190
|
+
const out = new Uint32Array(pairs.length);
|
|
191
|
+
for (let s = 0; s < n; s++) {
|
|
192
|
+
out[s * 2] = Number(keys[s] >> 32n);
|
|
193
|
+
out[s * 2 + 1] = Number(keys[s] & 0xffffffffn);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function boundsOf(positions) {
|
|
199
|
+
const min = [Infinity, Infinity, Infinity];
|
|
200
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
201
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
202
|
+
for (let a = 0; a < 3; a++) {
|
|
203
|
+
if (positions[i + a] < min[a]) min[a] = positions[i + a];
|
|
204
|
+
if (positions[i + a] > max[a]) max[a] = positions[i + a];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { min, max };
|
|
208
|
+
}
|
|
209
|
+
// Column-major 4×4 helpers (glTF's layout) for the fallback's frame change. A
|
|
210
|
+
// node matrix is affine, so the inverse is the 3×3 block's inverse plus the
|
|
211
|
+
// translation carried through it.
|
|
212
|
+
function multiply(a, b) {
|
|
213
|
+
const out = new Array(16);
|
|
214
|
+
for (let c = 0; c < 4; c++) {
|
|
215
|
+
for (let r = 0; r < 4; r++) {
|
|
216
|
+
let v = 0;
|
|
217
|
+
for (let k = 0; k < 4; k++) v += a[k * 4 + r] * b[c * 4 + k];
|
|
218
|
+
out[c * 4 + r] = v;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
function invertAffine(m) {
|
|
224
|
+
const [a, b, c, d, e, f, g, h, i] = [m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]]; // rows
|
|
225
|
+
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
|
|
226
|
+
const det = a * A + b * B + c * C;
|
|
227
|
+
if (!det) throw new Error("encodePreviewGlb: a mesh node's transform is not invertible");
|
|
228
|
+
const inv = [ // rows of the 3×3 inverse
|
|
229
|
+
A / det, -(b * i - c * h) / det, (b * f - c * e) / det,
|
|
230
|
+
B / det, (a * i - c * g) / det, -(a * f - c * d) / det,
|
|
231
|
+
C / det, -(a * h - b * g) / det, (a * e - b * d) / det,
|
|
232
|
+
];
|
|
233
|
+
const t = [m[12], m[13], m[14]];
|
|
234
|
+
const out = new Array(16).fill(0);
|
|
235
|
+
for (let r = 0; r < 3; r++) {
|
|
236
|
+
for (let col = 0; col < 3; col++) out[col * 4 + r] = inv[r * 3 + col];
|
|
237
|
+
out[12 + r] = -(inv[r * 3] * t[0] + inv[r * 3 + 1] * t[1] + inv[r * 3 + 2] * t[2]);
|
|
238
|
+
}
|
|
239
|
+
out[15] = 1;
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
const meshNode = (mesh) => mesh.listParents().find((p) => p instanceof Node);
|
|
243
|
+
|
|
244
|
+
const outside = (points, { min, max }) => {
|
|
245
|
+
for (let i = 0; i < points.length; i += 3) {
|
|
246
|
+
for (let a = 0; a < 3; a++) if (points[i + a] < min[a] || points[i + a] > max[a]) return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
};
|
|
250
|
+
|
|
69
251
|
function checkMesh(m, seen) {
|
|
70
252
|
if (!m || typeof m.name !== "string" || !m.name) throw new TypeError("encodePreviewGlb: every mesh needs a non-empty string name");
|
|
71
253
|
if (seen.has(m.name)) throw new Error(`encodePreviewGlb: two meshes are named "${m.name}"`);
|
|
@@ -121,6 +303,7 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
121
303
|
...(meta ? { partforgeMeta: meta } : {}),
|
|
122
304
|
});
|
|
123
305
|
|
|
306
|
+
const pending = []; // { mesh, surface, edges } — line primitives to add after quantize()
|
|
124
307
|
for (const m of meshes) {
|
|
125
308
|
if (m.positions.length === 0) continue; // glTF forbids a zero-count accessor
|
|
126
309
|
// Copies throughout: setArray keeps the array it is handed, and the
|
|
@@ -141,10 +324,7 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
141
324
|
.setRoughnessFactor(look.roughness)
|
|
142
325
|
.setAlphaMode(look.opacity < 1 ? "BLEND" : "OPAQUE"));
|
|
143
326
|
const mesh = doc.createMesh(m.name).addPrimitive(prim);
|
|
144
|
-
if (m.edges?.length) {
|
|
145
|
-
mesh.addPrimitive(doc.createPrimitive().setMode(Primitive.Mode.LINES).setAttribute(
|
|
146
|
-
"POSITION", doc.createAccessor().setType("VEC3").setArray(Float32Array.from(m.edges)).setBuffer(buffer)));
|
|
147
|
-
}
|
|
327
|
+
if (m.edges?.length) pending.push({ mesh, surface: prim, edges: Float32Array.from(m.edges) });
|
|
148
328
|
const extras = displayExtras(display);
|
|
149
329
|
root.addChild(doc.createNode(m.name)
|
|
150
330
|
.setMesh(mesh)
|
|
@@ -152,7 +332,73 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
152
332
|
}
|
|
153
333
|
if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
|
|
154
334
|
|
|
155
|
-
|
|
335
|
+
// The surfaces alone through weld + reorder: their vertex arrays are final after this.
|
|
336
|
+
await doc.transform(weld(), reorder({ encoder: MeshoptEncoder, target: "size" }));
|
|
337
|
+
|
|
338
|
+
// Map each endpoint onto those vertices; append the ones on none. Then quantize
|
|
339
|
+
// with the appended vertices in place, and only then add the line primitives.
|
|
340
|
+
const shared = [];
|
|
341
|
+
const ownVertices = []; // { mesh, edges, before } — the fallback, added after quantize()
|
|
342
|
+
for (const { mesh, surface, edges } of pending) {
|
|
343
|
+
const position = surface.getAttribute("POSITION");
|
|
344
|
+
const positions = position.getArray();
|
|
345
|
+
const bounds = boundsOf(positions);
|
|
346
|
+
const tol = Math.max(...bounds.max.map((hi, a) => hi - bounds.min[a])) / 2 ** POSITION_BITS;
|
|
347
|
+
const { pairs, appended } = mapEdges(positions, edges, tol);
|
|
348
|
+
const vertexCount = positions.length / 3 + appended.length / 3;
|
|
349
|
+
if (outside(appended, bounds) || compactsAt(surface.getIndices().getCount(), vertexCount)) {
|
|
350
|
+
// Appending would change the surface's quantization volume, or quantize()
|
|
351
|
+
// would compact the surface and drop the appended endpoints: these lines
|
|
352
|
+
// keep vertices of their own instead (see the file header).
|
|
353
|
+
ownVertices.push({ mesh, edges, before: meshNode(mesh).getWorldMatrix() });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (appended.length) {
|
|
357
|
+
const grown = new Float32Array(vertexCount * 3);
|
|
358
|
+
grown.set(positions);
|
|
359
|
+
grown.set(appended, positions.length);
|
|
360
|
+
position.setArray(grown);
|
|
361
|
+
const normal = surface.getAttribute("NORMAL");
|
|
362
|
+
if (normal) { // unit +Z for vertices no triangle draws: a valid NORMAL, never read
|
|
363
|
+
const normals = new Float32Array(vertexCount * 3);
|
|
364
|
+
normals.set(normal.getArray());
|
|
365
|
+
for (let i = positions.length + 2; i < normals.length; i += 3) normals[i] = 1;
|
|
366
|
+
normal.setArray(normals);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
shared.push({ mesh, surface, pairs, vertexCount });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await doc.transform(quantize());
|
|
373
|
+
|
|
374
|
+
// The fallback lines: float positions mapped into the frame quantize() left the
|
|
375
|
+
// mesh in. quantize() folds the mesh's offset and scale into the node holding it
|
|
376
|
+
// (or a new child node), so a vertex q now draws at after·q where it drew p at
|
|
377
|
+
// before·p: q = after⁻¹ · before · p, whatever transform the node carried.
|
|
378
|
+
for (const { mesh, edges, before } of ownVertices) {
|
|
379
|
+
const m = multiply(invertAffine(meshNode(mesh).getWorldMatrix()), before);
|
|
380
|
+
const local = new Float32Array(edges.length);
|
|
381
|
+
for (let i = 0; i < edges.length; i += 3) {
|
|
382
|
+
const [x, y, z] = [edges[i], edges[i + 1], edges[i + 2]];
|
|
383
|
+
for (let a = 0; a < 3; a++) local[i + a] = m[a] * x + m[4 + a] * y + m[8 + a] * z + m[12 + a];
|
|
384
|
+
}
|
|
385
|
+
mesh.addPrimitive(doc.createPrimitive().setMode(Primitive.Mode.LINES).setAttribute(
|
|
386
|
+
"POSITION", doc.createAccessor().setType("VEC3").setArray(local).setBuffer(buffer)));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const { mesh, surface, pairs, vertexCount } of shared) {
|
|
390
|
+
// quantize() replaced the surface's accessors; the vertex order and count did not change.
|
|
391
|
+
const position = surface.getAttribute("POSITION");
|
|
392
|
+
if (position.getCount() !== vertexCount) throw new Error("encodePreviewGlb: quantize() changed a surface's vertex count");
|
|
393
|
+
mesh.addPrimitive(doc.createPrimitive().setMode(Primitive.Mode.LINES)
|
|
394
|
+
.setAttribute("POSITION", position)
|
|
395
|
+
.setIndices(doc.createAccessor().setType("SCALAR")
|
|
396
|
+
.setArray(vertexCount < 65535 ? new Uint16Array(pairs) : pairs).setBuffer(buffer)));
|
|
397
|
+
}
|
|
398
|
+
// meshopt({ level: "medium" })'s last step: compress every buffer view, attributes as quantized.
|
|
399
|
+
doc.createExtension(EXTMeshoptCompression).setRequired(true)
|
|
400
|
+
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
|
|
401
|
+
|
|
156
402
|
const io = new WebIO()
|
|
157
403
|
.registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
|
|
158
404
|
.registerDependencies({ "meshopt.encoder": MeshoptEncoder });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Group 3 — how the build uses the kernel, discovered by executing build() against
|
|
2
2
|
// the validating probe with no geometry kernel. Every error in this group already
|
|
3
3
|
// throws at runtime; the value is reaching it in microseconds, before a WASM boot.
|
|
4
|
-
import { err, warn } from "./finding.js";
|
|
4
|
+
import { err, note, warn } from "./finding.js";
|
|
5
5
|
import { ROUTED_CAD_OPS } from "../geometry/kernel.js";
|
|
6
6
|
import { MAX_PROBE_OPS } from "../geometry/probe.js";
|
|
7
7
|
|
|
@@ -10,6 +10,19 @@ import { MAX_PROBE_OPS } from "../geometry/probe.js";
|
|
|
10
10
|
const OCCT_ONLY = new Set(ROUTED_CAD_OPS);
|
|
11
11
|
const unique = (xs) => [...new Set(xs)];
|
|
12
12
|
|
|
13
|
+
// A throw after the call read a query the probe answers with a placeholder
|
|
14
|
+
// (contains() always false, boundingBox() a unit box, area()/volume() 1, …) proves
|
|
15
|
+
// nothing: the build may only have believed the placeholder — a part that scans
|
|
16
|
+
// contains() to place ribs finds none and throws its own guard, then builds fine on
|
|
17
|
+
// a real kernel. That is a NOTE (seen, never gating load, measure or --strict), the
|
|
18
|
+
// way rules that cannot evaluate something stand down rather than guess. A throw
|
|
19
|
+
// with no such read is the build's own logic and stays an error.
|
|
20
|
+
const STUB_HINT = (queried) =>
|
|
21
|
+
`Nothing to fix if the part builds on a real kernel. Before throwing, this call read ${queried.join(", ")}, and with no kernel attached those answers are placeholders (Shape2D.contains() is always false, boundingBox() a unit box, area() and volume() 1), so a guard that depends on them can fire here and nowhere else. A genuine fault still surfaces when the real build runs.`;
|
|
22
|
+
const stubbedThrow = (rule, what, message, queried, path) =>
|
|
23
|
+
note(rule, `${what} threw after reading ${queried.join(", ")}, so it could not be evaluated geometry-free: ${message}`,
|
|
24
|
+
STUB_HINT(queried), path);
|
|
25
|
+
|
|
13
26
|
export const BUILD_RULES = [
|
|
14
27
|
{
|
|
15
28
|
id: "derive-throws",
|
|
@@ -45,10 +58,11 @@ export const BUILD_RULES = [
|
|
|
45
58
|
},
|
|
46
59
|
{
|
|
47
60
|
id: "build-throws",
|
|
48
|
-
run: ({ probe }) => probe().throws.map(({ subpart, message }) =>
|
|
49
|
-
|
|
61
|
+
run: ({ probe }) => probe().throws.map(({ subpart, message, queried }) => (queried.length
|
|
62
|
+
? stubbedThrow("build-throws", `sub-part "${subpart}"`, message, queried, `parts.${subpart}.build`)
|
|
63
|
+
: err("build-throws", `sub-part "${subpart}" threw during a geometry-free build: ${message}`,
|
|
50
64
|
"Fix the error in build(). This was raised with no kernel attached, so it is a fault in the build's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure.",
|
|
51
|
-
`parts.${subpart}.build`)),
|
|
65
|
+
`parts.${subpart}.build`))),
|
|
52
66
|
},
|
|
53
67
|
{
|
|
54
68
|
// Same class of fault as build-throws, located at the probe. A throwing
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// which is also the documented reason CLI stdout isn't pure JSON in that case.
|
|
4
4
|
// Catching them statically removes both the wasted boot and the stdout caveat.
|
|
5
5
|
import { err } from "./finding.js";
|
|
6
|
-
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
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
|
-
import { parseAssertion } from "../oracle/assert-dsl.js";
|
|
8
|
+
import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
|
|
9
9
|
import { suggest } from "../geometry/op-options.js";
|
|
10
10
|
import { PROCESS_IDS } from "../process/registry.js";
|
|
11
11
|
import { SHEET_DOC_ID } from "../sheet/constants.js";
|
|
@@ -187,6 +187,46 @@ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
|
|
|
187
187
|
path)];
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// One expectation's well-formedness against its registry entry: it parses, and it has the form the
|
|
191
|
+
// metric reads — a range for `wall`, a componentwise vector for bbox/centerOfMass/the
|
|
192
|
+
// sides of bounds. A bare vector gets the two legal spellings back, in its own numbers.
|
|
193
|
+
const DSL_HINT = "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).";
|
|
194
|
+
const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
195
|
+
|
|
196
|
+
function checkExpr(target, metric, spec, reg) {
|
|
197
|
+
const path = `verify.expect.${target}.${metric}`;
|
|
198
|
+
const expr = exprOf(spec);
|
|
199
|
+
let parsed;
|
|
200
|
+
try { parsed = parseAssertion(expr); }
|
|
201
|
+
catch (e) {
|
|
202
|
+
const bare = bareVectorHint(expr);
|
|
203
|
+
return [err("verify-bad-expr",
|
|
204
|
+
`the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
|
|
205
|
+
bare ? `${capitalize(bare)}. A vector expectation is one-sided: "<=" for a limit, ">=" for a floor.` : DSL_HINT,
|
|
206
|
+
path)];
|
|
207
|
+
}
|
|
208
|
+
const form = reg?.form;
|
|
209
|
+
if (form === "range" && parsed.op !== "range") {
|
|
210
|
+
return [err("verify-bad-expr",
|
|
211
|
+
`the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
|
|
212
|
+
`Write ${metric} as \`"<min>..<max>"\` in mm.`,
|
|
213
|
+
path)];
|
|
214
|
+
}
|
|
215
|
+
if (form === "range" && parsed.min > parsed.max) {
|
|
216
|
+
return [err("verify-bad-expr",
|
|
217
|
+
`the expectation for ${target}.${metric} must be a range with min <= max, got "${expr}"`,
|
|
218
|
+
`Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
|
|
219
|
+
path)];
|
|
220
|
+
}
|
|
221
|
+
if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge") {
|
|
222
|
+
return [err("verify-bad-expr",
|
|
223
|
+
`the expectation for ${target}.${metric} must be a componentwise vector, got ${describe(expr)} — ${metric} is an [x,y,z] reading`,
|
|
224
|
+
`Write ${metric} as \`"<=[x,y,z]"\` or \`">=[x,y,z]"\` (componentwise; * skips an axis).`,
|
|
225
|
+
path)];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
190
230
|
export const VERIFY_RULES = [
|
|
191
231
|
{
|
|
192
232
|
id: "verify-expect-throws",
|
|
@@ -229,9 +269,12 @@ export const VERIFY_RULES = [
|
|
|
229
269
|
// separately by verify-bad-pair-check, and excluded here.
|
|
230
270
|
const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
|
|
231
271
|
const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
|
|
232
|
-
|
|
272
|
+
// Legacy spellings (boundsMin/boundsMax) still pass this rule, since verify
|
|
273
|
+
// still evaluates them, but are never listed or suggested: the vocabulary a
|
|
274
|
+
// reader is shown is the one the docs teach.
|
|
275
|
+
const valid = teachableMetrics(registry);
|
|
233
276
|
for (const metric of Object.keys(metrics)) {
|
|
234
|
-
if (
|
|
277
|
+
if (Object.hasOwn(registry, metric)) continue;
|
|
235
278
|
const hint = suggest(metric, valid);
|
|
236
279
|
out.push(err("verify-unknown-metric",
|
|
237
280
|
`"${metric}" is not a ${target === "_view" ? "view" : "sub-part"} metric`,
|
|
@@ -256,28 +299,28 @@ export const VERIFY_RULES = [
|
|
|
256
299
|
// though, so they get their own pass (with a pair-shaped path) below.
|
|
257
300
|
const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
|
|
258
301
|
const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
|
|
259
|
-
for (const [metric,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
302
|
+
for (const [metric, specRaw] of Object.entries(metrics)) {
|
|
303
|
+
// A compound metric (`bounds: { min, max }`) is checked side by side, each
|
|
304
|
+
// side an ordinary expectation against its own registry entry.
|
|
305
|
+
// An unknown metric is verify-unknown-metric's finding; parsing its value
|
|
306
|
+
// too would only add a second, vaguer error for the same key.
|
|
307
|
+
if (!Object.hasOwn(registry, metric)) continue;
|
|
308
|
+
const reg = registry[metric];
|
|
309
|
+
if (reg.sides) {
|
|
310
|
+
const bad = sidesSpecError(metric, reg, specRaw);
|
|
311
|
+
if (bad) {
|
|
312
|
+
out.push(err("verify-bad-expr",
|
|
313
|
+
`the expectation for ${target}.${metric} is malformed: ${bad}`,
|
|
314
|
+
`Write \`${BOUNDS_FORM}\` — the same { min, max } shape measure reports. Either side may be left out, and \`*\` skips an axis.`,
|
|
315
|
+
`verify.expect.${target}.${metric}`));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
for (const [side, sideSpec] of Object.entries(specRaw)) {
|
|
319
|
+
out.push(...checkExpr(target, `${metric}.${side}`, sideSpec, reg.sides[side]));
|
|
320
|
+
}
|
|
267
321
|
continue;
|
|
268
322
|
}
|
|
269
|
-
|
|
270
|
-
if (form === "range" && parsed.op !== "range") {
|
|
271
|
-
out.push(err("verify-bad-expr",
|
|
272
|
-
`the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
|
|
273
|
-
`Write ${metric} as \`"<min>..<max>"\` in mm.`,
|
|
274
|
-
`verify.expect.${target}.${metric}`));
|
|
275
|
-
} else if (form === "range" && parsed.op === "range" && parsed.min > parsed.max) {
|
|
276
|
-
out.push(err("verify-bad-expr",
|
|
277
|
-
`the expectation for ${target}.${metric} must be a range with min <= max, got "${exprOf(spec)}"`,
|
|
278
|
-
`Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
|
|
279
|
-
`verify.expect.${target}.${metric}`));
|
|
280
|
-
}
|
|
323
|
+
out.push(...checkExpr(target, metric, specRaw, reg));
|
|
281
324
|
}
|
|
282
325
|
}
|
|
283
326
|
out.push(...checkClearanceExprs(expect?._view?.clearance, "verify.expect._view.clearance"));
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// background/environment meta. The CAD look is exact; realistic mode uses each
|
|
11
11
|
// sub-part's display only — no sheet stock, no layer-line print frames (a
|
|
12
12
|
// residual of the format). CAD feature edges come from the file (encoder
|
|
13
|
-
// version 2); a version-1 file
|
|
13
|
+
// version 2, sharing the surface's vertices since version 3); a version-1 file
|
|
14
|
+
// decodes with none. The decoder loads on demand (it carries the
|
|
14
15
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
15
16
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
16
17
|
import "./app.css";
|
|
@@ -19,15 +19,47 @@ const reScalar = new RegExp(`^(>=|<=|>|<)?\\s*(${NUM})\\s*${U}$`);
|
|
|
19
19
|
const reRange = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})\\s*${U}$`);
|
|
20
20
|
const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
|
|
21
21
|
|
|
22
|
+
// A vector with no comparison — `[40,40,10]` or `"[40,40,10]"` (or `"=[…]"`) — is the
|
|
23
|
+
// commonest slip: the writer means "exactly this size". A vector is only ever
|
|
24
|
+
// one-sided, so the error hands back both legal spellings built from the writer's
|
|
25
|
+
// own numbers, which is a one-step fix. Returns that hint, or null when `expr` is
|
|
26
|
+
// not a bare vector. An exact vector is deliberately not offered: a mesh's box is
|
|
27
|
+
// tessellation-dependent (a curved face chords inside its true extent), so no
|
|
28
|
+
// tolerance would be honest for every part.
|
|
29
|
+
const reBareVec = /^(?:==?)?\s*\[(.*)\]$/;
|
|
30
|
+
const VEC_TAIL = "(componentwise; * skips an axis)";
|
|
31
|
+
|
|
32
|
+
export function bareVectorHint(expr) {
|
|
33
|
+
let comps;
|
|
34
|
+
if (Array.isArray(expr)) comps = expr.map((c) => String(c).trim());
|
|
35
|
+
else if (typeof expr === "string") {
|
|
36
|
+
const m = expr.trim().match(reBareVec);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
comps = m[1].split(",").map((t) => t.trim());
|
|
39
|
+
} else return null;
|
|
40
|
+
comps = comps.map((c) => c.replace(/\s*mm$/, ""));
|
|
41
|
+
const ok = comps.length === 3 && comps.every((c) => c === "*" || (c !== "" && Number.isFinite(Number(c))));
|
|
42
|
+
if (!ok) return `use "<=[x,y,z]" or ">=[x,y,z]" (exactly 3 plain numbers in mm; componentwise; * skips an axis)`;
|
|
43
|
+
const v = `[${comps.join(",")}]`;
|
|
44
|
+
return `use "<=${v}" or ">=${v}" ${VEC_TAIL}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const show = (expr) => (typeof expr === "string" ? `"${expr}"` : JSON.stringify(expr));
|
|
48
|
+
|
|
22
49
|
export function parseAssertion(expr) {
|
|
23
50
|
if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
|
|
51
|
+
const bare = bareVectorHint(expr);
|
|
52
|
+
if (bare) throw new Error(`assertion: a vector needs a comparison, got ${show(expr)} — ${bare}`);
|
|
24
53
|
if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
|
|
25
54
|
const s = expr.trim();
|
|
26
55
|
|
|
27
56
|
const vec = s.match(reVec);
|
|
28
57
|
if (vec) {
|
|
29
58
|
const parts = vec[2].split(",").map((t) => t.trim());
|
|
30
|
-
|
|
59
|
+
const plain = (t) => t === "*" || (t !== "" && Number.isFinite(Number(t)));
|
|
60
|
+
if (parts.length !== 3 || !parts.every(plain)) {
|
|
61
|
+
throw new Error(`assertion: a vector takes exactly 3 components, each a plain number in mm or *: "${expr}"`);
|
|
62
|
+
}
|
|
31
63
|
return { op: vec[1] === "<=" ? "vle" : "vge", vec: parts.map((t) => (t === "*" ? null : toBase(t, undefined))) };
|
|
32
64
|
}
|
|
33
65
|
const range = s.match(reRange);
|
|
@@ -4,7 +4,7 @@ import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
|
4
4
|
import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
|
|
5
5
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
6
|
import { relevanceHash } from "../param-deps.js";
|
|
7
|
-
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
7
|
+
import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError } from "../verify-metrics.js";
|
|
8
8
|
import { processById } from "../process/registry.js";
|
|
9
9
|
import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
|
|
10
10
|
// Re-exported for the callers that read them from here; their one home is sheet/constants.js.
|
|
@@ -173,6 +173,21 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
173
173
|
return out;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// Every check one declared metric yields: one, or one per side of a compound metric
|
|
177
|
+
// (`bounds: { min, max }` → `bounds.min`, `bounds.max`), each judged by `check()` against
|
|
178
|
+
// its side's own registry entry. A malformed compound value throws, as a malformed
|
|
179
|
+
// expression does.
|
|
180
|
+
function checksFor(scope, subpart, metric, spec, registry, factsObj) {
|
|
181
|
+
const reg = registry[metric];
|
|
182
|
+
if (!reg?.sides) return [check(scope, subpart, metric, spec, registry, factsObj)];
|
|
183
|
+
const bad = sidesSpecError(metric, reg, spec);
|
|
184
|
+
if (bad) throw new Error(`${scope === "view" ? "_view" : subpart}: ${bad}`);
|
|
185
|
+
return Object.entries(spec).map(([side, sideSpec]) => {
|
|
186
|
+
const name = `${metric}.${side}`;
|
|
187
|
+
return check(scope, subpart, name, sideSpec, { [name]: reg.sides[side] }, factsObj);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
// ── sheet parts ──────────────────────────────────────────────────────────────
|
|
177
192
|
// A view holding a sheet part (sheetPart(), partforge/geometry — measure() stamps
|
|
178
193
|
// its row with `sheet`) is judged per part: the process profile's bed fits each
|
|
@@ -220,8 +235,7 @@ function sheetRowChecks(s, declaredExp) {
|
|
|
220
235
|
const budgeted = (metric) => SUBPART_METRICS[metric]?.budgeted === true;
|
|
221
236
|
const unread = (c, metric) => (c.actual == null ? SUBPART_METRICS[metric]?.readError?.(s) ?? null : null);
|
|
222
237
|
const readFailure = (c, why) => ({ ...c, message: `not measured — ${why}`, hint: SHEET_READ_ERROR_HINT, pattern: SHEET_DOC_ID });
|
|
223
|
-
for (const [metric, expr] of Object.entries(declaredExp)) {
|
|
224
|
-
const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
|
|
238
|
+
for (const [metric, expr] of Object.entries(declaredExp)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
|
|
225
239
|
if (PRINT_ONLY.has(metric)) {
|
|
226
240
|
out.push({ scope: c.scope, subpart: c.subpart, metric, kind: c.kind, expr: c.expr, actual: null, status: "skip", pass: null,
|
|
227
241
|
message: "not measured on a sheet part", hint: PRINT_ONLY_HINT, pattern: SHEET_DOC_ID });
|
|
@@ -269,7 +283,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
|
|
|
269
283
|
...(profile?.bed && !anySheet ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
|
|
270
284
|
...viewScalarExp,
|
|
271
285
|
};
|
|
272
|
-
for (const [metric, expr] of Object.entries(viewExp)) checks.push(
|
|
286
|
+
for (const [metric, expr] of Object.entries(viewExp)) checks.push(...checksFor("view", null, metric, expr, VIEW_METRICS, facts));
|
|
273
287
|
checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
|
|
274
288
|
|
|
275
289
|
// Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
|
|
@@ -297,8 +311,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
|
|
|
297
311
|
...(overhang != null ? { overhangArea: "<=1" } : {}),
|
|
298
312
|
...(expect?.[s.name] ?? {}),
|
|
299
313
|
};
|
|
300
|
-
for (const [metric, expr] of Object.entries(merged)) {
|
|
301
|
-
const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
|
|
314
|
+
for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
|
|
302
315
|
if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
|
|
303
316
|
checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
|
|
304
317
|
hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
|
|
@@ -15,6 +15,54 @@
|
|
|
15
15
|
// sheet/constants.js, lint/finding.js) — so the linter's stays kernel-free.
|
|
16
16
|
import { SHEET_METRICS } from "./process/registry.js";
|
|
17
17
|
|
|
18
|
+
// `bounds` is written the way measure() reports it — `bounds: { min: [x,y,z], max:
|
|
19
|
+
// [x,y,z] }` — so a reader who copies the shape of a measurement gets a legal
|
|
20
|
+
// expectation: `bounds: { min: ">=[0,0,0]", max: "<=[220,220,250]" }`. It is the one
|
|
21
|
+
// COMPOUND metric: its value is an object of `sides`, each an ordinary vector
|
|
22
|
+
// expectation checked on its own and reported as `bounds.min` / `bounds.max`. Either
|
|
23
|
+
// side may be left out; nothing else may sit under it (boundsSpecError).
|
|
24
|
+
export const BOUNDS_FORM = 'bounds: { min: ">=[x,y,z]", max: "<=[x,y,z]" }';
|
|
25
|
+
const BOUNDS_HINT = `write ${BOUNDS_FORM} — either side may be left out, and * skips an axis`;
|
|
26
|
+
|
|
27
|
+
function boundsMetric(bounds, minHint, maxHint) {
|
|
28
|
+
return { kind: "gate", hint: BOUNDS_HINT, sides: {
|
|
29
|
+
min: { kind: "gate", form: "vector", extract: (f) => bounds(f)?.min, hint: minHint },
|
|
30
|
+
max: { kind: "gate", form: "vector", extract: (f) => bounds(f)?.max, hint: maxHint },
|
|
31
|
+
} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The flat spellings `bounds` replaced. Still evaluated, so a part written against
|
|
35
|
+
// them keeps verifying, but `legacy` keeps them out of everything that TEACHES the
|
|
36
|
+
// vocabulary: lint's list of valid metrics and its did-you-mean (teachableMetrics).
|
|
37
|
+
function legacyBounds(bounds, minHint, maxHint) {
|
|
38
|
+
return {
|
|
39
|
+
boundsMin: { kind: "gate", form: "vector", legacy: "bounds", extract: (f) => bounds(f)?.min, hint: minHint },
|
|
40
|
+
boundsMax: { kind: "gate", form: "vector", legacy: "bounds", extract: (f) => bounds(f)?.max, hint: maxHint },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The metric names a reader should be shown: every registry key but the legacy ones.
|
|
45
|
+
export const teachableMetrics = (registry) => Object.keys(registry).filter((k) => !registry[k].legacy);
|
|
46
|
+
|
|
47
|
+
// Why a compound metric's value is malformed, or null when it is well formed. verify
|
|
48
|
+
// throws with it and lint reports it, so both say the same thing.
|
|
49
|
+
export function sidesSpecError(metric, reg, spec) {
|
|
50
|
+
const sides = Object.keys(reg.sides);
|
|
51
|
+
const form = metric === "bounds" ? BOUNDS_FORM : `${metric}: { ${sides.join(", ")} }`;
|
|
52
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
53
|
+
let shown;
|
|
54
|
+
try { shown = JSON.stringify(spec); } catch { shown = String(spec); }
|
|
55
|
+
return `${metric} takes an object { ${sides.join(", ")} }, got ${shown} — write ${form}`;
|
|
56
|
+
}
|
|
57
|
+
const keys = Object.keys(spec);
|
|
58
|
+
const unknown = keys.filter((k) => !sides.includes(k));
|
|
59
|
+
if (unknown.length) {
|
|
60
|
+
return `${metric} accepts only ${sides.map((k) => `"${k}"`).join(" and ")}, got ${unknown.map((k) => `"${k}"`).join(", ")} — write ${form}`;
|
|
61
|
+
}
|
|
62
|
+
if (!keys.length) return `${metric} needs at least one of ${sides.join(" or ")} — write ${form}`;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
18
66
|
export const SUBPART_METRICS = {
|
|
19
67
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
20
68
|
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
@@ -27,14 +75,16 @@ export const SUBPART_METRICS = {
|
|
|
27
75
|
hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
|
|
28
76
|
triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
|
|
29
77
|
hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
|
|
30
|
-
bbox: { kind: "gate", extract: (s) => s.bbox,
|
|
78
|
+
bbox: { kind: "gate", form: "vector", extract: (s) => s.bbox,
|
|
31
79
|
hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
|
|
32
|
-
centerOfMass: { kind: "gate", extract: (s) => s.centerOfMass,
|
|
80
|
+
centerOfMass: { kind: "gate", form: "vector", extract: (s) => s.centerOfMass,
|
|
33
81
|
hint: "center of mass is outside the expected region — mass is distributed differently than intended; check feature placement or a mis-scaled sub-part" },
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
82
|
+
bounds: boundsMetric((s) => s.bounds,
|
|
83
|
+
"the low corner is out of range — the part is positioned or oriented differently than expected",
|
|
84
|
+
"the high corner is out of range — the part is positioned or oriented differently than expected"),
|
|
85
|
+
...legacyBounds((s) => s.bounds,
|
|
86
|
+
"the low corner is out of range — the part is positioned or oriented differently than expected",
|
|
87
|
+
"the high corner is out of range — the part is positioned or oriented differently than expected"),
|
|
38
88
|
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
39
89
|
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
40
90
|
pattern: "minwall-sliver-triangles",
|
|
@@ -87,17 +137,19 @@ export const SUBPART_METRICS = {
|
|
|
87
137
|
...SHEET_METRICS,
|
|
88
138
|
};
|
|
89
139
|
export const VIEW_METRICS = {
|
|
90
|
-
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
140
|
+
bbox: { kind: "gate", form: "vector", extract: (r) => r.aggregate.bbox,
|
|
91
141
|
hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
|
|
92
142
|
volume: { kind: "gate", extract: (r) => r.aggregate.volume,
|
|
93
143
|
hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
|
|
94
144
|
overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
|
|
95
145
|
hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
|
|
96
146
|
locate: (r) => r.overlaps[0]?.location ?? null },
|
|
97
|
-
centerOfMass: { kind: "gate", extract: (r) => r.aggregate.centerOfMass,
|
|
147
|
+
centerOfMass: { kind: "gate", form: "vector", extract: (r) => r.aggregate.centerOfMass,
|
|
98
148
|
hint: "the assembly's center of mass is outside the expected region — a sub-part is mis-placed or mis-scaled" },
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
bounds: boundsMetric((r) => r.aggregate.bounds,
|
|
150
|
+
"the assembly's low corner is out of range — check placement or orientation",
|
|
151
|
+
"the assembly's high corner is out of range — check placement or orientation"),
|
|
152
|
+
...legacyBounds((r) => r.aggregate.bounds,
|
|
153
|
+
"the assembly's low corner is out of range — check placement or orientation",
|
|
154
|
+
"the assembly's high corner is out of range — check placement or orientation"),
|
|
103
155
|
};
|
package/types/gltf.d.ts
CHANGED
|
@@ -23,7 +23,10 @@ export interface PreviewMesh {
|
|
|
23
23
|
/**
|
|
24
24
|
* Feature-edge segments, endpoint pairs `[x0,y0,z0, x1,y1,z1, …]` in the same posed
|
|
25
25
|
* frame as `positions` (encoder version 2+). Written as a LINES primitive on the
|
|
26
|
-
* sub-part's mesh
|
|
26
|
+
* sub-part's mesh — since version 3, indices into the surface's own POSITION
|
|
27
|
+
* accessor (each endpoint snapped to the surface vertex at its position, or
|
|
28
|
+
* appended past the surface's vertices when it lies on none). Empty or absent
|
|
29
|
+
* writes none, and decodes as "no feature edges".
|
|
27
30
|
*/
|
|
28
31
|
edges?: Float32Array | null;
|
|
29
32
|
}
|
|
@@ -37,7 +40,7 @@ export interface EncodePreviewInput {
|
|
|
37
40
|
view?: string | null;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/** Bumped whenever the encoder writes different bytes for the same input (2: feature edges). */
|
|
43
|
+
/** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices). */
|
|
41
44
|
export const PREVIEW_ENCODER_VERSION: number;
|
|
42
45
|
|
|
43
46
|
/** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */
|
package/types/part.d.ts
CHANGED
|
@@ -506,6 +506,16 @@ export type AssertionExpr = number | string | boolean;
|
|
|
506
506
|
/** An expectation, optionally carrying a part-authored corrective `hint`. */
|
|
507
507
|
export type Expectation = AssertionExpr | { expr: AssertionExpr; hint?: string };
|
|
508
508
|
|
|
509
|
+
/**
|
|
510
|
+
* `bounds`: where the geometry sits, written in the same `{ min, max }` shape
|
|
511
|
+
* `measure` reports — `{ min: ">=[0,0,0]", max: "<=[220,220,250]" }`. Each side is
|
|
512
|
+
* a componentwise vector expectation (`*` skips an axis); either may be left out.
|
|
513
|
+
*/
|
|
514
|
+
export interface BoundsExpectation {
|
|
515
|
+
min?: Expectation;
|
|
516
|
+
max?: Expectation;
|
|
517
|
+
}
|
|
518
|
+
|
|
509
519
|
/**
|
|
510
520
|
* Per-sub-part expectations. The keys are exactly `SUBPART_METRICS` in
|
|
511
521
|
* src/framework/verify-metrics.js. `holes`/`watertight` are Manifold-only and
|
|
@@ -519,7 +529,11 @@ export interface SubPartExpectations {
|
|
|
519
529
|
triangleCount?: Expectation;
|
|
520
530
|
bbox?: Expectation;
|
|
521
531
|
centerOfMass?: Expectation;
|
|
532
|
+
/** The axis-aligned corners: `{ min: ">=[x,y,z]", max: "<=[x,y,z]" }`, as `measure` reports `bounds`. */
|
|
533
|
+
bounds?: BoundsExpectation;
|
|
534
|
+
/** @deprecated Write `bounds: { min }` — still evaluated, no longer taught. */
|
|
522
535
|
boundsMin?: Expectation;
|
|
536
|
+
/** @deprecated Write `bounds: { max }` — still evaluated, no longer taught. */
|
|
523
537
|
boundsMax?: Expectation;
|
|
524
538
|
minWall?: Expectation;
|
|
525
539
|
/**
|
|
@@ -574,7 +588,11 @@ export interface ViewExpectations {
|
|
|
574
588
|
volume?: Expectation;
|
|
575
589
|
overlaps?: Expectation;
|
|
576
590
|
centerOfMass?: Expectation;
|
|
591
|
+
/** The axis-aligned corners: `{ min: ">=[x,y,z]", max: "<=[x,y,z]" }`, as `measure` reports `bounds`. */
|
|
592
|
+
bounds?: BoundsExpectation;
|
|
593
|
+
/** @deprecated Write `bounds: { min }` — still evaluated, no longer taught. */
|
|
577
594
|
boundsMin?: Expectation;
|
|
595
|
+
/** @deprecated Write `bounds: { max }` — still evaluated, no longer taught. */
|
|
578
596
|
boundsMax?: Expectation;
|
|
579
597
|
/** Pairs that must touch, as `[["a", "b"], …]`. */
|
|
580
598
|
contacts?: Array<[string, string]>;
|