partforge 0.153.0 → 0.155.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 +13 -9
- package/package.json +1 -1
- package/src/framework/gltf/decode-preview.js +62 -5
- package/src/framework/gltf/encode-preview.js +25 -6
- package/src/framework/lint/rules-verify.js +67 -24
- package/src/framework/mount-preview.js +3 -2
- 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/src/framework/viewer.js +15 -3
- package/types/gltf.d.ts +6 -2
- package/types/part.d.ts +18 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -4267,11 +4267,11 @@ are skipped. Switch it off under an FDM profile with an inline
|
|
|
4267
4267
|
`{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
|
|
4268
4268
|
assertions on the facts `measure` already reports — `holes` (through-bores / genus),
|
|
4269
4269
|
`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
|
|
4270
|
+
`overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
|
|
4271
|
+
positions, written in the same `{ min, max }` shape `measure` reports them — where
|
|
4272
|
+
the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
4273
4273
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
4274
|
-
`volume`, `overlaps`, `centerOfMass`, `
|
|
4274
|
+
`volume`, `overlaps`, `centerOfMass`, `bounds`, plus the pair-wise
|
|
4275
4275
|
`contacts` / `clearance` below.
|
|
4276
4276
|
|
|
4277
4277
|
Passing these checks does **not** prove structural strength, fatigue life, stability,
|
|
@@ -4283,9 +4283,13 @@ unverified.
|
|
|
4283
4283
|
|
|
4284
4284
|
**Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
|
|
4285
4285
|
`"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
|
|
4286
|
-
`bbox`, `centerOfMass
|
|
4287
|
-
`">=[x,y,z]"`
|
|
4288
|
-
|
|
4286
|
+
`bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector `"<=[x,y,z]"` /
|
|
4287
|
+
`">=[x,y,z]"` — exactly 3 components, each a plain number in mm or `*` to skip an axis.
|
|
4288
|
+
A vector is always one-sided: a bare `[40,40,10]` (meaning "exactly this size") is
|
|
4289
|
+
rejected, since a mesh's box shifts with tessellation and no tolerance would be honest
|
|
4290
|
+
for every part — pick `<=` for a limit or `>=` for a floor. `bounds` takes an object
|
|
4291
|
+
with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
|
|
4292
|
+
"<=[220,220,250]" }`. The parser is strict — a malformed assertion fails loudly.
|
|
4289
4293
|
|
|
4290
4294
|
**A part whose typeface is a parameter needs band assertions, not points.** Glyph
|
|
4291
4295
|
advance widths differ by family, so a `text2d` sub-part's `bbox`/`volume` shifts with
|
|
@@ -4297,8 +4301,8 @@ need this, but a part whose default already names a specific face does.
|
|
|
4297
4301
|
|
|
4298
4302
|
```js
|
|
4299
4303
|
verify: { expect: {
|
|
4300
|
-
stand: {
|
|
4301
|
-
_view: {
|
|
4304
|
+
stand: { bounds: { min: ">=[0,0,0]" }, centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
|
|
4305
|
+
_view: { bounds: { max: "<=[220,220,250]" } }, // whole assembly fits the bed
|
|
4302
4306
|
} }
|
|
4303
4307
|
```
|
|
4304
4308
|
|
package/package.json
CHANGED
|
@@ -11,6 +11,16 @@
|
|
|
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. GLTFLoader then
|
|
16
|
+
// makes the node a Group of a Mesh and a LineSegments (the node's extras and
|
|
17
|
+
// name on the Group, not the Mesh), so each surface and its lines are matched
|
|
18
|
+
// through the glTF NODE they belong to. Lines go back to model frame through
|
|
19
|
+
// the same matrix as the positions; a node with no line primitive — version 1,
|
|
20
|
+
// or a sub-part whose edges were empty — decodes to an EMPTY Float32Array,
|
|
21
|
+
// which the viewer reads as "no feature edges" (never undefined, which would
|
|
22
|
+
// make it derive lines by angle).
|
|
23
|
+
//
|
|
14
24
|
// Rejects on anything that is not a decodable GLB; the callers (mount's
|
|
15
25
|
// `preview`, mountPreview) decide what a failure means for them.
|
|
16
26
|
import { Matrix3, Matrix4, Vector3 } from "three";
|
|
@@ -50,15 +60,50 @@ function metaOf(extras) {
|
|
|
50
60
|
return Object.keys(out).length ? out : null;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
// The object standing for a glTF node: the object itself when its node had one
|
|
64
|
+
// primitive, else the Group GLTFLoader made for a multi-primitive mesh — the
|
|
65
|
+
// one carrying the node's extras / name (a primitive's own Mesh has neither).
|
|
66
|
+
function nodeObjectOf(obj) {
|
|
67
|
+
const own = obj.userData ?? {};
|
|
68
|
+
if ("partforgeSubPart" in own || "name" in own) return obj;
|
|
69
|
+
const parent = obj.parent;
|
|
70
|
+
return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Line segments as endpoint pairs in model frame (an indexed LINES primitive —
|
|
74
|
+
// weld() indexes it — expanded back to pairs).
|
|
75
|
+
function readSegments(geometry, matrix) {
|
|
76
|
+
const position = geometry.getAttribute("position");
|
|
77
|
+
if (!position || position.count === 0) return null;
|
|
78
|
+
const points = readVec3(position, matrix, false);
|
|
79
|
+
if (!geometry.index) return points;
|
|
80
|
+
const index = geometry.index.array;
|
|
81
|
+
const out = new Float32Array(index.length * 3);
|
|
82
|
+
for (let i = 0; i < index.length; i++) out.set(points.subarray(index[i] * 3, index[i] * 3 + 3), i * 3);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
53
86
|
export async function decodePreviewGlb(bytes) {
|
|
54
87
|
const data = toArrayBuffer(bytes);
|
|
55
88
|
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
|
56
89
|
const gltf = await loader.parseAsync(data, "");
|
|
57
90
|
gltf.scene.updateMatrixWorld(true);
|
|
58
91
|
const meshes = [];
|
|
92
|
+
const edgesByNode = new Map(); // node object → model-frame segment arrays
|
|
93
|
+
const meshByNode = new Map(); // node object → its surface payload
|
|
59
94
|
const toModel = new Matrix4();
|
|
60
95
|
const normalMatrix = new Matrix3();
|
|
61
96
|
gltf.scene.traverse((obj) => {
|
|
97
|
+
if (obj.isLineSegments) {
|
|
98
|
+
toModel.multiplyMatrices(Y_UP_TO_Z_UP, obj.matrixWorld);
|
|
99
|
+
const segments = readSegments(obj.geometry, toModel);
|
|
100
|
+
if (segments) {
|
|
101
|
+
const node = nodeObjectOf(obj);
|
|
102
|
+
edgesByNode.set(node, [...(edgesByNode.get(node) ?? []), segments]);
|
|
103
|
+
}
|
|
104
|
+
obj.geometry.dispose();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
62
107
|
if (!obj.isMesh) return;
|
|
63
108
|
const geometry = obj.geometry;
|
|
64
109
|
const position = geometry.getAttribute("position");
|
|
@@ -67,19 +112,31 @@ export async function decodePreviewGlb(bytes) {
|
|
|
67
112
|
normalMatrix.getNormalMatrix(toModel);
|
|
68
113
|
const normal = geometry.getAttribute("normal");
|
|
69
114
|
const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
|
|
70
|
-
const
|
|
71
|
-
|
|
115
|
+
const node = nodeObjectOf(obj);
|
|
116
|
+
const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
|
|
117
|
+
const payload = {
|
|
72
118
|
// The writer's own record of the key, then the unsanitized glTF node name.
|
|
73
|
-
name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ??
|
|
119
|
+
name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
|
|
74
120
|
positions: readVec3(position, toModel, false),
|
|
75
121
|
normals: normal ? readVec3(normal, normalMatrix, true) : null,
|
|
76
122
|
indices,
|
|
77
123
|
triangles: (indices ? indices.length : position.count) / 3,
|
|
78
|
-
edges: new Float32Array(0), //
|
|
124
|
+
edges: new Float32Array(0), // filled below from the node's LINES primitive, if any
|
|
79
125
|
display: partforgeDisplay && typeof partforgeDisplay === "object" ? partforgeDisplay : null,
|
|
80
|
-
}
|
|
126
|
+
};
|
|
127
|
+
meshes.push(payload);
|
|
128
|
+
if (!meshByNode.has(node)) meshByNode.set(node, payload);
|
|
81
129
|
geometry.dispose();
|
|
82
130
|
});
|
|
131
|
+
for (const [node, parts] of edgesByNode) {
|
|
132
|
+
const payload = meshByNode.get(node);
|
|
133
|
+
if (!payload) continue; // lines with no surface: nothing to draw them on
|
|
134
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
135
|
+
const edges = new Float32Array(total);
|
|
136
|
+
let at = 0;
|
|
137
|
+
for (const p of parts) { edges.set(p, at); at += p.length; }
|
|
138
|
+
payload.edges = edges;
|
|
139
|
+
}
|
|
83
140
|
const extras = gltf.userData ?? {};
|
|
84
141
|
return {
|
|
85
142
|
view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
// buildView all hold) as a small GLB a viewer can show before the kernel boots.
|
|
4
4
|
//
|
|
5
5
|
// The file, which a reader may rely on:
|
|
6
|
-
// - one node + mesh
|
|
7
|
-
//
|
|
6
|
+
// - one node + mesh per sub-part, both named by the sub-part key; the mesh's
|
|
7
|
+
// FIRST primitive is the surface (TRIANGLES: POSITION + NORMAL, + indices
|
|
8
|
+
// when the kernel sent them); a sub-part with feature edges adds a SECOND
|
|
9
|
+
// primitive, mode LINES, POSITION only and no material — the kernel's edge
|
|
10
|
+
// segments in the same posed frame. A sub-part whose `edges` is empty or
|
|
11
|
+
// absent has no line primitive; a reader takes that as "no feature edges"
|
|
12
|
+
// (never "derive them"). Version-1 files have no line primitives at all.
|
|
13
|
+
// Both primitives share the mesh, so quantize() gives them ONE quantization
|
|
14
|
+
// volume and one node transform: the lines dequantize onto the faces.
|
|
8
15
|
// - every sub-part node under ONE root node, "partforge", rotated −90° about X:
|
|
9
16
|
// partforge models Z-up in millimetres and glTF is Y-up. Units stay mm — the
|
|
10
17
|
// file is for partforge's own viewer; metres would only matter for AR.
|
|
@@ -14,20 +21,20 @@
|
|
|
14
21
|
// - top-level extras { partforgeView, partforgeEncoder, partforgeMeta? }
|
|
15
22
|
// - weld() → quantize() → meshopt({ level: "medium" }): 5–8 bytes a triangle,
|
|
16
23
|
// the recipe measured in partforge-cloud's local_glb_benchmark.md
|
|
17
|
-
// Not encoded
|
|
24
|
+
// Not encoded: textures, patterns. (Feature edges since version 2.)
|
|
18
25
|
//
|
|
19
26
|
// Runs in Node, a page and a Web Worker: no three.js, no DOM, no Blob
|
|
20
27
|
// (test/gltf-layering.test.js). Deterministic: the same input writes the same
|
|
21
28
|
// bytes, and nothing here mutates the input (exportPreview passes the viewer's
|
|
22
29
|
// live buffers). Bump PREVIEW_ENCODER_VERSION whenever the bytes for the same
|
|
23
30
|
// input change — downstream caches key on it.
|
|
24
|
-
import { Document, Logger, WebIO } from "@gltf-transform/core";
|
|
31
|
+
import { Document, Logger, Primitive, WebIO } from "@gltf-transform/core";
|
|
25
32
|
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
|
26
33
|
import { meshopt, quantize, weld } from "@gltf-transform/functions";
|
|
27
34
|
import { MeshoptEncoder } from "meshoptimizer/encoder";
|
|
28
35
|
import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
|
|
29
36
|
|
|
30
|
-
export const PREVIEW_ENCODER_VERSION =
|
|
37
|
+
export const PREVIEW_ENCODER_VERSION = 2; // 2: feature edges as a LINES primitive
|
|
31
38
|
|
|
32
39
|
// −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
|
|
33
40
|
const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
|
|
@@ -72,6 +79,13 @@ function checkMesh(m, seen) {
|
|
|
72
79
|
if (m.normals != null && m.normals.length > 0 && m.normals.length !== m.positions.length) {
|
|
73
80
|
throw new Error(`encodePreviewGlb: "${m.name}" normals do not match its positions`);
|
|
74
81
|
}
|
|
82
|
+
// Any zero-length edges (typed or plain) mean "no feature edges": nothing to check.
|
|
83
|
+
if (m.edges != null && m.edges.length !== 0) {
|
|
84
|
+
if (!ArrayBuffer.isView(m.edges) && !Array.isArray(m.edges)) {
|
|
85
|
+
throw new TypeError(`encodePreviewGlb: "${m.name}" edges must be a typed array or an array of numbers`);
|
|
86
|
+
}
|
|
87
|
+
if (m.edges.length % 6 !== 0) throw new Error(`encodePreviewGlb: "${m.name}" edges are not whole segments`);
|
|
88
|
+
}
|
|
75
89
|
if (indexed) {
|
|
76
90
|
const vertexCount = m.positions.length / 3;
|
|
77
91
|
for (let i = 0; i < m.indices.length; i++) {
|
|
@@ -126,9 +140,14 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
126
140
|
.setMetallicFactor(look.metalness)
|
|
127
141
|
.setRoughnessFactor(look.roughness)
|
|
128
142
|
.setAlphaMode(look.opacity < 1 ? "BLEND" : "OPAQUE"));
|
|
143
|
+
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
|
+
}
|
|
129
148
|
const extras = displayExtras(display);
|
|
130
149
|
root.addChild(doc.createNode(m.name)
|
|
131
|
-
.setMesh(
|
|
150
|
+
.setMesh(mesh)
|
|
132
151
|
.setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name }));
|
|
133
152
|
}
|
|
134
153
|
if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
|
|
@@ -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"));
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// built once the GLB has decoded, from a synthetic part: one sub-part per glTF
|
|
9
9
|
// mesh, carrying the `display` block the writer stored beside it, and the file's
|
|
10
10
|
// background/environment meta. The CAD look is exact; realistic mode uses each
|
|
11
|
-
// sub-part's display only — no sheet stock, no layer-line print frames (a
|
|
12
|
-
// residual
|
|
11
|
+
// sub-part's display only — no sheet stock, no layer-line print frames (a
|
|
12
|
+
// residual of the format). CAD feature edges come from the file (encoder
|
|
13
|
+
// version 2); a version-1 file decodes with none. The decoder loads on demand (it carries the
|
|
13
14
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
14
15
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
15
16
|
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/src/framework/viewer.js
CHANGED
|
@@ -1741,9 +1741,12 @@ export function createViewer(container, part) {
|
|
|
1741
1741
|
// draw none rather than falling back. Only a payload with NO edge data at
|
|
1742
1742
|
// all (edges === undefined; no current backend does this) derives by angle.
|
|
1743
1743
|
const lg = new LineSegmentsGeometry();
|
|
1744
|
-
|
|
1745
|
-
|
|
1744
|
+
// The raw segment pairs drawn, kept for getSubMeshPayload (reading them back
|
|
1745
|
+
// out of LineSegmentsGeometry's interleaved instance buffer is awkward).
|
|
1746
|
+
const segments = edges ?? new THREE.EdgesGeometry(out, EDGE_ANGLE).getAttribute("position").array;
|
|
1747
|
+
lg.setPositions(segments); // a well-formed (possibly zero-length) Float32Array
|
|
1746
1748
|
out.userData.edges = lg;
|
|
1749
|
+
out.userData.edgePositions = segments;
|
|
1747
1750
|
return out;
|
|
1748
1751
|
}
|
|
1749
1752
|
|
|
@@ -1790,7 +1793,9 @@ export function createViewer(container, part) {
|
|
|
1790
1793
|
// The geometry drawn for one sub-part, as a fresh worker-style payload with its
|
|
1791
1794
|
// fast-path pose (setSubPose) baked in — the posed mesh a headless buildView
|
|
1792
1795
|
// would return. Copies: the caller (exportPreview → partforge/gltf) may keep or
|
|
1793
|
-
// transfer them. null when nothing has been delivered under that name.
|
|
1796
|
+
// transfer them. null when nothing has been delivered under that name. Its
|
|
1797
|
+
// `edges` are the lines shown on screen: the kernel's segments, or — when the
|
|
1798
|
+
// delivery carried no edge data at all — the angle-derived fallback lines.
|
|
1794
1799
|
const _payloadV = new THREE.Vector3();
|
|
1795
1800
|
const _payloadNormal = new THREE.Matrix3();
|
|
1796
1801
|
function getSubMeshPayload(name) {
|
|
@@ -1811,10 +1816,17 @@ export function createViewer(container, part) {
|
|
|
1811
1816
|
_payloadV.fromBufferAttribute(nor, i).applyMatrix3(_payloadNormal).normalize().toArray(normals, i * 3);
|
|
1812
1817
|
}
|
|
1813
1818
|
}
|
|
1819
|
+
// Feature edges, posed by the same matrix (an empty array stays empty: "none").
|
|
1820
|
+
const rawEdges = geo.userData.edgePositions ?? new Float32Array(0);
|
|
1821
|
+
const edges = new Float32Array(rawEdges.length);
|
|
1822
|
+
for (let i = 0; i < rawEdges.length; i += 3) {
|
|
1823
|
+
_payloadV.fromArray(rawEdges, i).applyMatrix4(pose).toArray(edges, i);
|
|
1824
|
+
}
|
|
1814
1825
|
return {
|
|
1815
1826
|
name,
|
|
1816
1827
|
positions,
|
|
1817
1828
|
normals,
|
|
1829
|
+
edges,
|
|
1818
1830
|
indices: geo.index ? Uint32Array.from(geo.index.array) : null,
|
|
1819
1831
|
triangles: geo.userData.triangles ?? (geo.index ? geo.index.count : pos.count) / 3,
|
|
1820
1832
|
};
|
package/types/gltf.d.ts
CHANGED
|
@@ -20,7 +20,11 @@ export interface PreviewMesh {
|
|
|
20
20
|
/** Absent for a non-indexed soup (Manifold); OCCT sends indices. */
|
|
21
21
|
indices?: Uint32Array | null;
|
|
22
22
|
triangles?: number;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Feature-edge segments, endpoint pairs `[x0,y0,z0, x1,y1,z1, …]` in the same posed
|
|
25
|
+
* frame as `positions` (encoder version 2+). Written as a LINES primitive on the
|
|
26
|
+
* sub-part's mesh. Empty or absent writes none, and decodes as "no feature edges".
|
|
27
|
+
*/
|
|
24
28
|
edges?: Float32Array | null;
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -33,7 +37,7 @@ export interface EncodePreviewInput {
|
|
|
33
37
|
view?: string | null;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/** Bumped whenever the encoder writes different bytes for the same input. */
|
|
40
|
+
/** Bumped whenever the encoder writes different bytes for the same input (2: feature edges). */
|
|
37
41
|
export const PREVIEW_ENCODER_VERSION: number;
|
|
38
42
|
|
|
39
43
|
/** 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]>;
|