partforge 0.72.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.
@@ -225,9 +225,119 @@
225
225
  (background/border/radius/shadow) from app.css, so that chrome must live in
226
226
  app.css ungated — not be duplicated into a class the cloud never sets. This
227
227
  file owns where things sit; app.css owns what they look like. */
228
- .pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
229
- .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
230
- .pf-float-viewbar { bottom: 12px; right: 12px; }
228
+ .pf-float-tabs, .pf-float-viewbar, .pf-float-rail-toggle { position: absolute; z-index: 15; }
229
+ /* Every inset below is SHARED with whichever other float sits on that edge, so
230
+ the stage's margin is stated once per edge and not once per element. The rail
231
+ toggle (2026-08-20: out of #viewbar's pill, floating on its own at the top
232
+ right) is the tab strip's `top` with the viewbar's `right` — a corner
233
+ symmetric with the viewbar's, by construction rather than by two numbers that
234
+ happen to agree today. Retune the margin and all three corners follow.
235
+ Appearance, including the `[hidden]` guard that lets rail.js hide the toggle
236
+ below the narrow breakpoint, is in app.css. */
237
+ .pf-float-tabs, .pf-float-rail-toggle { top: 12px; }
238
+ .pf-float-viewbar, .pf-float-rail-toggle { right: 12px; }
239
+ .pf-float-tabs { left: 50%; transform: translateX(-50%); }
240
+ .pf-float-viewbar { bottom: 12px; }
241
+
242
+ /* ---- view cube stack: PLACEMENT ONLY (see the rule above) ----------------
243
+ Bottom-right, stacked above #viewbar. The offset is measured, not
244
+ hardcoded: mount.js publishes --pf-viewbar-clear on the stage from a
245
+ ResizeObserver on #viewbar, mirroring --pf-anim-clear. It sits there rather
246
+ than in viewcube-controls.js (the precedent being animation-controls.js,
247
+ which publishes its own --pf-anim-clear) because the value describes the
248
+ VIEWBAR's vertical claim, not the cube's, and mount owns both elements. The
249
+ 56px fallback is the standard viewbar's 12px bottom + 44px height, so the
250
+ stack still sits right if the observer never fires.
251
+
252
+ The projection toggle has moved twice. Through 2026-08-19 it sat in its own
253
+ `.pf-viewcube-pill` card BELOW the cube, making the stack a column. Earlier
254
+ on 2026-08-20 it became a bare circle BESIDE the cube, making the stack a
255
+ `row-reverse` flex row (`canvas + gap + button`, 167px wide at the full
256
+ 135px cube). It now sits OVER the cube's bottom-right corner instead:
257
+ absolutely positioned within the stack, right edge on the stack's right edge
258
+ and bottom edge on its bottom baseline, so the stack's width collapses back
259
+ to the canvas alone (135px, or 101 below the narrow breakpoint).
260
+
261
+ That is also what satisfies "aligned with the right of the toolbar" without a
262
+ second offset: `.pf-float-viewbar` above is `right: 12px` too, so making the
263
+ button's right edge the STACK's right edge lines it up with the viewbar's by
264
+ construction — and it keeps doing so if that margin is ever retuned.
265
+
266
+ The button is a later sibling than the cube's wrapper, so it paints over the
267
+ canvas with no z-index of its own; 24px over the corner cell — the least
268
+ informative part of the drawing — is small enough not to hide anything worth
269
+ clicking, and the canvas keeps receiving pointer events everywhere else.
270
+
271
+ The 8px that used to sit between the stack and the viewbar is now 3px
272
+ (2026-08-20: "lower the whole box so it's closer to the bottom bar"). 3
273
+ rather than 0 because the stack's own box MUST NOT overlap #viewbar: the
274
+ stack is a later sibling at the same z-index, and its canvas is
275
+ pointer-events:auto across its whole footprint, so any vertical overlap
276
+ would silently swallow clicks on the viewbar's rightmost buttons (both are
277
+ right: 12px). --pf-viewbar-clear is published rounded to whole px, so a
278
+ fractional viewbar height can move this by half a pixel either way; 3px
279
+ absorbs that and still reads as a deliberate gap rather than a collision.
280
+ The other ~5px of the lowering comes from inside the canvas — see
281
+ cube-geom.js's CUBE_DOWN_BIAS_PX; that is where the rest of the perceived
282
+ gap actually lives, and it is capped by the axis labels' clearance, so
283
+ this pair (3 + 5) is the whole slack that exists without either overlapping
284
+ the viewbar or shrinking the cube.
285
+
286
+ The stack itself is pointer-transparent. With the button back inside the
287
+ canvas's footprint there is no dead gap left at all: the stack's box IS the
288
+ canvas's box, and both the canvas and the button opt back in below. The
289
+ declaration stays because the stack's own box is still an element over the
290
+ viewer, and a stray hit on it (its padding-free edges, or a future child
291
+ before it opts in) should reach the model behind rather than be swallowed. */
292
+ .pf-viewcube-stack {
293
+ position: absolute;
294
+ right: 12px;
295
+ bottom: calc(var(--pf-viewbar-clear, 56px) + 3px);
296
+ z-index: 15;
297
+ display: flex;
298
+ pointer-events: none;
299
+ }
300
+ .pf-viewcube-stack[hidden] { display: none; }
301
+ .pf-viewcube-canvas, .pf-viewcube-toggle { pointer-events: auto; }
302
+ /* Over the cube's bottom-right corner. Absolute against the stack, which is
303
+ itself absolute and so already the containing block. */
304
+ .pf-viewcube-toggle { position: absolute; right: 0; bottom: 0; }
305
+
306
+ /* The keyboard surface: six per-view buttons standing in for the DOM focus a
307
+ canvas cannot give us. Visually hidden rather than display:none — the latter
308
+ takes them out of the tab order, which is the whole point of them.
309
+
310
+ The hiding properties are on the BUTTONS, not on their wrapper, so that
311
+ :focus-visible can undo them. A non-`none` clip-path clips the element's
312
+ whole SUBTREE and makes the element a containing block for fixed-position
313
+ descendants, so with the clip on the wrapper no rule on a focused child
314
+ could escape it — the reveal below was dead CSS, and six buttons sat in the
315
+ tab order with no visible focus indicator at all. On the buttons themselves
316
+ there is no clipping ancestor to get out of.
317
+
318
+ The wrapper keeps only `position: absolute`, which takes it out of the
319
+ stack's flex flow. That mattered when the stack had a gap (a zero-height
320
+ flex item still earned it, pushing the cube out of place); it still matters
321
+ now that it does not, because an in-flow second item would widen the stack
322
+ past the canvas — and the stack's width is what the crowding rule reads. */
323
+ .pf-viewcube-key { position: absolute; }
324
+ .pf-viewcube-key button {
325
+ position: absolute;
326
+ width: 1px; height: 1px;
327
+ margin: -1px; padding: 0;
328
+ overflow: hidden;
329
+ clip-path: inset(50%);
330
+ white-space: nowrap;
331
+ border: 0;
332
+ }
333
+ .pf-viewcube-key button:focus-visible {
334
+ position: fixed;
335
+ width: auto; height: auto;
336
+ margin: 0; padding: 2px 6px;
337
+ overflow: visible;
338
+ clip-path: none;
339
+ pointer-events: auto; /* the stack is pointer-transparent; a revealed control is not */
340
+ }
231
341
 
