partforge 0.72.0 → 0.73.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.
@@ -1480,14 +1480,30 @@ stylesheet). `mount` looks up these element IDs:
1480
1480
  | `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
1481
1481
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
1482
1482
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
1483
- | `#viewbar` with `#reframe` / `#cutaway` / `#measure` / `#theme` | optional viewer controls (omit any you don't want) |
1483
+ | `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
1484
1484
  | `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
1485
- | `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
1485
+ | `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme`. A sibling of `#viewbar`, not a child of it: give it `class="pf-float-rail-toggle"` and it floats at the stage's top right |
1486
1486
 
1487
1487
  Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
1488
1488
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
1489
1489
  `"occt"` for STEP — handled for you).
1490
1490
 
1491
+ **`#reframe` is supported but no longer shipped.** The framework's own pages dropped
1492
+ the button on 2026-08-20: clicking a face, edge or corner on the view cube reframes
1493
+ too, so a separate control was one more thing in a crowded bottom-right corner. The
1494
+ wiring is untouched and fully optional — supply the button (by ID or as
1495
+ `elements.chrome.reframe`) and it works exactly as before — so a host with its own
1496
+ scaffold need change nothing.
1497
+
1498
+ **`#rail-toggle` left the viewbar on 2026-08-20.** It used to be the pill's last
1499
+ button; it now floats alone at the stage's **top right**, opposite the pill's bottom
1500
+ right, as a bare icon that grows a background on hover. Nothing in the wiring changed —
1501
+ `mount` still resolves it by id (or `elements.chrome.railToggle`), and `rail.js` still
1502
+ hides it below the 720px narrow breakpoint, where the pane tab bar takes over. A host
1503
+ with its own scaffold gets the new look by moving the button out of `#viewbar` and adding
1504
+ `class="pf-float-rail-toggle"`; leaving it inside the pill keeps the old look and still
1505
+ works.
1506
+
1491
1507
  **View control (the mount handle).** For an embedder driving the view tabs from its own UI
1492
1508
  instead of (or in addition to) the built-in `#part` bar:
1493
1509
 
@@ -1554,6 +1570,35 @@ pane's pixel size:
1554
1570
  counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
1555
1571
  for feeding a vision model, not for display; use `captureCurrent` for showcase images.
1556
1572
 
1573
+ ### `runtime.projection`
1574
+
1575
+ `{ get(), set(mode), onChange(cb) }` where `mode` is `"perspective"` or
1576
+ `"orthographic"`. Drives the **live view** and `captureCurrent` only —
1577
+ `captureCanonicalViews`, `renderMeshPayloads`, and the CLI's `partforge render`
1578
+ stay perspective unconditionally, so agent-facing output does not depend on a UI
1579
+ toggle. The choice persists across reloads under `partforge:projection` and is
1580
+ restored before the first framing. The orientation cube and its projection
1581
+ button are hidden while Sketch (annotate) mode is active, but that only governs
1582
+ *user-driven* view changes — the framework does not police programmatic ones.
1583
+ The ink is a transparent overlay and the WebGL canvas keeps rendering beneath
1584
+ it, so a host that calls `runtime.projection.set()` mid-sketch **visibly
1585
+ re-frames the 3D view underneath ink the user may still be drawing**: the
1586
+ strokes stay where they were laid down while the model shifts out from under
1587
+ them, and the sketch that gets sent is misaligned, not merely mis-labelled.
1588
+ Deliberately unguarded, the same way it's always been free to call
1589
+ `setCameraState` during Sketch.
1590
+
1591
+ ### The annotation payload's camera block
1592
+
1593
+ `onAnnotationSend(payload)` receives a `camera` block in two frames — `world`
1594
+ (replays exactly against the build that produced it) and `parts` (pinned to
1595
+ the CAD geometry, so it survives a later rebuild's bbox recentring; reread a
1596
+ sketch's camera intrinsics from `parts`, not `world`, once the model has been
1597
+ rebuilt). `ANNOTATION_VERSION` is **2**: both frames carry
1598
+ `projection: "perspective" | "orthographic"`, and under an orthographic camera
1599
+ `fov` is `null` while `orthoHeight` gives the frustum's world height instead.
1600
+ (v1 had `fov` only, and predates the projection toggle.)
1601
+
1557
1602
  **The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
1558
1603
  `class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
1559
1604
  (`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.72.0",
