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.
- package/docs/AUTHORING-PARTS.md +47 -2
- package/package.json +1 -1
- package/src/framework/animation-controls.js +171 -16
- package/src/framework/annotate/annotate-mode.js +31 -3
- package/src/framework/app.css +122 -4
- package/src/framework/camera-orbit.js +84 -0
- package/src/framework/camera-tween.js +22 -10
- package/src/framework/chrome.css +113 -3
- package/src/framework/cutaway-gizmo.js +6 -1
- package/src/framework/cutaway.js +9 -1
- package/src/framework/measure/dim3-place.js +77 -8
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/measure/measure-mode.js +80 -15
- package/src/framework/mount.js +79 -2
- package/src/framework/projection.js +19 -0
- package/src/framework/view-angles.js +69 -1
- package/src/framework/view-state.js +9 -0
- package/src/framework/viewcube/cube-canvas.js +410 -0
- package/src/framework/viewcube/cube-geom.js +367 -0
- package/src/framework/viewcube/viewcube-controls.js +157 -0
- package/src/framework/viewcube/viewcube-mode.js +201 -0
- package/src/framework/viewer.js +289 -23
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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 `#
|
|
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
|
@@ -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, {
|
|
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
|
|
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
|
|
528
|
-
// narrows —
|
|
529
|
-
// disappear), so a static reservation would either overlap or
|
|
530
|
-
// space. Measure instead: when the
|
|
531
|
-
// the
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
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
|
|
560
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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() {
|
package/src/framework/app.css
CHANGED
|
@@ -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
|
-
/*
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
-
// interpolation of {position, target} pairs about the
|
|
6
|
-
// target, shortest-path in azimuth
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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
|
-
|
|
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() {
|