232
342
  /* --- animation transport bar (generated by animation-controls.js) ----------
233
343
  PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
@@ -44,7 +44,7 @@ const WHITE = new THREE.Color(0xffffff);
44
44
  export function createCutawayGizmo({
45
45
  scene,
46
46
  overlayScene,
47
- camera,
47
+ camera: initialCamera,
48
48
  domElement,
49
49
  orbitControls,
50
50
  onPoseChange = () => {},
@@ -53,6 +53,10 @@ export function createCutawayGizmo({
53
53
  onDragChange = () => {},
54
54
  pickHandle,
55
55
  }) {
56
+ // Reassignable: the viewer swaps cameras when the projection toggle flips,
57
+ // and this module holds fifteen references to it. One binding to move beats
58
+ // threading a getter through all of them.
59
+ let camera = initialCamera;
56
60
  const sceneGraph = buildGizmoScene(THEMES.dark);
57
61
  const {
58
62
  group,
@@ -535,6 +539,7 @@ export function createCutawayGizmo({
535
539
  setActiveAppearance,
536
540
  setTheme,
537
541
  updateForCamera,
542
+ setCamera(next) { if (next) camera = next; },
538
543
  dispose,
539
544
  };
540
545
  }
@@ -32,7 +32,7 @@ function validBounds(getBounds) {
32
32
  export function createCutaway({
33
33
  renderer,
34
34
  scene,
35
- camera,
35
+ camera: initialCamera,
36
36
  orbitControls,
37
37
  domElement,
38
38
  getBounds,
@@ -40,6 +40,9 @@ export function createCutaway({
40
40
  schedule = defaultSchedule,
41
41
  now,
42
42
  }) {
43
+ // Reassignable: a projection swap (perspective <-> ortho) hands the cutaway
44
+ // a new camera after construction, and the gizmo must follow it too.
45
+ let camera = initialCamera;
43
46
  let supported = false;
44
47
  try {
45
48
  supported = Boolean(
@@ -522,6 +525,11 @@ export function createCutaway({
522
525
  updateForCamera,
523
526
  renderOverlay,
524
527
  onHandleHoverChange,
528
+ setCamera(next) {
529
+ if (!next) return;
530
+ camera = next;
531
+ gizmo?.setCamera(next);
532
+ },
525
533
  dispose,
526
534
  _renderSetFor: (name) => renderSets.get(name)?.renderSet ?? null,
527
535
  _setDragging: setDragging,
@@ -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
  }
@@ -58,6 +58,13 @@ export function worldPerPx(dist, fovDeg, viewportPx) {
58
58
  return (2 * dist * Math.tan((fovDeg * Math.PI) / 360)) / viewportPx;
59
59
  }
60
60
 
61
+ // The orthographic twin of worldPerPx. An ortho camera's scale is a property of
62
+ // its frustum and zoom alone — distance does not enter — which is exactly why
63
+ // the perspective formula cannot be reused with a substituted fov.
64
+ export function orthoWorldPerPx(top, bottom, zoom, viewportPx) {
65
+ return Math.abs(top - bottom) / Math.max(zoom, 1e-6) / Math.max(viewportPx, 1);
66
+ }
67
+
61
68
  // Kept for compatibility with earlier callers/tests: the world height that
62
69
  // renders as `targetPx` on screen.
63
70
  export function labelWorldHeight(dist, fovDeg, viewportPx, targetPx = LABEL_SCREEN_PX) {
@@ -319,8 +326,14 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
319
326
  // One shared reference distance — camera to the dim group's origin (the
320
327
  // recentred model centre) — sizes the whole drawing.
321
328
  group.getWorldPosition(_gp);
322
- const dist = viewer.camera.position.distanceTo(_gp);
323
- const wpp = worldPerPx(dist, viewer.camera.fov ?? 45, h);
329
+ // `viewer.camera.fov ?? 45` was the bug this branch removes: under an ortho
330
+ // camera fov is undefined, so the fallback produced a plausible-but-wrong
331
+ // scale and every label, arrow and standoff drifted as the user dollied.
332
+ // The ortho formula matches cutaway-gizmo.js:485's worldUnitsPerPixelAt.
333
+ const cam = viewer.camera;
334
+ const wpp = cam.isOrthographicCamera
335
+ ? orthoWorldPerPx(cam.top, cam.bottom, cam.zoom, h)
336
+ : worldPerPx(cam.position.distanceTo(_gp), cam.fov ?? 45, h);
324
337
  if (wpp > 0) {
325
338
  const hStar = LABEL_SCREEN_PX * wpp;
326
339
  const aw = ARROW_SCREEN_PX * wpp;
@@ -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();
@@ -6,7 +6,7 @@ import { attachCutawayControls } from "./cutaway-controls.js";
6
6
  import { attachRail } from "./rail.js";
7
7
  import { attachMobileTabs } from "./mobile-tabs.js";
8
8
  import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
9
- import { loadCamera } from "./view-state.js";
9
+ import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
10
10
  import { buildControls } from "./controls.js";
11
11
  import { relevantParamKeys } from "./param-deps.js";
12
12
  import { createMeshCache } from "./mesh-cache.js";
@@ -30,6 +30,7 @@ import { createMeasureMode } from "./measure/measure-mode.js";
30
30
  import { attachMeasureControls } from "./measure/measure-controls.js";
31
31
  import { createAnnotateMode } from "./annotate/annotate-mode.js";
32
32
  import { attachAnnotateControls } from "./annotate/annotate-controls.js";
33
+ import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
33
34
 
34
35
  // The mount handle, factored out so its shape is unit-testable without booting
35
36
  // the full mount() pipeline (WASM + workers + DOM).
@@ -59,7 +60,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
59
60
  // carries the worker's own error text. See the correlated "error" case below.
60
61
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
61
62
 
62
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
63
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
63
64
  return {
64
65
  ready, dispose, setParams,
65
66
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -106,6 +107,13 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
106
107
  // the built-in pencil button. send() delivers to onAnnotationSend and
107
108
  // returns false when there is no ink or the capture failed.
108
109
  annotate: annotate ?? NOOP_ANNOTATE,
110
+ // Projection is a viewer-wide display mode, not a part property — same
111
+ // shape as `measure` and `annotate` so a host reads one convention.
112
+ projection: projection ?? {
113
+ get: () => "perspective",
114
+ set: () => {},
115
+ onChange: () => () => {},
116
+ },
109
117
  };
110
118
  }
111
119
 
@@ -203,6 +211,10 @@ function createCleanupStack() {
203
211
  // // subscribes return an unsubscribe; onInkChange fires on
204
212
  // // every stroke/undo/clear, which is what a host driving its
205
213
  // // own Send button gates that button on (strokeCount() > 0).
214
+ // runtime.projection: { get, set, onChange }
215
+ // // "perspective" | "orthographic". Drives the LIVE view
216
+ // // and captureCurrent only — captureCanonicalViews,
217
+ // // renderMeshPayloads and the CLI stay perspective.
206
218
  // runtime.dispose(); // full teardown
207
219
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
208
220
  // those are repaired in the viewer and produce no build at all.
@@ -412,6 +424,55 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
412
424
  annotate: els.chrome.annotate,
413
425
  }, { tooltip, escapeScope: els.viewer, send: annotateSend });
414
426
  cleanup.defer(() => annotateChrome.detach());
427
+ // Orientation cube + projection toggle. Generated chrome — no host markup
428
+ // declares it, so an embedder gets it for free. Restored BEFORE any framing
429
+ // happens so a reload into ortho frames once instead of framing in
430
+ // perspective and then visibly re-framing.
431
+ viewer.setProjection(loadProjection());
432
+ const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
433
+ cleanup.defer(() => viewcube.detach());
434
+ cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
435
+ // setHidden takes one boolean, and there are two independent reasons to hide
436
+ // the cube: Sketch mode (below) and a crowded transport bar (wired into the
437
+ // animation controls further down). Applied straight, whichever fires last
438
+ // would win — leaving Sketch would reveal a cube that crowding still wants
439
+ // gone. Track a flag per reason and OR them through one place, the
440
+ // syncHoverSuppression precedent below.
441
+ let cubeHiddenForSketch = false;
442
+ let cubeHiddenForCrowding = false;
443
+ const syncViewcubeHidden = () =>
444
+ viewcube.setHidden(cubeHiddenForSketch || cubeHiddenForCrowding);
445
+ // Sketch freezes the view on purpose: ink is stored in screen space and is
446
+ // meaningful only against the pose it was drawn over. A live camera control
447
+ // on top of that — orbit OR a projection swap — invalidates the drawing.
448
+ if (annotateMode) {
449
+ cleanup.defer(annotateMode.onModeChange(() => {
450
+ cubeHiddenForSketch = annotateMode.isEnabled();
451
+ syncViewcubeHidden();
452
+ }));
453
+ }
454
+ // Publish the viewbar's vertical claim so chrome.css can stack the cube on
455
+ // top of it without hardcoding a height that cutaway/measure/annotate
456
+ // action rows can change.
457
+ const viewbarEl = els.viewer.querySelector("#viewbar");
458
+ if (viewbarEl && typeof ResizeObserver === "function") {
459
+ const publishViewbarClear = () => {
460
+ const stageRect = els.viewer.getBoundingClientRect();
461
+ const barRect = viewbarEl.getBoundingClientRect();
462
+ els.viewer.style.setProperty(
463
+ "--pf-viewbar-clear",
464
+ `${Math.max(0, Math.round(stageRect.bottom - barRect.top))}px`,
465
+ );
466
+ };
467
+ const viewbarObserver = new ResizeObserver(publishViewbarClear);
468
+ viewbarObserver.observe(viewbarEl);
469
+ viewbarObserver.observe(els.viewer);
470
+ publishViewbarClear();
471
+ cleanup.defer(() => {
472
+ viewbarObserver.disconnect();
473
+ els.viewer.style.removeProperty("--pf-viewbar-clear");
474
+ });
475
+ }
415
476
  // escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
416
477
  // #viewbar, not descendants of the canvas — attaching Escape to
417
478
  // viewer.domElement alone would leave a guarded Escape from those buttons
@@ -836,6 +897,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
836
897
  applyValues: applyAnimationValues,
837
898
  getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
838
899
  getView: view,
900
+ // On a narrow stage the transport bar has to cap its own width to stay
901
+ // clear of the bottom-right cluster, and under that cap its controls fall
902
+ // below the 44px tap target. The cube gives way instead — it is the
903
+ // reclaimable half of that cluster. Nothing here keys on the viewport
904
+ // width: a part with no animations, or one whose bar fits, keeps its cube
905
+ // at every size. syncViewcubeHidden and the viewcube itself are both
906
+ // declared above this point, so this can never fire into a hole.
907
+ onCrowded: (crowded) => {
908
+ cubeHiddenForCrowding = crowded;
909
+ syncViewcubeHidden();
910
+ },
839
911
  });
840
912
  if (animCtl) cleanup.defer(() => animCtl.detach());
841
913
 
@@ -944,6 +1016,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
944
1016
  onInkChange: annotateMode.onInkChange,
945
1017
  onModeChange: annotateMode.onModeChange,
946
1018
  } : null,
1019
+ projection: {
1020
+ get: () => viewer.getProjection(),
1021
+ set: (mode) => viewer.setProjection(mode),
1022
+ onChange: (cb) => viewer.onProjectionChange(cb),
1023
+ },
947
1024
  });
948
1025
  } catch (error) {
949
1026
  try {
@@ -0,0 +1,19 @@
1
+ // The perspective <-> orthographic framing pair. Pure, so the swap's only
2
+ // interesting property — that the part does not change size the instant the
3
+ // user hits the toggle — is unit-testable without a renderer.
4
+ //
5
+ // Perspective frames by DISTANCE; orthographic frames by a frustum height plus
6
+ // a zoom (OrbitControls dollies an ortho camera by changing camera.zoom, not by
7
+ // moving it). These two functions convert between the two descriptions.
8
+
9
+ const halfHeightAt = (fovDeg, distance) => distance * Math.tan((fovDeg * Math.PI) / 360);
10
+
11
+ export function orthoFrustum({ fovDeg, distance, aspect = 1 }) {
12
+ const halfH = halfHeightAt(fovDeg, distance);
13
+ const halfW = halfH * aspect;
14
+ return { halfW, halfH, left: -halfW, right: halfW, top: halfH, bottom: -halfH };
15
+ }
16
+
17
+ export function perspectiveDistance({ halfH, zoom = 1, fovDeg }) {
18
+ return halfH / (zoom * Math.tan((fovDeg * Math.PI) / 360));
19
+ }