partforge 0.73.0 → 0.73.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.73.0",
3
+ "version": "0.73.1",
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",
@@ -221,22 +221,91 @@ export function choicesEqual(a, b) {
221
221
  // The vertex realizing the extreme along `axis` over the posed meshes; ties
222
222
  // within tolerance (a flat base is all "the minimum") break toward `near`, so
223
223
  // the anchor lands on the side of the part the dimension is drawn on.
224
+ //
225
+ // placeBox asks for all six ±axis directions, and the naive shape of this —
226
+ // one pass to find the extreme, a second to tie-break, per call — walked every
227
+ // vertex twelve times over. On a soup carrying three points per triangle that
228
+ // is the dominant cost of a placement, so the walk happens ONCE per (geometry,
229
+ // pose) and every later call is answered from `extremeCache`: the six extreme
230
+ // VALUES, plus, per direction, the posed vertices tied with that direction's
231
+ // own extreme. `near` varies per call (it is the side the dim hangs off), so
232
+ // the tie-break itself still runs — over the handful of tied points instead of
233
+ // the whole soup.
234
+ //
235
+ // Per-mesh candidates are exact for the multi-mesh scan they feed: a vertex
236
+ // within TIE_TOL of the GLOBAL extreme is necessarily within TIE_TOL of its own
237
+ // mesh's extreme too (the mesh extreme sits between them), so nothing that
238
+ // could win is missing from the union — and the loop below re-filters against
239
+ // the global value regardless.
240
+ //
241
+ // A candidate is stored as its INDEX into the soup, not as a posed point: four
242
+ // bytes instead of twenty-four, and the tie-break still runs the same f64
243
+ // transform the un-cached version did, so the anchor it picks is bit-identical.
244
+ // The size question that buys is a real one — most parts sit on a flat face, so
245
+ // one direction's tie set can be a whole face of the mesh — and on the parts
246
+ // this was measured against the whole cache lands around 0.1% of the soup.
247
+ const TIE_TOL = 1e-3;
248
+ // The extremes and the tie sets are both POSED, so a record is only valid for
249
+ // the pose it was built from. A mesh's `matrix` is mutated in place by the
250
+ // viewer's pose fast path (setSubPose), so identity says nothing about it — the
251
+ // sixteen elements do.
252
+ const extremeCache = new WeakMap(); // positions -> { pose, lo, hi, cand: Uint32Array[6] }
224
253
  const _sv = new THREE.Vector3();
225
- export function extremeVertex(meshData, axis, sign, near) {
226
- let bestVal = sign > 0 ? -Infinity : Infinity;
227
- for (const { positions, matrix } of meshData) {
254
+
255
+ const dirIndex = (axis, sign) => axis * 2 + (sign > 0 ? 0 : 1);
256
+
257
+ const samePose = (a, b) => {
258
+ for (let i = 0; i < 16; i++) if (a[i] !== b[i]) return false;
259
+ return true;
260
+ };
261
+
262
+ function meshExtremes(positions, matrix) {
263
+ const hit = extremeCache.get(positions);
264
+ if (hit && samePose(hit.pose, matrix.elements)) return hit;
265
+ const lo = [Infinity, Infinity, Infinity];
266
+ const hi = [-Infinity, -Infinity, -Infinity];
267
+ for (let i = 0; i < positions.length; i += 3) {
268
+ _sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
269
+ for (let a = 0; a < 3; a++) {
270
+ const v = _sv.getComponent(a);
271
+ if (v < lo[a]) lo[a] = v;
272
+ if (v > hi[a]) hi[a] = v;
273
+ }
274
+ }
275
+ const cand = [[], [], [], [], [], []];
276
+ if (Number.isFinite(lo[0])) {
228
277
  for (let i = 0; i < positions.length; i += 3) {
229
278
  _sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
230
- const val = _sv.getComponent(axis);
231
- if (sign > 0 ? val > bestVal : val < bestVal) bestVal = val;
279
+ for (let a = 0; a < 3; a++) {
280
+ const v = _sv.getComponent(a);
281
+ if (Math.abs(v - hi[a]) <= TIE_TOL) cand[a * 2].push(i);
282
+ if (Math.abs(v - lo[a]) <= TIE_TOL) cand[a * 2 + 1].push(i);
283
+ }
232
284
  }
233
285
  }
286
+ const rec = { pose: matrix.elements.slice(), lo, hi, cand: cand.map((c) => Uint32Array.from(c)) };
287
+ extremeCache.set(positions, rec);
288
+ return rec;
289
+ }
290
+
291
+ export function extremeVertex(meshData, axis, sign, near) {
292
+ const dir = dirIndex(axis, sign);
293
+ let bestVal = sign > 0 ? -Infinity : Infinity;
294
+ const scan = [];
295
+ for (const { positions, matrix } of meshData) {
296
+ const rec = meshExtremes(positions, matrix);
297
+ const v = sign > 0 ? rec.hi[axis] : rec.lo[axis];
298
+ if (!Number.isFinite(v)) continue; // empty mesh
299
+ scan.push({ positions, matrix, cand: rec.cand[dir] });
300
+ if (sign > 0 ? v > bestVal : v < bestVal) bestVal = v;
301
+ }
234
302
  if (!Number.isFinite(bestVal)) return null;
235
303
  let best = null, bestD = Infinity;
236
- for (const { positions, matrix } of meshData) {
237
- for (let i = 0; i < positions.length; i += 3) {
304
+ for (const { positions, matrix, cand } of scan) {
305
+ for (let k = 0; k < cand.length; k++) {
306
+ const i = cand[k];
238
307
  _sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
239
- if (Math.abs(_sv.getComponent(axis) - bestVal) > 1e-3) continue;
308
+ if (Math.abs(_sv.getComponent(axis) - bestVal) > TIE_TOL) continue;
240
309
  const d = _sv.distanceToSquared(near);
241
310
  if (d < bestD) { bestD = d; best = _sv.clone(); }
242
311
  }
@@ -19,6 +19,35 @@ import { createPinStore, occurrenceOf } from "./pins.js";
19
19
  import { evaluateChoices, choicesEqual, placeDims, specSig, laneCounts } from "./dim3-place.js";
20
20
  import { createDimScene } from "./dim3-scene.js";
21
21
 
22
+ // How many distinct placements each cache holds. An orbit sweeps a bounded set
23
+ // of side choices, and a session hovers a bounded set of features; anything
24
+ // past that is ground the user has moved on from. Entries are plain drawing
25
+ // records (numbers, no GL objects), so the ceiling is about retention hygiene
26
+ // on a memory-tight device, not about a runaway.
27
+ const PLACEMENT_CACHE_LIMIT = 32;
28
+
29
+ // Bounded LRU. A Map iterates in insertion order, so re-inserting on a hit
30
+ // moves the entry to the back and the oldest key is always the first one out.
31
+ function keyedCache(limit) {
32
+ const entries = new Map();
33
+ return {
34
+ get(key) {
35
+ if (!entries.has(key)) return undefined;
36
+ const value = entries.get(key);
37
+ entries.delete(key);
38
+ entries.set(key, value);
39
+ return value;
40
+ },
41
+ set(key, value) {
42
+ entries.delete(key);
43
+ entries.set(key, value);
44
+ if (entries.size > limit) entries.delete(entries.keys().next().value);
45
+ return value;
46
+ },
47
+ clear() { entries.clear(); },
48
+ };
49
+ }
50
+
22
51
  export function createMeasureMode(viewer, { part, getContext, revealParams, getParamsVersion, schedule = (cb) => requestAnimationFrame(cb) }) {
23
52
  const pins = createPinStore();
24
53
  const pinListeners = new Set();
@@ -218,7 +247,23 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
218
247
  // hover dim, which changes on every pointer move. placeBox scans every
219
248
  // vertex of the meshes it covers (tens of ms on a big part), so re-placing
220
249
  // the whole set per hover frame is the one path that must not exist.
221
- const baseCache = { key: null, drawings: [] };
250
+ //
251
+ // Both caches are KEYED and bounded rather than one slot each. A one-slot
252
+ // cache is right for a monotonic sequence and wrong for an orbit, which
253
+ // sweeps back and forth over the same handful of side choices — measured at
254
+ // ~15 flips per 360° turn, every one of them a full re-place of ground
255
+ // already covered. Keyed, an orbit places each distinct choice once.
256
+ const baseCache = keyedCache(PLACEMENT_CACHE_LIMIT);
257
+ // ...and the PAINTED list — base plus the hover dim — cached the same way, on
258
+ // the base key plus everything the hover pass alone depends on. The hover
259
+ // spec is identical for every pointer position over one feature, so without
260
+ // this a pointer resting on a face re-ran that item's vertex scan and its
261
+ // surface raycasts on every single rAF.
262
+ const paintCache = keyedCache(PLACEMENT_CACHE_LIMIT);
263
+ // The drawing list currently on screen, held by IDENTITY: scene.update()
264
+ // rebuilds every child object (and repaints every label texture), so a list
265
+ // the cache just handed back unchanged must not reach it.
266
+ let painted = null;
222
267
  const _rc = new THREE.Raycaster();
223
268
  const _origin = new THREE.Vector3();
224
269
  const _dir = new THREE.Vector3();
@@ -274,12 +319,26 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
274
319
  return key;
275
320
  }
276
321
 
322
+ // The hover half of the choice table, which baseCacheKey deliberately omits
323
+ // (hover entries come and go with the pointer and must not evict the base).
324
+ // A camera move that flips which side the hovered dim hangs off has to
325
+ // re-place it, so the paint key carries them.
326
+ function hoverChoiceKey() {
327
+ let key = "";
328
+ for (const ck of Object.keys(choices)) {
329
+ if (!ck.startsWith("hover|")) continue;
330
+ const c = choices[ck];
331
+ key += `|${ck}=${c.key ?? ""}${c.du ? `,${c.du.map((n) => n.toFixed(4))}` : ""}`;
332
+ }
333
+ return key;
334
+ }
335
+
277
336
  function rebuild() {
278
337
  if (!enabled || !scene) return;
279
338
  const { items, meshes, bounds } = buildItems();
280
339
  lastItems = items;
281
340
  lastBounds = bounds ?? null;
282
- if (!items.length || !bounds) { scene.clear(); baseCache.key = null; return; }
341
+ if (!items.length || !bounds) { scene.clear(); painted = null; return; }
283
342
  const env = buildEnv(meshes);
284
343
  choices = evaluateChoices(items, { camPos: env.camPos, center: centerOf(bounds), prev: choices });
285
344
  const place = (list, suppress, lanes) =>
@@ -287,10 +346,8 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
287
346
  const hoverItem = items.find((i) => i.id === "hover");
288
347
  const baseItems = hoverItem ? items.filter((i) => i !== hoverItem) : items;
289
348
  const key = baseCacheKey(`${units}|${meshSig()}`, baseItems);
290
- if (baseCache.key !== key) {
291
- baseCache.key = key;
292
- baseCache.drawings = place(baseItems);
293
- }
349
+ let baseDrawings = baseCache.get(key);
350
+ if (!baseDrawings) baseDrawings = baseCache.set(key, place(baseItems));
294
351
  // The hover pass can't see the base pass's items (they're cached), so it
295
352
  // hands over (a) their sigs as `suppress` — a hover duplicating an
296
353
  // already-drawn measurement (the sub-part bounds over the overall, a
@@ -298,13 +355,20 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
298
355
  // occupancy, so a hovered dim staggers into the SAME lane it will occupy
299
356
  // once pinned (pins append after the base items in the same order) and
300
357
  // clicking never moves it.
301
- scene.update(hoverItem
302
- ? baseCache.drawings.concat(place(
303
- [hoverItem],
304
- new Set(baseItems.map((i) => specSig(i.spec))),
305
- laneCounts(baseCache.drawings),
306
- ))
307
- : baseCache.drawings);
358
+ const paintKey = hoverItem
359
+ ? `h|${key}|${specSig(hoverItem.spec)}|${hoverChoiceKey()}`
360
+ : `b|${key}`;
361
+ let drawings = paintCache.get(paintKey);
362
+ if (!drawings) {
363
+ drawings = paintCache.set(paintKey, hoverItem
364
+ ? baseDrawings.concat(place(
365
+ [hoverItem],
366
+ new Set(baseItems.map((i) => specSig(i.spec))),
367
+ laneCounts(baseDrawings),
368
+ ))
369
+ : baseDrawings);
370
+ }
371
+ if (painted !== drawings) { painted = drawings; scene.update(drawings); }
308
372
  }
309
373
 
310
374
  // ---- frame dirty check ---------------------------------------------------
@@ -500,8 +564,9 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
500
564
  scene?.clear();
501
565
  lastItems = [];
502
566
  lastBounds = null;
503
- baseCache.key = null;
504
- baseCache.drawings = [];
567
+ baseCache.clear();
568
+ paintCache.clear();
569
+ painted = null;
505
570
  dom.style.cursor = "";
506
571
  }
507
572
  notifyMode();