partforge 0.71.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.
@@ -0,0 +1,367 @@
1
+ // The view cube's geometry, hit model, and projection — the pure leaf of the
2
+ // viewcube trio (cf. ink.js under annotate/, dim3-place.js under measure/).
3
+ // No DOM, no three, no node:. Everything here is plain numbers so the whole
4
+ // widget's correctness is testable without a GL context.
5
+ //
6
+ // The cube is modelled as 6 faces x a 3x3 grid = 54 surface cells. The centre
7
+ // cell of a face IS that face; the 4 edge cells belong to the 12 edges (each
8
+ // edge shows up on 2 faces); the 4 corner cells belong to the 8 corners (each
9
+ // corner shows up on 3 faces). 6 + 24 + 24 = 54 cells over 26 ids.
10
+ //
11
+ // Coordinates are MODEL space (parts are authored Z-up), so a cell's id can be
12
+ // assembled directly from the model axes it touches. PIVOT_QUAT carries the
13
+ // viewer's fixed pivot (rotation.x = -PI/2) so the drawing always agrees with
14
+ // the part on screen.
15
+
16
+ // Tuning block. Locked by the look-and-feel spike (see the plan's Task 4);
17
+ // every visual proportion the widget has lives here and nowhere else.
18
+ //
19
+ // The reshape (2026-08-19) moved the axis arrows off the cube's centre and
20
+ // onto the edge running from the corner where +X/+Y/+Z originate — model
21
+ // corner (-1,-1,-1) — to the far face. A second pass then moved the
22
+ // arrowHEAD and its label off model space entirely: the shaft now ends
23
+ // exactly ON the far face (model coordinate 1, no overshoot), and the head +
24
+ // label are built in fixed SCREEN pixels by cube-canvas.js so they read the
25
+ // same size at every rotation (see CUBE_RENDER's headLengthPx/headHalfWidthPx
26
+ // /labelGapPx). That leaves `faceHalf` as the only model-space proportion left
27
+ // to tune here — the old `arrowOvershoot`/`labelOffset`/`tailFraction` no
28
+ // longer have anything to mean and are gone.
29
+ export const CUBE_CONSTANTS = {
30
+ faceHalf: 0.62, // half-width of the centre (face) cell, cube half-extent = 1
31
+ };
32
+
33
+ // How far DOWN its canvas the drawing sits, in CSS px — the second half of the
34
+ // 2026-08-20 "lower the cube toward the viewbar" change (the first half was
35
+ // chrome.css taking most of the 8px the stack cleared #viewbar by). Kept out of
36
+ // CUBE_CONSTANTS on purpose: everything in there is a model-space proportion,
37
+ // and this is pixels.
38
+ //
39
+ // It buys room from the padding the cube already leaves inside its box, so its
40
+ // ceiling is arithmetic, not taste. `projectCube` scales the cube to
41
+ // (size/2 - outerPad) of reach from the box centre, and the renderer then paints
42
+ // headLengthPx + labelGapPx + the axis glyph past that — so the only slack that
43
+ // exists is `labelPx` minus how far the glyph actually paints past its anchor
44
+ // (about 3.2px down for `600 10px ui-sans-serif`), i.e. ~6.8px. 5 leaves nearly
45
+ // 2px of that unspent, which is the margin for a platform whose system font is
46
+ // heavier than Chromium's. Spending more clips an axis label whenever that axis
47
+ // points straight down the screen — see viewcube-mode.test.js's orientation
48
+ // sweep, which is the guard, and note that raising this WILL fail it.
49
+ //
50
+ // The cube is NOT re-centred as it turns (a bias that tracked the projected
51
+ // bounding box would win another ~14px at most poses): the cube would then bob
52
+ // up and down as you orbit, which is a worse artefact than a smaller gap.
53
+ export const CUBE_DOWN_BIAS_PX = 5;
54
+
55
+ // The viewer's pivot is rotation.x = -PI/2: quaternion (sin(-PI/4), 0, 0, cos(-PI/4)).
56
+ const HALF_SQRT2 = Math.SQRT1_2;
57
+ export const PIVOT_QUAT = [-HALF_SQRT2, 0, 0, HALF_SQRT2];
58
+
59
+ // Model axis -> face name for each sign. Mirrors FACE_DIRS in view-angles.js;
60
+ // kept here as the model-space twin so this module needs no import.
61
+ const AXIS_FACE = {
62
+ x: { 1: "right", "-1": "left" },
63
+ y: { 1: "back", "-1": "front" },
64
+ z: { 1: "top", "-1": "bottom" },
65
+ };
66
+ const AXIS_INDEX = { x: 0, y: 1, z: 2 };
67
+
68
+ // The reverse of AXIS_FACE: face name -> the axis and sign it stands for.
69
+ const FACE_AXIS = {};
70
+ for (const axis of ["x", "y", "z"]) {
71
+ for (const sign of [1, -1]) FACE_AXIS[AXIS_FACE[axis][String(sign)]] = { axis, sign };
72
+ }
73
+
74
+ // Canonical id ordering: vertical, then depth, then side (see view-angles.js).
75
+ const ORDER = { top: 0, bottom: 0, front: 1, back: 1, left: 2, right: 2 };
76
+ const idFor = (parts) => [...parts].sort((a, b) => ORDER[a] - ORDER[b]).join("-");
77
+
78
+ // The two in-plane axes for each face normal, in a fixed order so cell
79
+ // enumeration is deterministic.
80
+ const IN_PLANE = { x: ["y", "z"], y: ["x", "z"], z: ["x", "y"] };
81
+
82
+ // The direction, IN MODEL SPACE, that has to point up the screen for a face's
83
+ // NAME to read the right way round. Declared per face rather than inferred from
84
+ // a cell's corner ordering, which is the bug the 2026-08-20 fix removed: a
85
+ // basis built from corner order was only ever checked for MIRRORING, and a
86
+ // basis rotated 180 degrees is non-mirrored too, so LEFT and BACK read upside
87
+ // down (see cube-canvas.js's faceLabelBasis).
88
+ //
89
+ // These are MODEL-space vectors, because that is the frame this module's cells
90
+ // are expressed in and parts are authored Z-UP (see the file header). Do NOT
91
+ // copy view-angles.js's FACE_DIRS here: that table is Y-up because it names
92
+ // CAMERA poses in the viewer's world frame, and the two disagree.
93
+ //
94
+ // The four side faces therefore get model +Z — the model's own up.
95
+ //
96
+ // TOP and BOTTOM have no natural up: whichever in-plane direction is chosen,
97
+ // the label reads upright from some azimuths and rotated from others. The
98
+ // choice made here is the standard CAD one, and it is not arbitrary — model
99
+ // +Y / -Y are exactly the ups view-angles.js's upFor() gives the pure top and
100
+ // bottom camera poses (world [0,0,-1] / [0,0,1]). So TOP reads upright when the
101
+ // camera is above and looking from the front, BOTTOM when it is below looking
102
+ // from the front, and in particular both read upright right after you click
103
+ // that face on the cube. Away from that azimuth they read rotated. That is
104
+ // ACCEPTED, deliberately: re-choosing the axis as the camera orbits would snap
105
+ // the label through 90-degree jumps mid-drag, which is worse than a label lying
106
+ // on its side.
107
+ export const FACE_LABEL_UP = {
108
+ right: [0, 0, 1],
109
+ left: [0, 0, 1],
110
+ front: [0, 0, 1],
111
+ back: [0, 0, 1],
112
+ top: [0, 1, 0],
113
+ bottom: [0, -1, 0],
114
+ };
115
+
116
+ // The sign of a face's declared label up along that face's own local V axis —
117
+ // the in-plane axis a projected cell's (p3 - p0) edge runs along (cubeCells
118
+ // winds corners from IN_PLANE, so p3 - p0 is always +vAxis).
119
+ //
120
+ // Always exactly +1 or -1, because every vector in FACE_LABEL_UP IS one of its
121
+ // face's two in-plane axes: the side faces' up is model Z, the V axis of both
122
+ // the X and the Y face pair; TOP/BOTTOM's is model Y, the V axis of the Z pair.
123
+ // That is what lets the renderer turn its already-projected (and so already
124
+ // foreshortened) v edge into the label's up direction with one multiply,
125
+ // instead of needing a second projection of its own.
126
+ export function faceLabelUpSign(face) {
127
+ const entry = FACE_AXIS[face];
128
+ if (!entry) throw new Error(`unknown cube face "${face}"`);
129
+ const vAxis = IN_PLANE[entry.axis][1];
130
+ return Math.sign(FACE_LABEL_UP[face][AXIS_INDEX[vAxis]]);
131
+ }
132
+
133
+ // Cell bounds along one in-plane axis for grid index -1 / 0 / +1.
134
+ function span(index, half) {
135
+ if (index < 0) return [-1, -half];
136
+ if (index > 0) return [half, 1];
137
+ return [-half, half];
138
+ }
139
+
140
+ // The 54 cells, in model space. Recomputed per call rather than cached: it is a
141
+ // few hundred arithmetic ops, and a cached array would be mutable shared state
142
+ // in a module the rest of the framework expects to be pure.
143
+ export function cubeCells({ faceHalf = CUBE_CONSTANTS.faceHalf } = {}) {
144
+ const cells = [];
145
+ for (const axis of ["x", "y", "z"]) {
146
+ for (const sign of [1, -1]) {
147
+ const [uAxis, vAxis] = IN_PLANE[axis];
148
+ const n = AXIS_INDEX[axis], u = AXIS_INDEX[uAxis], v = AXIS_INDEX[vAxis];
149
+ const normal = [0, 0, 0];
150
+ normal[n] = sign;
151
+ for (const i of [-1, 0, 1]) {
152
+ for (const j of [-1, 0, 1]) {
153
+ const [u0, u1] = span(i, faceHalf);
154
+ const [v0, v1] = span(j, faceHalf);
155
+ const parts = [AXIS_FACE[axis][String(sign)]];
156
+ if (i !== 0) parts.push(AXIS_FACE[uAxis][String(Math.sign(i))]);
157
+ if (j !== 0) parts.push(AXIS_FACE[vAxis][String(Math.sign(j))]);
158
+ // Wound consistently so the projected polygon is convex in order.
159
+ const corners = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]].map(([uu, vv]) => {
160
+ const p = [0, 0, 0];
161
+ p[n] = sign;
162
+ p[u] = uu;
163
+ p[v] = vv;
164
+ return p;
165
+ });
166
+ cells.push({ id: idFor(parts), axis, sign, normal, corners });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return cells;
172
+ }
173
+
174
+ // The shared corner the three axis edges radiate from: model (-1,-1,-1), the
175
+ // vertex where +X, +Y, +Z all originate.
176
+ const AXIS_ORIGIN_CORNER = [-1, -1, -1];
177
+
178
+ // The cube's 12 edges, as model-space vertex pairs. Exactly 3 are tagged with
179
+ // the axis they carry — the ones touching AXIS_ORIGIN_CORNER and running
180
+ // toward +X / +Y / +Z — because those three are drawn as the labelled arrows;
181
+ // the other 9 are plain (untagged) and drawn as quiet cube edges instead.
182
+ //
183
+ // Each edge also carries `faceNormals`: the outward normals of the two faces
184
+ // that meet along it (an edge running along `n` at fixed (su, sv) sits on the
185
+ // boundary of the uAxis face signed `su` and the vAxis face signed `sv`).
186
+ // projectCube rotates those into view space to decide whether the edge is on
187
+ // the visible silhouette — the renderer has no other way to know which faces
188
+ // an edge belongs to without recomputing this module's geometry itself.
189
+ export function cubeEdges() {
190
+ const edges = [];
191
+ for (const axis of ["x", "y", "z"]) {
192
+ const [uAxis, vAxis] = IN_PLANE[axis];
193
+ const n = AXIS_INDEX[axis], u = AXIS_INDEX[uAxis], v = AXIS_INDEX[vAxis];
194
+ for (const su of [-1, 1]) {
195
+ for (const sv of [-1, 1]) {
196
+ const a = [0, 0, 0], b = [0, 0, 0];
197
+ a[u] = su; a[v] = sv; a[n] = -1;
198
+ b[u] = su; b[v] = sv; b[n] = 1;
199
+ // This is one of the 3 axis edges exactly when it starts at the
200
+ // shared corner (both in-plane coords at -1) and runs toward +axis.
201
+ const isAxisEdge = su === -1 && sv === -1;
202
+ const normalU = [0, 0, 0]; normalU[u] = su;
203
+ const normalV = [0, 0, 0]; normalV[v] = sv;
204
+ edges.push({ a, b, axis: isAxisEdge ? axis : null, faceNormals: [normalU, normalV] });
205
+ }
206
+ }
207
+ }
208
+ return edges;
209
+ }
210
+
211
+ // --- quaternion helpers (plain arrays, [x, y, z, w]) ------------------------
212
+ function qMul(a, b) {
213
+ return [
214
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
215
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
216
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
217
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
218
+ ];
219
+ }
220
+ const qConj = (q) => [-q[0], -q[1], -q[2], q[3]];
221
+
222
+ // An edge is on the visible silhouette when at least one of its two adjoining
223
+ // faces is camera-facing; otherwise it is hidden (it either coincides exactly
224
+ // with a silhouette edge already drawn on the near side, in an orthographic
225
+ // projection, or projects to a point). "Camera-facing" here is view-space
226
+ // normal Z > this epsilon, not > 0: at the identity quaternion four faces are
227
+ // *exactly* edge-on (Z === 0 up to float noise from the quaternion rotation),
228
+ // and the rule needs those treated as not-facing so the back face's edges and
229
+ // the front-to-back edges land on the hidden side, not the visible one. Not a
230
+ // visual tunable (nobody sweeps a numerical epsilon by eye), so it sits
231
+ // outside CUBE_CONSTANTS, same as cube-canvas.js's MIN_ARROW_DIR_PX.
232
+ const EDGE_FACE_VISIBLE_EPS = 1e-9;
233
+
234
+ function qApply(q, v) {
235
+ const [x, y, z, w] = q;
236
+ const ix = w * v[0] + y * v[2] - z * v[1];
237
+ const iy = w * v[1] + z * v[0] - x * v[2];
238
+ const iz = w * v[2] + x * v[1] - y * v[0];
239
+ const iw = -x * v[0] - y * v[1] - z * v[2];
240
+ return [
241
+ ix * w + iw * -x + iy * -z - iz * -y,
242
+ iy * w + iw * -y + iz * -x - ix * -z,
243
+ iz * w + iw * -z + ix * -y - iy * -x,
244
+ ];
245
+ }
246
+
247
+ // Project the cube as seen by a camera with world quaternion `cameraQuat`.
248
+ // Model -> world is the fixed pivot; world -> view is the camera's inverse
249
+ // rotation. The projection is deliberately ORTHOGRAPHIC regardless of the
250
+ // viewer's own projection: a widget that foreshortened would read as a bug.
251
+ //
252
+ // View space follows three.js: the camera looks down -Z, so a LARGER z is
253
+ // NEARER. Both halves are sorted ascending, which is exactly painter order.
254
+ export function projectCube(cameraQuat, {
255
+ size,
256
+ faceHalf = CUBE_CONSTANTS.faceHalf,
257
+ // A render-side pixel budget (head length + label gap + label glyph size)
258
+ // the caller may pass through so the SCALE reserves room for what gets
259
+ // drawn past the cube in fixed screen pixels — the head and label are no
260
+ // longer model-space geometry (see CUBE_CONSTANTS's comment), so the only
261
+ // thing left that can run past the box edge is those pixels. This module
262
+ // stays import-free (no reading CUBE_RENDER itself), so it defaults to 0
263
+ // and leaves supplying a real value to the caller that knows those pixel
264
+ // sizes (viewcube-mode.js).
265
+ outerPad = 0,
266
+ // Pixels to push the whole drawing DOWN inside the box (CUBE_DOWN_BIAS_PX).
267
+ // Defaults to 0 and is supplied by the caller for the same reason outerPad
268
+ // is: it is only safe to the extent the caller's own outerPad reserved room
269
+ // for it, and this module cannot see the render pixels that decide that. It
270
+ // does NOT touch the scale — the cube is the same size, it just sits lower.
271
+ downBias = 0,
272
+ } = {}) {
273
+ const toView = qMul(qConj(cameraQuat), PIVOT_QUAT);
274
+
275
+ // Scale so the drawing still fits the box at any rotation. Every cube
276
+ // vertex — and the arrow shafts now end exactly ON one (the far face, model
277
+ // coordinate 1 along the axis) — sits at the same distance from the model
278
+ // origin: sqrt(3). That is the only model-space reach left to guard;
279
+ // whatever the head/label add beyond it is screen pixels, covered by
280
+ // `outerPad` instead.
281
+ const scale = (size / 2 - outerPad) / Math.sqrt(3);
282
+ const cx = size / 2, cy = size / 2 + downBias;
283
+ const project = (p) => {
284
+ const v = qApply(toView, p);
285
+ return { xy: [cx + v[0] * scale, cy - v[1] * scale], z: v[2] };
286
+ };
287
+
288
+ const back = [], front = [];
289
+ for (const cell of cubeCells({ faceHalf })) {
290
+ const projected = cell.corners.map(project);
291
+ const normalView = qApply(toView, cell.normal);
292
+ const depth = projected.reduce((s, p) => s + p.z, 0) / projected.length;
293
+ const face = AXIS_FACE[cell.axis][String(cell.sign)];
294
+ const entry = {
295
+ id: cell.id,
296
+ points: projected.map((p) => p.xy),
297
+ depth,
298
+ face,
299
+ isCentre: cell.id === face,
300
+ };
301
+ // normalView[2] > 0 means the face's outward normal points at the camera.
302
+ (normalView[2] > 0 ? front : back).push(entry);
303
+ }
304
+ back.sort((a, b) => a.depth - b.depth);
305
+ front.sort((a, b) => a.depth - b.depth);
306
+
307
+ const backEdges = [], frontEdges = [];
308
+ for (const edge of cubeEdges()) {
309
+ const a = project(edge.a), b = project(edge.b);
310
+ const depth = (a.z + b.z) / 2;
311
+ // Rotate the edge's two adjoining face normals the same way the cell
312
+ // normals above are rotated, and hide the edge only when NEITHER faces
313
+ // the camera — see EDGE_FACE_VISIBLE_EPS for why this is "> eps" and not
314
+ // "> 0". Axis-tagged edges get this too (it costs nothing to compute) but
315
+ // the renderer ignores it for them: they draw as arrows in real depth
316
+ // order regardless, by the host's explicit choice.
317
+ const hidden = edge.faceNormals.every((n) => qApply(toView, n)[2] <= EDGE_FACE_VISIBLE_EPS);
318
+ const entry = { points: [a.xy, b.xy], axis: edge.axis, depth, hidden };
319
+ (depth >= 0 ? frontEdges : backEdges).push(entry);
320
+ }
321
+ backEdges.sort((x, y) => x.depth - y.depth);
322
+ frontEdges.sort((x, y) => x.depth - y.depth);
323
+
324
+ // The three axis arrows all start at the same corner and ride their own
325
+ // edge out to the far face (model coordinate 1 along the axis) — the whole
326
+ // edge, not a fraction of it. Everything past that point (the head, the
327
+ // label) is a screen-space add-on cube-canvas.js builds from `tip`, so
328
+ // there is no model-space geometry left to compute for them here.
329
+ const corner = project(AXIS_ORIGIN_CORNER);
330
+ // The edge runs corner-to-corner (length 2); +dir*2 from the shared corner
331
+ // lands exactly on the far face — the adjacent vertex along that axis.
332
+ const farFace = (dir) => AXIS_ORIGIN_CORNER.map((c, i) => c + dir[i] * 2);
333
+ const arrows = [["X", [1, 0, 0]], ["Y", [0, 1, 0]], ["Z", [0, 0, 1]]].map(([axis, dir]) => {
334
+ const tip = project(farFace(dir));
335
+ // The corner is the shared reference depth; the tip is where the arrow
336
+ // actually ends up, so average the two rather than picking either alone.
337
+ const depth = (corner.z + tip.z) / 2;
338
+ return { axis, from: corner.xy, tip: tip.xy, depth };
339
+ });
340
+
341
+ return { back, front, backEdges, frontEdges, arrows };
342
+ }
343
+
344
+ // Convex point-in-polygon: every cross product keeps the same sign.
345
+ function inside(px, py, points) {
346
+ let positive = false, negative = false;
347
+ for (let i = 0; i < points.length; i++) {
348
+ const [ax, ay] = points[i];
349
+ const [bx, by] = points[(i + 1) % points.length];
350
+ const cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
351
+ if (cross > 0) positive = true;
352
+ if (cross < 0) negative = true;
353
+ if (positive && negative) return false;
354
+ }
355
+ return true;
356
+ }
357
+
358
+ // Which orientation is under the cursor. Only camera-facing cells are
359
+ // candidates — you cannot click the far side of a cube — and they are tested
360
+ // nearest-first so an overlap resolves to the one actually on top.
361
+ export function hitRegion(px, py, projected) {
362
+ const cells = projected?.front ?? [];
363
+ for (let i = cells.length - 1; i >= 0; i--) {
364
+ if (inside(px, py, cells[i].points)) return cells[i].id;
365
+ }
366
+ return null;
367
+ }
@@ -0,0 +1,157 @@
1
+ // The view cube's chrome: the bottom-right stack (the cube, with the projection
2
+ // toggle over its bottom-right corner), and the visually-hidden per-view buttons
3
+ // that stand in for the DOM focus a canvas cannot give us. Generated, not
4
+ // declared — no part's HTML carries this, and partforge-cloud's scaffold does
5
+ // not either (the mobile-tabs.js and animation-controls.js precedent).
6
+ //
7
+ // The projection button deliberately lives OUTSIDE #viewbar: partforge-cloud's
8
+ // sandbox-scaffold test enumerates #viewbar's buttons against what it renders,
9
+ // and this one is the framework's own.
10
+ //
11
+ // The button used to sit in its own `.pf-viewcube-pill` card below the cube,
12
+ // borrowing #viewbar's chrome. The 2026-08-20 revision made it a small bare
13
+ // circle beside the cube instead (see chrome.css/app.css's viewcube sections),
14
+ // so the pill card — which existed only to give a single button somewhere to
15
+ // sit — is gone; the button is now a direct child of the stack.
16
+ //
17
+ // A same-day follow-up took it out of the stack's flex flow entirely and laid
18
+ // it OVER the cube's bottom-right corner. The DOM is unchanged (still a direct
19
+ // child, still after the cube's wrapper, which is what puts it on top); the
20
+ // visible consequence is that the stack is now exactly as wide as the canvas
21
+ // rather than `canvas + gap + button`, so the size it publishes below — and
22
+ // therefore the crowding decision that reads it — went from 167px to 135
23
+ // (101 below the rail's narrow breakpoint).
24
+ import { attachButtonTooltips } from "../tooltip.js";
25
+ import { runCleanupSteps } from "../teardown.js";
26
+ import { createViewcubeMode } from "./viewcube-mode.js";
27
+
28
+ const PERSPECTIVE_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l18 3v10l-18 3z"/><path d="M3 4v16"/></svg>`;
29
+ const ORTHOGRAPHIC_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="14" height="12" rx="1"/><path d="M7 6V3h14v12h-4"/></svg>`;
30
+
31
+ // One hidden button per canonical FACE view. Edges and corners are reachable by
32
+ // pointer only — six targets is a usable keyboard surface; twenty-six is a
33
+ // tab-stop thicket.
34
+ const KEY_VIEWS = [
35
+ ["front", "View from the front"],
36
+ ["back", "View from the back"],
37
+ ["left", "View from the left"],
38
+ ["right", "View from the right"],
39
+ ["top", "View from the top"],
40
+ ["bottom", "View from the bottom"],
41
+ ];
42
+
43
+ export function attachViewcubeControls(viewer, { stage } = {}, { tooltip } = {}) {
44
+ const stack = document.createElement("div");
45
+ stack.className = "pf-viewcube-stack";
46
+ stage.appendChild(stack);
47
+
48
+ const mode = createViewcubeMode(viewer, { host: stack });
49
+
50
+ const button = document.createElement("button");
51
+ button.type = "button";
52
+ button.id = "projection";
53
+ button.className = "pf-viewcube-toggle";
54
+ stack.appendChild(button);
55
+
56
+ const keys = document.createElement("div");
57
+ keys.className = "pf-viewcube-key";
58
+ const keyButtons = KEY_VIEWS.map(([view, label]) => {
59
+ const b = document.createElement("button");
60
+ b.type = "button";
61
+ b.dataset.view = view;
62
+ b.textContent = label;
63
+ b.setAttribute("aria-label", label);
64
+ keys.appendChild(b);
65
+ return b;
66
+ });
67
+ stack.appendChild(keys);
68
+
69
+ const tooltipBinding = tooltip
70
+ ? attachButtonTooltips(tooltip, [{ element: button }])
71
+ : null;
72
+
73
+ function sync() {
74
+ const ortho = viewer.getProjection() === "orthographic";
75
+ button.innerHTML = ortho ? ORTHOGRAPHIC_ICON : PERSPECTIVE_ICON;
76
+ button.classList.toggle("on", ortho);
77
+ button.setAttribute("aria-pressed", String(ortho));
78
+ const label = ortho ? "Switch to perspective view" : "Switch to orthographic view";
79
+ button.setAttribute("aria-label", label);
80
+ if (!tooltip) button.title = label;
81
+ tooltipBinding?.sync();
82
+ }
83
+
84
+ const onToggle = () => {
85
+ viewer.setProjection(viewer.getProjection() === "orthographic" ? "perspective" : "orthographic");
86
+ sync();
87
+ };
88
+ button.addEventListener("click", onToggle);
89
+
90
+ const keyHandlers = keyButtons.map((b) => {
91
+ // Same user intent as clicking the face on the canvas, so the same
92
+ // `refit` — these buttons ARE the keyboard route to that click.
93
+ const handler = () => viewer.tweenCameraTo(b.dataset.view, { duration: 0.6, refit: true });
94
+ b.addEventListener("click", handler);
95
+ return handler;
96
+ });
97
+
98
+ // A host or another mode can flip projection without going through this
99
+ // button; the chrome follows rather than drifting out of sync.
100
+ const offProjection = viewer.onProjectionChange(sync);
101
+ sync();
102
+
103
+ // Publish the stack's size on the element itself, in the data-pf-* convention
104
+ // the shell already uses (data-pf-pane). animation-controls.js reads it to
105
+ // decide whether the transport bar is crowded, and that decision has to come
106
+ // out the same whether or not the stack is on screen — otherwise hiding the
107
+ // cube un-crowds the bar, the bar un-hides the cube, and the two oscillate a
108
+ // frame at a time (see nominalClusterRect for the full argument). A
109
+ // display:none element measures all zeros, so the size cannot be read live;
110
+ // it has to have been written down.
111
+ //
112
+ // Only ever written from a REAL measured size, so the last real values survive
113
+ // a hide. They change only at the rail's narrow breakpoint, which leaves one
114
+ // stale case: a breakpoint change that happens WHILE hidden leaves the
115
+ // full-size value published. That is benign and deliberately not "fixed" — it
116
+ // is the conservative direction (it keeps the cube hidden rather than
117
+ // flickering it back), and the next real measurement corrects it.
118
+ //
119
+ // A dataset write affects no layout, so this observer cannot feed itself.
120
+ const publishSize = () => {
121
+ const { width, height } = stack.getBoundingClientRect();
122
+ if (width <= 0 || height <= 0) return;
123
+ stack.dataset.pfW = String(Math.round(width));
124
+ stack.dataset.pfH = String(Math.round(height));
125
+ };
126
+ const sizeObserver = typeof ResizeObserver === "function"
127
+ ? new ResizeObserver(publishSize) : null;
128
+ sizeObserver?.observe(stack);
129
+ publishSize(); // the observer's first callback is a frame away; the reader may not be
130
+
131
+ function setHidden(flag) {
132
+ const next = !!flag;
133
+ stack.hidden = next;
134
+ // Stand the frame subscription's work down too, not just the pixels.
135
+ mode.setHidden(next);
136
+ }
137
+
138
+ let detached = false;
139
+ return {
140
+ element: stack,
141
+ mode,
142
+ setHidden,
143
+ detach() {
144
+ if (detached) return;
145
+ detached = true;
146
+ runCleanupSteps([
147
+ offProjection,
148
+ () => sizeObserver?.disconnect(),
149
+ () => button.removeEventListener("click", onToggle),
150
+ ...keyButtons.map((b, i) => () => b.removeEventListener("click", keyHandlers[i])),
151
+ () => tooltipBinding?.detach(),
152
+ () => mode.detach(),
153
+ () => stack.remove(),
154
+ ], "viewcube control cleanup failed");
155
+ },
156
+ };
157
+ }