partforge 0.155.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.
@@ -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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.155.0",
3
+ "version": "0.156.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- function makeProbe(onCall) {
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) return (...args) => { onCall(scope, key, args); return queries[key](...args); };
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 { kernel, proxy, shape2d } = makeProbe(onCall);
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. GLTFLoader then
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, transformed.
40
- function readVec3(attr, matrix, isNormal) {
41
- const out = new Float32Array(attr.count * 3);
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 < attr.count; 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 (an indexed LINES primitive —
74
- // weld() indexes it — expanded back to pairs).
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
- const points = readVec3(position, matrix, false);
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
- for (let i = 0; i < index.length; i++) out.set(points.subarray(index[i] * 3, index[i] * 3 + 3), i * 3);
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 : position.count) / 3,
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, 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.
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() → quantize() → meshopt({ level: "medium" }): 5–8 bytes a triangle,
23
- // the recipe measured in partforge-cloud's local_glb_benchmark.md
24
- // Not encoded: textures, patterns. (Feature edges since version 2.)
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 { meshopt, quantize, weld } from "@gltf-transform/functions";
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 = 2; // 2: feature edges as a LINES primitive
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
- await doc.transform(weld(), quantize(), meshopt({ encoder: MeshoptEncoder, level: "medium" }));
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
- err("build-throws", `sub-part "${subpart}" threw during a geometry-free build: ${message}`,
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 decodes with none. The decoder loads on demand (it carries the
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";
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. Empty or absent writes none, and decodes as "no feature edges".
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. */