3
+ "version": "0.73.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",
@@ -44,6 +44,92 @@ export function planAnimBarPlacement({ stageWidth, barWidth, viewbarLeft }, { ga
44
44
  return barWidth > available ? { left, maxWidth: available } : { left };
45
45
  }
46
46
 
47
+ // A rect with no area is not a claim on the stage — it is the ABSENCE of one,
48
+ // and unioning it in would drag the union's left/top edges to 0. The concrete
49
+ // case is Sketch mode hiding the cube stack via the `hidden` property: a
50
+ // `display: none` element's getBoundingClientRect() is all zeros, which made
51
+ // the union {left: 0, top: 0} and planned the transport bar to the stage's left
52
+ // edge at max-width: 0. But a hidden ancestor is only one way to get that rect
53
+ // — an unattached element and a `content-visibility: hidden` subtree report the
54
+ // same — so the filter is on the rect's own emptiness rather than on any one
55
+ // cause of it.
56
+ const isEmptyRect = (r) => !r || r.right - r.left <= 0 || r.bottom - r.top <= 0;
57
+
58
+ // The bottom-right chrome cluster is two elements now — #viewbar with the view
59
+ // cube stacked above it — so the transport bar has to clamp against their union
60
+ // or it slides under the cube on a narrow stage. Null-tolerant because either
61
+ // element can be absent (a host that drops the viewbar; a mount before the cube
62
+ // attaches) — and empty-tolerant per the rule above, which is the same "no
63
+ // claim here" case arriving as a zero rect instead of as null.
64
+ export function unionRect(a, b) {
65
+ if (isEmptyRect(a)) return isEmptyRect(b) ? null : b;
66
+ if (isEmptyRect(b)) return a;
67
+ return {
68
+ left: Math.min(a.left, b.left),
69
+ right: Math.max(a.right, b.right),
70
+ top: Math.min(a.top, b.top),
71
+ bottom: Math.max(a.bottom, b.bottom),
72
+ };
73
+ }
74
+
75
+ // The cluster's footprint AS IF THE CUBE WERE VISIBLE — what the CROWDING
76
+ // decision is measured against, as opposed to the measured union above, which is
77
+ // what the bar is actually PLACED against. Two rects, two purposes.
78
+ //
79
+ // Deciding "is the bar crowded?" from the measured union oscillates, because the
80
+ // cube is part of that union and hiding it is the consequence of the answer:
81
+ // cube visible → wide union → bar capped → crowded → hide the cube → its rect
82
+ // is all zeros → unionRect drops it → the bar fits → not crowded → show the cube
83
+ // → round again. Two frames per cycle, each one a ResizeObserver notification,
84
+ // on screen as a flickering cube. This rect does not depend on whether the stack
85
+ // is displayed, so the answer is a fixed point: hiding the cube cannot change
86
+ // it.
87
+ //
88
+ // The stack's edges are derived from the viewbar's because chrome.css anchors
89
+ // both to the same margin: `.pf-float-viewbar` is `bottom: 12px; right: 12px`
90
+ // and `.pf-viewcube-stack` is `right: 12px` with its bottom resting on top of
91
+ // the viewbar (`--pf-viewbar-clear` + a few px). So the stack shares the
92
+ // viewbar's right edge, reaches a published width in from it, and occupies the
93
+ // band directly above the viewbar's top. That small gap between them is
94
+ // deliberately NOT subtracted: a hair taller than the truth is the safe
95
+ // direction for a "would these two collide?" test — and this rect is therefore
96
+ // unaffected the day chrome.css retunes it (2026-08-20 took it from 8px to 3).
97
+ //
98
+ // Null/empty-tolerant on exactly unionRect's terms — a zero-area rect is the
99
+ // ABSENCE of a claim, not a claim on the stage's top-left corner:
100
+ // - no viewbar rect (a host that drops #viewbar) → nothing to anchor a nominal
101
+ // stack on, so fall back to the cube's own measured rect; if that is empty
102
+ // too there is nothing to decide against at all. This one fallback is
103
+ // visibility-DEPENDENT and so not a fixed point — it is the best available
104
+ // answer without the viewbar anchor, and it only applies to a host that has
105
+ // removed the viewbar entirely.
106
+ // - no published size (no cube attached, or none ever measured) → the stack
107
+ // makes no nominal claim and the viewbar's rect stands alone.
108
+ export function nominalClusterRect(viewbarRect, cubeRect, size) {
109
+ if (isEmptyRect(viewbarRect)) return isEmptyRect(cubeRect) ? null : cubeRect;
110
+ const width = size?.width > 0 ? size.width : 0;
111
+ const height = size?.height > 0 ? size.height : 0;
112
+ if (!width || !height) return viewbarRect;
113
+ return unionRect(viewbarRect, {
114
+ left: viewbarRect.right - width,
115
+ right: viewbarRect.right,
116
+ top: viewbarRect.top - height,
117
+ bottom: viewbarRect.top,
118
+ });
119
+ }
120
+
121
+ // The stack's own size, read from where viewcube-controls.js publishes it rather
122
+ // than measured: a hidden (display:none) stack measures all zeros, and the whole
123
+ // point of the nominal rect above is to survive that. Integer CSS px in
124
+ // data-pf-w / data-pf-h; anything missing or non-positive means "nothing
125
+ // published yet".
126
+ function publishedSize(element) {
127
+ if (!element) return null;
128
+ const width = Number(element.dataset.pfW);
129
+ const height = Number(element.dataset.pfH);
130
+ return width > 0 && height > 0 ? { width, height } : null;
131
+ }
132
+
47
133
  // The scrubber's resolution: `t` is reported to the user as one of this many
48
134
  // steps, and read back the same way.
49
135
  export const SCRUB_STEPS = 1000;
@@ -97,7 +183,15 @@ function textSetter(element) {
97
183
  return (value) => { if (node.data !== value) node.data = value; };
98
184
  }
99
185
 
100
- export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues, getView }) {
186
+ export function attachAnimationControls(viewer, part, {
187
+ container, applyValues, getParamValues, getView,
188
+ // Called with `true` when the bar has run out of room beside the bottom-right
189
+ // cluster and has started capping its own width (see isCrowded below), and
190
+ // with `false` when it has not — on CHANGE only. mount uses it to stand the
191
+ // view cube down so the bar can have the space back. Defaulted, so a host or
192
+ // a test that does not care is unaffected.
193
+ onCrowded = () => {},
194
+ }) {
101
195
  // A malformed animations block must degrade to "no transport bar", never a
102
196
  // crashed mount — lint reports the specifics; the viewer just goes without.
103
197
  let byView;
@@ -522,20 +616,34 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
522
616
  syncStructure();
523
617
  syncUi();
524
618
 
525
- // --- placement: keep clear of the viewbar ---------------------------------
619
+ // --- placement: keep clear of the bottom-right cluster ---------------------
526
620
  // chrome.css centers the bar (left: 50% / translateX(-50%)), and nothing in
527
- // CSS can stop that centered position sliding under #viewbar when the stage
528
- // narrows — the viewbar's width is dynamic (cutaway's Flip/Reset appear and
529
- // disappear), so a static reservation would either overlap or waste centre
530
- // space. Measure instead: when the two bars' vertical bands intersect, clamp
531
- // the bar's left so a 10px gap to the viewbar holds, capping its width if
532
- // even the stage's 12px margin isn't enough. Overrides are inline and
533
- // cleared at the top of every pass, so chrome.css (or a host that
534
- // re-anchors either bar out of the shared band) stays authoritative the
535
- // moment the constraint stops binding. The clear-measure-apply sequence is
536
- // loop-safe: it settles within one frame, so ResizeObserver which reports
537
- // rendered sizes at frame boundaries never sees the intermediate state.
621
+ // CSS can stop that centered position sliding under the bottom-right cluster
622
+ // when the stage narrows — #viewbar's width is dynamic (cutaway's Flip/Reset
623
+ // appear and disappear), so a static reservation would either overlap or
624
+ // waste centre space. Measure instead: when the transport bar's vertical
625
+ // band intersects the CLUSTER's #viewbar unioned with the view cube stack
626
+ // sitting above it, since that stack can be taller than the viewbar alone —
627
+ // clamp the bar's left so a 10px gap holds, capping its width if even the
628
+ // stage's 12px margin isn't enough. Overrides are inline and cleared at the
629
+ // top of every pass, so chrome.css (or a host that re-anchors any of these
630
+ // elements out of the shared band) stays authoritative the moment the
631
+ // constraint stops binding. The clear-measure-apply sequence is loop-safe: it
632
+ // settles within one frame, so ResizeObserver — which reports rendered sizes
633
+ // at frame boundaries — never sees the intermediate state.
634
+ //
635
+ // A pass also decides whether the bar is CROWDED — out of room to the point
636
+ // of capping its own width, which shrinks its controls below the 44px tap
637
+ // target — and reports that through onCrowded so the cube can give way. That
638
+ // decision is measured against the NOMINAL cluster rect, never the union used
639
+ // for placement: see nominalClusterRect for why the difference is the whole
640
+ // design. It is reported LAST in the pass, after the bar has been placed, so
641
+ // the hide it may cause lands on the next frame's pass rather than
642
+ // invalidating the rects this one just measured.
538
643
  const viewbarEl = container.querySelector("#viewbar");
644
+ // Looked up lazily on every pass rather than captured once: the cube stack is
645
+ // generated by viewcube-controls.js, which may attach after this bar does.
646
+ const cubeSelector = ".pf-viewcube-stack";
539
647
  let placementRaf = 0;
540
648
  function applyPlacement() {
541
649
  placementRaf = 0;
@@ -556,12 +664,57 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
556
664
  ? 0
557
665
  : Math.max(0, Math.round(stageRect.bottom - barRect.top));
558
666
  container.style.setProperty("--pf-anim-clear", `${clear}px`);
559
- const vb = viewbarEl?.getBoundingClientRect();
560
- if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
667
+ const cubeEl = container.querySelector(cubeSelector);
668
+ const viewbarRect = viewbarEl?.getBoundingClientRect() ?? null;
669
+ const cubeRect = cubeEl?.getBoundingClientRect() ?? null;
670
+ // Every measurement of the pass is taken before anything is written, and
671
+ // the crowding verdict is one of them — it must not read rects that the
672
+ // placement below has already moved.
673
+ const crowded = isCrowded({ stageRect, barRect, viewbarRect, cubeRect, cubeEl });
674
+ placeBar(stageRect, barRect, unionRect(viewbarRect, cubeRect));
675
+ reportCrowded(crowded);
676
+ }
677
+
678
+ // Is the bar out of room? Judged against the nominal cluster rather than the
679
+ // measured union, so that hiding the cube — which is what a `true` here causes
680
+ // — cannot flip the answer back. `barRect.width` is the bar's NATURAL width:
681
+ // applyPlacement clears maxWidth/overflow/pf-squeezed before measuring, and
682
+ // getBoundingClientRect forces layout, so a previous pass's cap is never read
683
+ // back in here.
684
+ function isCrowded({ stageRect, barRect, viewbarRect, cubeRect, cubeEl }) {
685
+ // A bar that is not on screen cannot be crowded by anything — the same
686
+ // condition the --pf-anim-clear calculation above uses.
687
+ if (bar.style.display === "none") return false;
688
+ const nominal = nominalClusterRect(viewbarRect, cubeRect, publishedSize(cubeEl));
689
+ // Same vertical-band test the placement path applies, against the nominal
690
+ // rect: bands that do not intersect cannot collide, so there is nothing to
691
+ // be crowded by.
692
+ if (!nominal || barRect.top >= nominal.bottom || barRect.bottom <= nominal.top) return false;
693
+ const plan = planAnimBarPlacement({
694
+ stageWidth: stageRect.width,
695
+ barWidth: barRect.width,
696
+ viewbarLeft: nominal.left - stageRect.left,
697
+ });
698
+ // The CAP is the crowded state, not the slide: a bar that only moves
699
+ // sideways keeps its full width and its full-size controls.
700
+ return plan?.maxWidth != null;
701
+ }
702
+
703
+ let reportedCrowded = null; // null: nothing said yet, so the first pass always reports
704
+ function reportCrowded(crowded) {
705
+ if (crowded === reportedCrowded) return;
706
+ reportedCrowded = crowded;
707
+ onCrowded(crowded);
708
+ }
709
+
710
+ // The write half of a placement pass. `cluster` is the MEASURED union, which
711
+ // is what lets the bar actually reclaim the space once the cube is gone.
712
+ function placeBar(stageRect, barRect, cluster) {
713
+ if (!cluster || barRect.top >= cluster.bottom || barRect.bottom <= cluster.top) return;
561
714
  const plan = planAnimBarPlacement({
562
715
  stageWidth: stageRect.width,
563
716
  barWidth: barRect.width,
564
- viewbarLeft: vb.left - stageRect.left,
717
+ viewbarLeft: cluster.left - stageRect.left,
565
718
  });
566
719
  if (!plan) return;
567
720
  bar.style.left = `${plan.left}px`;
@@ -598,6 +751,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
598
751
  placementObserver.observe(container);
599
752
  placementObserver.observe(bar);
600
753
  if (viewbarEl) placementObserver.observe(viewbarEl);
754
+ const cubeAtSetup = container.querySelector(cubeSelector);
755
+ if (cubeAtSetup) placementObserver.observe(cubeAtSetup);
601
756
  }
602
757
  onStructureChanged = schedulePlacement; // see the hook's declaration above
603
758
  schedulePlacement();
@@ -9,7 +9,12 @@ import { createInkStore, anchorSpecs, DEFAULT_STROKE_WIDTH } from "./ink.js";
9
9
  import { createInkCanvas } from "./ink-canvas.js";
10
10
  import { raycastViewer } from "../selection/raycast.js";
11
11
 
12
- export const ANNOTATION_VERSION = 1;
12
+ // v2 added the camera block's `projection` / `orthoHeight` and made `fov`
13
+ // nullable: the viewer gained an orthographic camera, and a user can switch to
14
+ // it and THEN open Sketch. An additive optional field alone would have left any
15
+ // consumer that reconstructs the camera from `fov` silently wrong rather than
16
+ // loudly broken, so the version moves.
17
+ export const ANNOTATION_VERSION = 2;
13
18
  // Long-edge bound on BOTH pictures in the payload. The ink canvas is stage
14
19
  // sized × devicePixelRatio, so an unbounded send on a large hi-DPI display
15
20
  // hands the host a multi-megabyte pair of base64 strings — slow to encode,
@@ -85,14 +90,37 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
85
90
  // it survives the per-view bbox recentring when the model is rebuilt later.
86
91
  function cameraBlock() {
87
92
  const { pos, target } = viewer.getCameraState();
88
- const world = { pos, target, up: viewer.camera.up.toArray(), fov: viewer.camera.fov };
93
+ const cam = viewer.camera;
94
+ const ortho = !!cam.isOrthographicCamera;
95
+ const world = {
96
+ pos,
97
+ target,
98
+ up: cam.up.toArray(),
99
+ projection: ortho ? "orthographic" : "perspective",
100
+ fov: ortho ? null : cam.fov,
101
+ orthoHeight: ortho ? Math.abs(cam.top - cam.bottom) / Math.max(cam.zoom, 1e-6) : null,
102
+ };
89
103
  const parent = Object.values(viewer._subMeshes ?? {})[0]?.parent ?? null;
90
104
  if (!parent) return { world, parts: null };
91
105
  parent.updateWorldMatrix(true, false);
92
106
  const inv = parent.matrixWorld.clone().invert();
93
107
  const map = (v) => new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(inv).toArray();
94
108
  const up = new THREE.Vector3(world.up[0], world.up[1], world.up[2]).transformDirection(inv).toArray();
95
- return { world, parts: { pos: map(world.pos), target: map(world.target), up, fov: world.fov } };
109
+ return {
110
+ world,
111
+ parts: {
112
+ pos: map(world.pos),
113
+ target: map(world.target),
114
+ up,
115
+ // Camera intrinsics, not coordinates: they describe the lens, so they
116
+ // cross frames unchanged. `fov` alone would leave a consumer reading
117
+ // only this frame with a null and no way to know an ortho camera
118
+ // caused it — the exact hole ANNOTATION_VERSION 2 exists to close.
119
+ projection: world.projection,
120
+ fov: world.fov,
121
+ orthoHeight: world.orthoHeight,
122
+ },
123
+ };
96
124
  }
97
125
 
98
126
  function send() {
@@ -264,7 +264,8 @@ button.action:disabled { opacity: .5; cursor: default; }
264
264
 
265
265
  /* keyboard focus ring shared across the panel's interactive controls */
266
266
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
267
- button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible {
267
+ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
268
+ .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible {
268
269
  outline: none;
269
270
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
270
271
  }
@@ -342,11 +343,128 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
342
343
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
343
344
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
344
345
 
345
- /* At very narrow widths the full-size pill (5 icon buttons + cutaway's
346
+ /* ---- the rail toggle: a bare floating icon at the stage's top right --------
347
+ APPEARANCE only (placement lives in chrome.css, per the split at the top of
348
+ this file). Through 2026-08-20 this was the last button in #viewbar's pill;
349
+ it now floats on its own, in the corner diagonally opposite the pill.
350
+
351
+ Unadorned at rest — no background, no border, no shadow — so a control that
352
+ is always over the model reads as an icon the viewer carries rather than as a
353
+ second toolbar competing with the first. Explicitly a PLACEHOLDER ("just be a
354
+ floating icon for now"): the position is settled, the treatment is not.
355
+
356
+ 38px is the MEASURED height of the centred view selector (`#topbar .seg`:
357
+ 4px padding + a 28px tab + 4px + 2 hairlines, the same 38 on every page), and
358
+ the reference is that selector rather than #viewbar's 44px bar it briefly
359
+ matched: both floats sit at `top: 12px`, so equal heights put the two on one
360
+ line and their centres coincide by construction. Sizing to the selector is
361
+ also what keeps the hover chip from reading as oversized — the chip IS this
362
+ box. Note the trade: 38px is under the 44px tap-target floor
363
+ scripts/check-app.mjs enforces, and the check cannot see it, because it
364
+ measures targets at 320px where rail.js has already hidden this button.
365
+
366
+ A rounded rect, and deliberately NOT the view cube's projection circle: two
367
+ different controls in two different corners, where a shared silhouette would
368
+ invite reading one as the other. --pf-radius-control, the token `.seg` and its
369
+ tabs use, so the chip reads as part of the selector's family (the selector's
370
+ own outer corner is rounder — `#topbar .seg` overrides it to --pf-radius-pill).
371
+
372
+ The hover fill is WHITE, and that is a placeholder as well — asked for in as
373
+ many words, and true only over the dark viewer. Translucent rather than #fff
374
+ so it reads as a lift off the viewer instead of a hole punched in it, and so
375
+ the icon keeps its contrast on top; in the LIGHT theme a white fill is close
376
+ to invisible whatever the alpha. When this stops being a placeholder the fill
377
+ wants the --pf-surface family (--pf-surface-2, as `#viewbar button:hover`
378
+ uses) so both themes get a real surface — deliberately not substituted here.
379
+
380
+ The focus ring is NOT restated: the button joins the shared :focus-visible
381
+ selector list above. It used to inherit that through `#viewbar button`, which
382
+ no longer matches it. */
383
+ .pf-float-rail-toggle {
384
+ width: 38px; height: 38px;
385
+ border: 0; border-radius: var(--pf-radius-control);
386
+ background: transparent; box-shadow: none;
387
+ color: var(--pf-muted-2); cursor: pointer;
388
+ font-size: 15px; line-height: 1;
389
+ display: flex; align-items: center; justify-content: center;
390
+ }
391
+ /* The same trap `#viewbar button[hidden]` guards against, inherited along with
392
+ the `display: flex` above: it is author-origin and beats the UA's
393
+ `[hidden] { display: none }`, so rail.js's `toggle.hidden = narrow` would
394
+ otherwise leave this fully visible below the narrow breakpoint — where the
395
+ pane tab bar owns pane selection and a second collapse affordance must not
396
+ compete with it. scripts/check-app.mjs asserts the computed display. */
397
+ .pf-float-rail-toggle[hidden] { display: none; }
398
+ .pf-float-rail-toggle:hover { color: var(--pf-text); background: rgb(255 255 255 / 14%); }
399
+ /* GREY IN BOTH STATES, deliberately. rail.js still toggles `.on` when the rail
400
+ collapses, and inside #viewbar that class picked up the accent fill; there is
401
+ no rule for it here on purpose, so collapsed and expanded look identical. The
402
+ chevron `d` swap is the signal (it flips to point back out of the divider) and
403
+ aria-expanded carries it for assistive tech — a tint would have been a second
404
+ signal on a bare icon over the model, which is what the accent-coloured
405
+ version read as. Do not add one back without asking. */
406
+
407
+ /* ---- view cube ------------------------------------------------------------
408
+ APPEARANCE only (placement lives in chrome.css, per the split at the top of
409
+ this file).
410
+
411
+ Through 2026-08-19 the projection toggle sat in its own `.pf-viewcube-pill`
412
+ card below the cube, deliberately borrowing #viewbar's chrome so it read as
413
+ one of the viewer controls. The 2026-08-20 revision moved it beside the
414
+ cube instead, at a size and a bottom-aligned baseline that a #viewbar-style
415
+ card could not shrink to — so it is now a small bare circle in its own
416
+ right (`.pf-viewcube-toggle`), no card, no border, no shadow.
417
+
418
+ A same-day follow-up moved it again, from beside the cube to OVER its
419
+ bottom-right corner (placement in chrome.css, as ever). Nothing here
420
+ changed: a bare transparent circle was already what made that legal — it
421
+ has no card edge to cut across the drawing underneath it, so overlapping
422
+ costs the cube nothing but the 24px it covers.
423
+
424
+ A later revision that same day took the resting fill away entirely: now
425
+ that the button sits ON the cube's drawing, a persistent accent-tinted
426
+ disc in orthographic mode read as a blob over the geometry rather than as
427
+ a control. A background appears on HOVER only, in both states.
428
+
429
+ The last revision of the day took the accent COLOUR away too, so the
430
+ orthographic state now carries exactly ONE visual signal: the icon GLYPH (a
431
+ perspective frustum becomes an axonometric box). The button is grey in both
432
+ projections, resting and hovered, and there is deliberately no `.on` rule
433
+ below — viewcube-controls.js still adds the class, and `aria-pressed` still
434
+ carries the state for assistive tech. That is a choice, not an oversight: a
435
+ blue icon sitting on the cube's own drawing read as a stray tint rather than
436
+ as a mode, and the glyph is the tell that survives any colour scheme anyway.
437
+ Do not reintroduce a tint without asking. */
438
+ .pf-viewcube-canvas {
439
+ display: block;
440
+ touch-action: none;
441
+ }
442
+ .pf-viewcube-toggle {
443
+ /* 24px: the low end of "small" so the bare circle reads as understated next
444
+ to the cube rather than as a second focal point competing with it — a
445
+ card's border/background used to do that visual quieting; a transparent
446
+ shape has nothing else to lean on but size. */
447
+ width: 24px; height: 24px;
448
+ border: 0; border-radius: 50%;
449
+ background: transparent; box-shadow: none;
450
+ color: var(--pf-muted-2); cursor: pointer;
451
+ font-size: 14px; line-height: 1;
452
+ display: flex; align-items: center; justify-content: center;
453
+ }
454
+ .pf-viewcube-toggle:hover { color: var(--pf-text); background: var(--pf-surface-2); }
455
+ /* The focus ring is NOT restated here: the toggle joins the shared
456
+ :focus-visible selector list above, beside #viewbar's own.
457
+
458
+ No narrow-width shrink rule here: unlike the old pill (which borrowed
459
+ #viewbar's chrome and had to shrink in lockstep with it, below), a bare
460
+ 24px circle has no card to keep in step with a smaller viewbar — it stays
461
+ its one fixed size at every width. */
462
+
463
+ /* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
346
464
  Flip/Reset, ~314px) is wider than a small phone's viewport minus the
347
465
  stage's 12px margins, and .pf-shell { overflow: hidden } clips the excess
348
466
  off its left edge (caught by scripts/check-app.mjs's containment check).
349
- Shrink the pill instead of letting it wrap — a two-row pill would need its
467
+ Shrink the bar instead of letting it wrap — a two-row bar would need its
350
468
  own anchoring rework, while shrinking is a pure appearance change. */
351
469
  @media (max-width: 360px) {
352
470
  #viewbar { gap: 3px; }
@@ -355,7 +473,7 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
355
473
  #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button, #viewbar .pf-annotate-actions button { min-width: 44px; padding: 0 6px; }
356
474
  /* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
357
475
  measure's two-button rows at the shared size above, so it still clips the
358
- pill's left edge at 320px — shrink it further than the shared rule. */
476
+ bar's left edge at 320px — shrink it further than the shared rule. */
359
477
  #viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
360
478
  }
361
479
 
@@ -0,0 +1,84 @@
1
+ // Spherical orbit math, pure. Exists so the view cube can orbit the real camera
2
+ // by handing the viewer a pixel delta, without importing three itself.
3
+ //
4
+ // The frame is the caller's `up`, not world +Y, because that is exactly what
5
+ // OrbitControls does: it builds a quaternion taking object.up to +Y, once, at
6
+ // construction. Working in the same frame is what makes orbitBy's answers agree
7
+ // with a real drag on the canvas rather than merely resemble them.
8
+ //
9
+ // In today's viewer that `up` is always [0, 1, 0]: cameraPoseForView returns an
10
+ // `up`, but tweenCameraTo drops it and camera-tween.js interpolates
11
+ // {position, target} only, so nothing ever writes the live camera's `up` (bar
12
+ // setProjection copying it across, and the throwaway capture cameras). The
13
+ // antiparallel branch in upFrame is therefore defensive, not currently
14
+ // reachable through the viewer — it is kept, and its equivariance test with it,
15
+ // so this stays correct the day a caller does set `up`.
16
+ //
17
+ // Sign convention follows OrbitControls exactly: dragging right decreases theta,
18
+ // dragging down decreases phi.
19
+
20
+ const EPS = 1e-8;
21
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
22
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
23
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
24
+ const cross = (a, b) => [
25
+ a[1] * b[2] - a[2] * b[1],
26
+ a[2] * b[0] - a[0] * b[2],
27
+ a[0] * b[1] - a[1] * b[0],
28
+ ];
29
+ const length = (a) => Math.hypot(a[0], a[1], a[2]);
30
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
31
+
32
+ // Rodrigues rotation of v about unit axis k by angle t.
33
+ function rotate(v, k, t) {
34
+ const c = Math.cos(t), s = Math.sin(t);
35
+ const kv = cross(k, v);
36
+ const kd = dot(k, v) * (1 - c);
37
+ return [
38
+ v[0] * c + kv[0] * s + k[0] * kd,
39
+ v[1] * c + kv[1] * s + k[1] * kd,
40
+ v[2] * c + kv[2] * s + k[2] * kd,
41
+ ];
42
+ }
43
+
44
+ // A pair of functions taking `up` onto +Y and back again.
45
+ function upFrame(up) {
46
+ const l = length(up);
47
+ if (l < EPS) return { to: (v) => v, from: (v) => v };
48
+ const u = [up[0] / l, up[1] / l, up[2] / l];
49
+ const axis = cross(u, [0, 1, 0]);
50
+ const al = length(axis);
51
+ if (al < EPS) {
52
+ // Parallel (already +Y) or antiparallel (upside down): a half turn about X.
53
+ if (dot(u, [0, 1, 0]) > 0) return { to: (v) => v, from: (v) => v };
54
+ const k = [1, 0, 0];
55
+ return { to: (v) => rotate(v, k, Math.PI), from: (v) => rotate(v, k, Math.PI) };
56
+ }
57
+ const k = [axis[0] / al, axis[1] / al, axis[2] / al];
58
+ const angle = Math.acos(clamp(dot(u, [0, 1, 0]), -1, 1));
59
+ return { to: (v) => rotate(v, k, angle), from: (v) => rotate(v, k, -angle) };
60
+ }
61
+
62
+ export function orbitPose(
63
+ { position, target, up = [0, 1, 0] },
64
+ { dx = 0, dy = 0 } = {},
65
+ { radiansPerPx = 0.005, minPolar = 1e-4, maxPolar = Math.PI - 1e-4 } = {},
66
+ ) {
67
+ const offset = sub(position, target);
68
+ const radius = length(offset);
69
+ // A camera sitting exactly on its target has no orbit to speak of; returning
70
+ // the pose untouched beats emitting NaN.
71
+ if (radius < EPS) return { position: [...position], target: [...target] };
72
+
73
+ const frame = upFrame(up);
74
+ const o = frame.to(offset);
75
+ const theta = Math.atan2(o[0], o[2]) - dx * radiansPerPx;
76
+ const phi = clamp(Math.acos(clamp(o[1] / radius, -1, 1)) - dy * radiansPerPx, minPolar, maxPolar);
77
+ const sinPhi = Math.sin(phi);
78
+ const next = [
79
+ radius * sinPhi * Math.sin(theta),
80
+ radius * Math.cos(phi),
81
+ radius * sinPhi * Math.cos(theta),
82
+ ];
83
+ return { position: add(target, frame.from(next)), target: [...target] };
84
+ }
@@ -1,18 +1,30 @@
1
1
  import * as THREE from "three";
2
2
  import { EASINGS } from "./animation.js";
3
3
 
4
- // Retargetable orbit-camera tween for animation camera cues: eased spherical
5
- // interpolation of {position, target} pairs about the (linearly moving) orbit
6
- // target, shortest-path in azimuth, clamped off the poles so OrbitControls
7
- // never gimbal-locks on a "top"/"bottom" cue. Pure math, no clock the viewer
8
- // feeds dt seconds into update() each frame and applies the returned pose.
9
- const POLE_EPS = 0.01;
10
-
4
+ // Retargetable orbit-camera tween for animation camera cues and view-cube
5
+ // clicks: eased spherical interpolation of {position, target} pairs about the
6
+ // (linearly moving) orbit target, shortest-path in azimuth. Pure math, no clock
7
+ // the viewer feeds dt seconds into update() each frame and applies the
8
+ // returned pose.
9
+ //
10
+ // NOT clamped off the poles. It used to be, by 0.01 rad, to keep OrbitControls
11
+ // off its gimbal — but the clamp applied to the DESTINATION too, so a "top" or
12
+ // "bottom" cue landed 0.573° short of the axis every single time, in both
13
+ // projections. That is the whole of the "clicking top doesn't view from the top"
14
+ // bug: a spacer kept a sliver of side wall visible instead of reading as a flat
15
+ // outline.
16
+ //
17
+ // Landing exactly on the pole is safe, because OrbitControls' own update() is
18
+ // the backstop: Spherical.makeSafe() holds phi off 0 and PI by 1e-6 rad (a
19
+ // 5.7e-5° tilt, three orders of magnitude under what the eye or a frustum can
20
+ // resolve), so the camera is never left with a degenerate lookAt basis and the
21
+ // roll stays deterministic. Azimuth at the pole is a no-op either way, and
22
+ // atan2(0, 0) is 0 rather than NaN — which is also the azimuth the canonical
23
+ // top/bottom poses ask for, so the roll OrbitControls derives there is exactly
24
+ // the `up` view-angles.js names for them.
11
25
  function toSpherical(position, target) {
12
26
  const off = new THREE.Vector3().fromArray(position).sub(new THREE.Vector3().fromArray(target));
13
- const sph = new THREE.Spherical().setFromVector3(off);
14
- sph.phi = Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, sph.phi));
15
- return sph;
27
+ return new THREE.Spherical().setFromVector3(off);
16
28
  }
17
29
 
18
30
  export function createCameraTween() {