partforge 0.155.0 → 0.157.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 +56 -17
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- 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/mount-preview.js +2 -1
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/types/geometry.d.ts +6 -4
- package/types/gltf.d.ts +5 -2
- package/types/index.d.ts +6 -0
- package/types/part.d.ts +2 -0
|
@@ -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
|
|
@@ -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";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// CNC routing — process module #2. Flat stock machined from
|
|
1
|
+
// CNC routing — process module #2. Flat stock machined from either face — the near
|
|
2
|
+
// face (z = t, the default) or the far face (z = 0, per op `face: "far"`): through cuts
|
|
2
3
|
// (profile), flat-floored pockets at a depth, drilled holes at a depth, and `extra`
|
|
3
4
|
// operations the operator does with another tool after the flat milling. The
|
|
4
5
|
// drawing is one layered DXF per piece, depth on the layer name and colour.
|
|
@@ -12,8 +13,9 @@ import {
|
|
|
12
13
|
} from "../../sheet/constants.js";
|
|
13
14
|
import { err } from "../../lint/finding.js";
|
|
14
15
|
|
|
15
|
-
// The preview: the profile extruded to the thickness, each pocket cut `depth`
|
|
16
|
-
//
|
|
16
|
+
// The preview: the profile extruded to the thickness, each pocket cut `depth` into the
|
|
17
|
+
// stock from its face (down from z = t on the near face, up from z = 0 on the far
|
|
18
|
+
// face), each drill as a cylinder from its face to its depth —
|
|
17
19
|
// through holes and through pockets (depth = thickness) clear BOTH faces by MARK_OVERCUT so no cap is coplanar with a face
|
|
18
20
|
// (OCCT's coplanar booleans are fragile) — each extra solid subtracted as given, and
|
|
19
21
|
// the reference grooves cut like a laser score. A tool whose region turned out empty
|
|
@@ -29,10 +31,14 @@ function routedPreview(k, s) {
|
|
|
29
31
|
const tools = [
|
|
30
32
|
...pockets.map((pk) => orEmpty(() => (pk.depth >= t
|
|
31
33
|
? pk.region.extrude({ h: t + 2 * MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
|
|
32
|
-
: pk.
|
|
34
|
+
: pk.face === "far"
|
|
35
|
+
? pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, -MARK_OVERCUT])
|
|
36
|
+
: pk.region.extrude({ h: pk.depth + MARK_OVERCUT }).translate([0, 0, t - pk.depth])))),
|
|
33
37
|
...drills.map((dr) => (dr.through
|
|
34
38
|
? k.cylinder({ d: dr.d, h: t + 2 * MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
|
|
35
|
-
:
|
|
39
|
+
: dr.face === "far"
|
|
40
|
+
? k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], -MARK_OVERCUT])
|
|
41
|
+
: k.cylinder({ d: dr.d, h: dr.depth + MARK_OVERCUT }).translate([dr.at[0], dr.at[1], t - dr.depth]))),
|
|
36
42
|
...extra.map((x) => x.solid),
|
|
37
43
|
...s.grooves.map((g) => orEmpty(() => g.extrude({ h: MARK_DEPTH + MARK_OVERCUT }).translate([0, 0, t - MARK_DEPTH]))),
|
|
38
44
|
].filter(Boolean);
|
|
@@ -74,21 +80,21 @@ function routedFacts(s) {
|
|
|
74
80
|
for (const pk of pockets) {
|
|
75
81
|
if (pk.region.isEmpty()) { empty++; continue; }
|
|
76
82
|
const [w, h] = sizeOf(pk.region);
|
|
77
|
-
pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()) });
|
|
83
|
+
pocketRows.push({ label: pk.label, depth: pk.depth, w: round2(w), h: round2(h), area: round2(pk.region.area()), face: pk.face });
|
|
78
84
|
noteOutside(pk.region);
|
|
79
85
|
}
|
|
80
|
-
// Drills grouped by (d, depth, through): one row per tool setting, labels joined.
|
|
86
|
+
// Drills grouped by (d, depth, through, face): one row per tool setting, labels joined.
|
|
81
87
|
const drillGroups = new Map();
|
|
82
88
|
for (const dr of drills) {
|
|
83
|
-
const key = `${dr.d}|${dr.depth}|${dr.through}`;
|
|
84
|
-
const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, count: 0 };
|
|
89
|
+
const key = `${dr.d}|${dr.depth}|${dr.through}|${dr.face}`;
|
|
90
|
+
const g = drillGroups.get(key) ?? { labels: [], d: dr.d, depth: dr.depth, through: dr.through, face: dr.face, count: 0 };
|
|
85
91
|
g.count++;
|
|
86
92
|
if (dr.label && !g.labels.includes(dr.label)) g.labels.push(dr.label);
|
|
87
93
|
drillGroups.set(key, g);
|
|
88
94
|
noteOutside(dr.shape);
|
|
89
95
|
}
|
|
90
|
-
const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count }) =>
|
|
91
|
-
({ label: labels.length ? labels.join(", ") : null, d, depth, through, count }));
|
|
96
|
+
const drillRows = [...drillGroups.values()].map(({ labels, d, depth, through, count, face }) =>
|
|
97
|
+
({ label: labels.length ? labels.join(", ") : null, d, depth, through, count, face }));
|
|
92
98
|
// An extra op whose footprint resolved to nothing draws nothing: counted under
|
|
93
99
|
// `empty` with the empty pockets, never a row (its size has no bounding box).
|
|
94
100
|
const extraRows = [];
|
|
@@ -96,12 +102,25 @@ function routedFacts(s) {
|
|
|
96
102
|
if (x.footprint.isEmpty()) { empty++; continue; }
|
|
97
103
|
const [w, h] = sizeOf(x.footprint);
|
|
98
104
|
noteOutside(x.footprint);
|
|
99
|
-
// How deep the op reaches
|
|
100
|
-
//
|
|
105
|
+
// How deep the op reaches into the stock from its face — below z = t on the near
|
|
106
|
+
// face, above z = 0 on the far face — within the stock. An empty solid has no
|
|
107
|
+
// bounding box and reaches nothing.
|
|
101
108
|
const bb = x.solid.boundingBox();
|
|
102
|
-
const
|
|
103
|
-
const reach = Number.isFinite(
|
|
104
|
-
extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))) });
|
|
109
|
+
const bound = x.face === "far" ? bb?.max?.[2] : bb?.min?.[2];
|
|
110
|
+
const reach = Number.isFinite(bound) ? (x.face === "far" ? bound : t - bound) : 0;
|
|
111
|
+
extraRows.push({ label: x.label, method: x.method, w: round2(w), h: round2(h), maxDepth: round2(Math.max(0, Math.min(t, reach))), face: x.face });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Breakthrough: a near pocket and a far pocket whose regions overlap and whose depths
|
|
115
|
+
// together exceed the stock — the router breaks through between them. Depths adding
|
|
116
|
+
// up to exactly the thickness are a deliberate opening and never counted.
|
|
117
|
+
const live = pockets.filter((pk) => !pk.region.isEmpty());
|
|
118
|
+
const near = live.filter((pk) => pk.face !== "far"), far = live.filter((pk) => pk.face === "far");
|
|
119
|
+
let breakthrough = 0;
|
|
120
|
+
for (const a of near) {
|
|
121
|
+
for (const b of far) {
|
|
122
|
+
if (a.depth + b.depth > t + 1e-6 && a.region.intersect(b.region).area() > LOSS_TOL_MM2) breakthrough++;
|
|
123
|
+
}
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
return {
|
|
@@ -112,7 +131,7 @@ function routedFacts(s) {
|
|
|
112
131
|
at: { bridge: null, gap: null, marks: null, outside: null },
|
|
113
132
|
readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
|
|
114
133
|
evaluated: true,
|
|
115
|
-
routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty },
|
|
134
|
+
routed: { profile: { w: round2(flat[0]), h: round2(flat[1]) }, pockets: pocketRows, drills: drillRows, extra: extraRows, outside, empty, breakthrough },
|
|
116
135
|
};
|
|
117
136
|
}
|
|
118
137
|
|
|
@@ -130,10 +149,13 @@ const METRICS = {
|
|
|
130
149
|
routedPieces: { kind: "warn", doc: ROUTED_DOC_ID,
|
|
131
150
|
extract: (s) => s.sheet?.pieces ?? null,
|
|
132
151
|
hint: "the profile is not exactly one piece — a routed part must cut out as one region; join the pieces or split them into separate routedPart sub-parts" },
|
|
152
|
+
routedBreakthrough: { kind: "warn", doc: ROUTED_DOC_ID,
|
|
153
|
+
extract: (s) => s.sheet?.routed?.breakthrough ?? null,
|
|
154
|
+
hint: "a pocket on the near face and one on the far face overlap and together go deeper than the stock, so the router breaks through between them — make the overlap a through cut in `profile`, or lower one depth; if the opening is intended, set the two depths to add up to exactly the thickness" },
|
|
133
155
|
};
|
|
134
156
|
|
|
135
157
|
// ── lint ──────────────────────────────────────────────────────────────────────
|
|
136
|
-
const DRILL_DEPTH_HINT = "A drill's depth is measured from the
|
|
158
|
+
const DRILL_DEPTH_HINT = "A drill's depth is measured from the op's own face (near or far); a depth at or past the stock thickness is a through hole. Lower the depth, raise the stock thickness control, or set depth to the thickness to drill through.";
|
|
137
159
|
const ROUTER_RULES = [{
|
|
138
160
|
id: "routed-drill-depth",
|
|
139
161
|
run: ({ part, p, d, deriveError }) => {
|
|
@@ -168,7 +190,7 @@ export const ROUTER = {
|
|
|
168
190
|
preview: routedPreview,
|
|
169
191
|
destinations: [{ id: "cnc-router", cutFormat: "dxf" }],
|
|
170
192
|
facts: routedFacts,
|
|
171
|
-
checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1" }),
|
|
193
|
+
checks: () => ({ routedOutside: "0", routedEmpty: "0", routedPieces: "1", routedBreakthrough: "0" }),
|
|
172
194
|
metrics: METRICS,
|
|
173
195
|
lintRules: ROUTER_RULES,
|
|
174
196
|
};
|