@dailies-studio/shot-editor 0.1.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.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,3423 @@
1
+ // The stage's React tree: the working area, `ShotRuntime` inside it, and the
2
+ // overlay on top.
3
+ //
4
+ // ── THE WORKING AREA ────────────────────────────────────────────────────
5
+ //
6
+ // The canvas fills the whole viewport and the CAMERA carries the pan and zoom,
7
+ // through `setViewOffset` (see view.ts). Two consequences worth stating:
8
+ //
9
+ // 1. You can work OFF-CANVAS. The render is wider than the exportable frame,
10
+ // and the frame's own rectangle is derived from the same numbers as the
11
+ // offset — so the bright border sits exactly on the pixels that will be
12
+ // exported rather than approximately on them.
13
+ //
14
+ // 2. Zooming RE-RENDERS instead of magnifying. A CSS `transform: scale()`
15
+ // would have been fewer lines and would have made every zoomed-in frame
16
+ // soft, in a tool whose entire job is judging pixels.
17
+ //
18
+ // The layer RASTERS follow the zoom too, so text is cut for the pixels it is
19
+ // actually shown at.
20
+ //
21
+ // ── TWO PANES, ONE CANVAS, ONE SET OF NUMBERS ───────────────────────────
22
+ //
23
+ // `paneRects` divides the canvas and EVERY consumer reads that one answer: the
24
+ // renderer scissors to it, `viewOffsetFor` measures the camera pane rather than
25
+ // the canvas, the overlay translates into it, and `paneAt` routes the pointer
26
+ // by it. A divider the renderer drew to and the pointer tested against
27
+ // separately would be off by a column at some widths, and the symptom is
28
+ // "clicking near the middle selects the wrong thing" rather than anything that
29
+ // looks like a layout bug.
30
+ //
31
+ // COORDINATES CROSS AS PANE-LOCAL. `paneAt` returns the pane and the local
32
+ // point together, and that pair is passed straight through to the feed. There
33
+ // is no second place where a pane origin is subtracted, which is the only way
34
+ // to be sure it is never subtracted twice.
35
+ //
36
+ // THE STAGE TAKES OVER THE PRESENT ONLY WHEN IT HAS TO. In camera mode
37
+ // `ShotRuntime` renders itself exactly as it does in an export, untouched;
38
+ // `SplitRenderer` mounts and `renderControl` flips to "external" only when a
39
+ // second pane is on screen. That is deliberate: the single-pane path is the one
40
+ // the export shares, and the cheapest way to keep it identical is to not
41
+ // reimplement it.
42
+ import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
43
+ import { ShotRuntime, hasPostChain, quantizeRasterScale } from "@dailies-studio/shot";
44
+ import {
45
+ DEFAULT_KEY_DIR,
46
+ MIN_INVERTIBLE_SCALE,
47
+ binsAtCached,
48
+ cameraRigAtCached,
49
+ componentDefsOfCached,
50
+ instanceComponentIdsOf,
51
+ masterToSceneCached,
52
+ sceneHasMeshCached,
53
+ transitionAtCached,
54
+ withDescendants,
55
+ } from "@dailies-studio/shot/doc";
56
+ import type { Doc3D, RenderBin, SnapGuide } from "@dailies-studio/shot/doc";
57
+ import { Vector3 } from "three";
58
+ import type { Plane, Ray } from "three";
59
+ import { Overlays, modalReadoutSink } from "./overlays/Overlays.js";
60
+ import type { OverlayProps, ScreenKeyLight, ScreenMarquee, ScreenModal } from "./overlays/Overlays.js";
61
+ import { FrustumGizmo } from "./FrustumGizmo.js";
62
+ import { GridPlane } from "./GridPlane.js";
63
+ import { MoveGizmo } from "./MoveGizmo.js";
64
+ import { SplitRenderer } from "./SplitRenderer.js";
65
+ import { StageTools } from "./StageTools.js";
66
+ import { createStageFeed } from "./feed.js";
67
+ import type { Grab, StageFeed } from "./feed.js";
68
+ import { PERF_HUD_ENABLED, PerfHud, PerfProbe, createPerfBox } from "./PerfHud.js";
69
+ import { resolveInstanceModules } from "./instanceModules.js";
70
+ import type { InstanceResolution } from "./instanceModules.js";
71
+ import { quadMoved, toScreen } from "./project.js";
72
+ import type { ScreenQuad, WorldQuad } from "./project.js";
73
+ import { quadCentre } from "./handles.js";
74
+ import { paneAt, paneRects } from "./panes.js";
75
+ import type { PaneRect, StagePane } from "./panes.js";
76
+ import { isolationExtent, isolationEye, isolationFrame } from "./isolation.js";
77
+ import {
78
+ DOLLY_FALLOFF_PX,
79
+ WORKING_FOV_DEG,
80
+ defaultWorkingView,
81
+ dollyBounds,
82
+ dollyBy,
83
+ orbitBy,
84
+ panWorkingBy,
85
+ workingScreenScale,
86
+ workingViewForIsolation,
87
+ } from "./workingView.js";
88
+ import type { WorkingView } from "./workingView.js";
89
+ import { axisParam, framingArmDelta, keyDirOnScreen, navBalls, rotateKeyDir, viewForBall } from "./gizmoMath.js";
90
+ import type { GizmoGrab, NavBall, Vec3 } from "./gizmoMath.js";
91
+ import { frustumShape } from "./frustumGeometry.js";
92
+ import { isPanGesture, panBy, wheelToView } from "./interactions/panZoom.js";
93
+ import { designDelta, designPoint, gesturePlane, unionScale } from "./interactions/move.js";
94
+ import type { DesignBox } from "./interactions/move.js";
95
+ import { clampZoom, designToScreen, fitView, frameRect, screenToDesign, viewOffsetFor } from "./view.js";
96
+ import type { FrameSize } from "./view.js";
97
+ import type { StageClient, StageClientState } from "./bridgeClient.js";
98
+ import { isDrawTool, specOf } from "../tools.js";
99
+ import type { DrawTool } from "../tools.js";
100
+ import { resolveKey } from "../state/keymap.js";
101
+ import {
102
+ PEN_CLOSE_SLOP_PX,
103
+ PEN_DRAG_THRESHOLD_PX,
104
+ PEN_EMPTY,
105
+ penCloses,
106
+ penData,
107
+ penReduce,
108
+ } from "./penState.js";
109
+ import type { PenState } from "./penState.js";
110
+ import {
111
+ TILT_DEG_PER_PX,
112
+ advanceVirtual,
113
+ axisRefusal,
114
+ framingDeltaFor,
115
+ initialGestureKind,
116
+ layerGestureFor,
117
+ modalAxisToggle,
118
+ modalLabel,
119
+ modalReadout,
120
+ modalTarget,
121
+ typeNumber,
122
+ typedValue,
123
+ PRECISION_FACTOR,
124
+ } from "./modalState.js";
125
+ import type { ModalMode, ModalState } from "./modalState.js";
126
+ import type { ShotView, WorkingViewOrigin } from "../bridgeTypes.js";
127
+
128
+ /**
129
+ * Below this a press-and-release is a CLICK, not a drag. Without it every click
130
+ * that moved one pixel would clear the selection instead of setting it — and,
131
+ * once the draw lane existed, would insert a two-pixel rectangle instead of one
132
+ * of a usable size.
133
+ *
134
+ * ONE threshold for both lanes on purpose: "did the pointer travel" is the same
135
+ * question, and two numbers would make a gesture a marquee and a click at the
136
+ * same time.
137
+ */
138
+ const DRAG_THRESHOLD_PX = 3;
139
+
140
+ /** What the wheel lane actually reads off a WheelEvent — a plain shape, so the
141
+ * rAF coalescer can hand the handler one batch with summed deltas. */
142
+ interface WheelLike {
143
+ clientX: number;
144
+ clientY: number;
145
+ deltaX: number;
146
+ deltaY: number;
147
+ deltaMode: number;
148
+ ctrlKey: boolean;
149
+ metaKey: boolean;
150
+ /** Shift turns a vertical wheel into a horizontal one, which is how a mouse
151
+ * reaches the pan a trackpad gets from `deltaX`. */
152
+ shiftKey: boolean;
153
+ }
154
+
155
+ /** What an unisolated layer fades to. Low enough that the subject is obviously
156
+ * the subject, high enough that the surroundings are still context to align
157
+ * against — which is the whole reason they are drawn at all rather than
158
+ * hidden. */
159
+ const DIM_OPACITY = 0.18;
160
+
161
+ // TILT_DEG_PER_PX moved to modalState.ts — one definition for the drag lane
162
+ // and the modal lane; imported above.
163
+
164
+ /**
165
+ * The design z = 0 plane, which is the plane the camera's framed rectangle sits
166
+ * in and therefore the one a frustum drag unprojects onto.
167
+ *
168
+ * Built through `gesturePlane` from an IDENTITY matrix rather than constructed
169
+ * here, so the frustum drag and a layer's "move it on the canvas" drag are the
170
+ * same plane maths from the same file. `gesturePlane` takes anything with an
171
+ * `elements` array, which is also what keeps this line free of a three import
172
+ * it would otherwise need for `Matrix4`.
173
+ */
174
+ const Z0_PLANE = gesturePlane("frame", {
175
+ elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
176
+ });
177
+
178
+ /** Nothing on screen. Hoisted so the prop is referentially stable. */
179
+ const EMPTY_BINS: readonly RenderBin[] = Object.freeze([]);
180
+
181
+ /** A live modal transform's imperative half — held in a REF, not state:
182
+ * mouse motion drives it per pointer event and must not re-render the
183
+ * stage. The pure half (what each mode+axis emits) is modalState.ts; the
184
+ * pill is the only thing React is told about. Coordinates are PANE-LOCAL
185
+ * CSS px, which is what `feed.rayAt` measures against. */
186
+ /**
187
+ * The four bidirectional resize cursors, in the order `resizeCursorFor` indexes
188
+ * them: east, south-east, south, south-west. Each is its own opposite, which is
189
+ * why four names cover eight handles.
190
+ */
191
+ const RESIZE_CURSORS = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"] as const;
192
+
193
+ /**
194
+ * A handle's screen direction, as a cursor.
195
+ *
196
+ * ── WHY AN ANGLE AND NOT A NAME ─────────────────────────────────────────
197
+ *
198
+ * The selection box is a PROJECTED QUAD, not an axis-aligned rectangle. Roll a
199
+ * layer 45 degrees and its "nw" corner points due north; look at it from the
200
+ * working pane's orbit and it can point anywhere at all. A cursor chosen from
201
+ * the handle's NAME would sit at up to 45 degrees to the edge the author is
202
+ * about to drag, which is worse than no cursor because it is confidently wrong.
203
+ *
204
+ * ── AND WHY FOUR BUCKETS RATHER THAN A DRAWN CURSOR ─────────────────────
205
+ *
206
+ * CSS has exactly these four bidirectional resize cursors and no way to rotate
207
+ * one. Snapping to the nearest is correct to within 22.5 degrees, which is what
208
+ * Figma does and what nobody has ever noticed. A data-URI SVG cursor rotated to
209
+ * the exact angle is possible and is deliberately NOT built here: it is a
210
+ * per-frame image encode for an error nobody can see. The rotate ring is where
211
+ * that cost earns its keep, because CSS has no rotate cursor at all.
212
+ *
213
+ * `angle` is measured in the overlay's y-DOWN space (see `Grab.handleAngle`),
214
+ * so bucket 1 is down-and-right, which reads as the ↖↘ diagonal: `nwse`.
215
+ */
216
+ /** Ctrl quantises a pointer roll, Blender's own 15 degrees. */
217
+ const ROLL_SNAP_DEG = 15;
218
+
219
+ /**
220
+ * The rotate cursor, as a data-URI SVG rotated to the ring's own angle.
221
+ *
222
+ * ── THE ONE PLACE A DRAWN CURSOR EARNS ITS KEEP ─────────────────────────
223
+ *
224
+ * CSS has four bidirectional resize cursors and NO rotate cursor at all, so
225
+ * unlike `resizeCursorFor` there is nothing to snap to. Figma and Framer both
226
+ * draw one, and without it the ring is a band of empty space that silently
227
+ * behaves differently from the space beside it.
228
+ *
229
+ * QUANTISED TO 15 DEGREES and cached, so a sweep round a box mints 24 strings
230
+ * over its whole life rather than one per pointermove. The eye cannot see the
231
+ * difference; the allocation is real.
232
+ */
233
+ const ROTATE_CURSORS = new Map<number, string>();
234
+ export function rotateCursorFor(angle: number): string {
235
+ if (!Number.isFinite(angle)) return "pointer";
236
+ const step = Math.round((angle * 180) / Math.PI / ROLL_SNAP_DEG) * ROLL_SNAP_DEG;
237
+ const cached = ROTATE_CURSORS.get(step);
238
+ if (cached !== undefined) return cached;
239
+ // Two black-outlined white arrowheads on an arc, which is the shape every
240
+ // design tool uses and the only one that reads at 24px.
241
+ const svg =
242
+ `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
243
+ `<g transform="rotate(${step} 12 12)" fill="none" stroke="#000" stroke-width="3.2" ` +
244
+ `stroke-linecap="round" stroke-linejoin="round">` +
245
+ `<path d="M5 9a7.6 7.6 0 0 1 14 0"/><path d="M19 4v5h-5"/></g>` +
246
+ `<g transform="rotate(${step} 12 12)" fill="none" stroke="#fff" stroke-width="1.6" ` +
247
+ `stroke-linecap="round" stroke-linejoin="round">` +
248
+ `<path d="M5 9a7.6 7.6 0 0 1 14 0"/><path d="M19 4v5h-5"/></g></svg>`;
249
+ const value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, pointer`;
250
+ ROTATE_CURSORS.set(step, value);
251
+ return value;
252
+ }
253
+
254
+ export function resizeCursorFor(angle: number): string {
255
+ if (!Number.isFinite(angle)) return "pointer";
256
+ const k = Math.round(angle / (Math.PI / 4));
257
+ return RESIZE_CURSORS[((k % 4) + 4) % 4]!;
258
+ }
259
+
260
+ /**
261
+ * What a press at a point would do, as data.
262
+ *
263
+ * `"empty"` is what a marquee, an orbit or a deselecting click all start from,
264
+ * and the three are told apart by the BUTTON and the MODIFIERS, which this
265
+ * union deliberately knows nothing about — those belong to the press lane, and
266
+ * a hover has neither.
267
+ */
268
+ type StageIntent =
269
+ | { kind: "arm"; arm: GizmoGrab }
270
+ | { kind: "grab"; grab: Grab }
271
+ | { kind: "frustum"; part: "apex" | "body" }
272
+ | { kind: "empty" };
273
+
274
+ interface ModalLive {
275
+ state: ModalState;
276
+ rect: PaneRect;
277
+ anchor: readonly [number, number];
278
+ virtual: readonly [number, number];
279
+ lastRaw: readonly [number, number];
280
+ /** The anchor ray and unprojection plane, captured at start — move modes
281
+ * only; rotate and scale are screen-space and need neither. */
282
+ ray: Ray | null;
283
+ plane: Plane | null;
284
+ screenScale: number;
285
+ /** The world DESIGN point a scale grows away from — the selection's median —
286
+ * or null for a single layer, which scales in place. Snapshotted at the
287
+ * start so the pivot cannot walk while the layers move around it. */
288
+ pivot: readonly [number, number, number] | null;
289
+ /**
290
+ * A one-shot message the pill shows INSTEAD of the numbers, or null.
291
+ *
292
+ * For the axis keys the mapping table claims and ignores (`S` then `Z`, any
293
+ * axis during a camera zoom, `Z` during a camera move). The listener has
294
+ * already called `preventDefault`, so without this the key is swallowed and
295
+ * nothing anywhere distinguishes that from a key that never registered.
296
+ *
297
+ * Cleared by the next axis press that IS honoured, AND by any real pointer
298
+ * motion — a refusal describes a KEY, and the author moving the mouse is the
299
+ * author moving on. Without the second clause the message sat over a
300
+ * transform that was still changing while the live numbers never came back.
301
+ */
302
+ refusal: string | null;
303
+ /**
304
+ * The numeric buffer: `G X 200 Enter`.
305
+ *
306
+ * A STRING and not a number, because `"1."`, `"-"` and `""` are all real
307
+ * mid-typing states that no number can represent — and a bare `-` parsed as
308
+ * `-0` would snap the layer to the origin the moment the author pressed it.
309
+ * `typedValue` is the one place that decides when it means something.
310
+ *
311
+ * Emptying it hands the transform back to the mouse, which is what makes a
312
+ * mistyped digit recoverable without cancelling the whole gesture.
313
+ */
314
+ typed: string;
315
+ /** CTRL is held: quantise. Shift's partner, tracked here for the same reason
316
+ * shift is read per event — it can change mid-gesture. */
317
+ snap: boolean;
318
+ }
319
+
320
+ /** Nothing snapping — the overwhelmingly common case, hoisted so "no guides"
321
+ * is the same array on every render and cannot break the overlay's memo. */
322
+ const EMPTY_GUIDES: OverlayProps["guides"] = [];
323
+
324
+ /**
325
+ * The overlay, with its OWN repaint subscription.
326
+ *
327
+ * This is the leaf `feed.onOverlay` re-renders. When the bump lived on
328
+ * `StageApp` (`bumpOverlay`), a camera dolly with a selection cost two full
329
+ * stage commits per frame — one for the dolly, one when the quads moved — and
330
+ * each commit is a GL draw under `frameloop="never"`. Here, a moved quad
331
+ * repaints one SVG.
332
+ */
333
+ function OverlaysHost({
334
+ feed,
335
+ designGuides,
336
+ size,
337
+ keyLightArm,
338
+ ...rest
339
+ }: {
340
+ feed: StageFeed;
341
+ /** The guides as the STORE sends them: one design-frame coordinate on one
342
+ * axis, origin at the canvas centre. The camera pane's screen numbers are
343
+ * derived by the caller; the working pane's segments are derived here,
344
+ * because only this half is re-rendered when the camera moves. */
345
+ designGuides: readonly SnapGuide[];
346
+ size: FrameSize;
347
+ /**
348
+ * The key-light arm's DIRECTION and its grab handler, or null.
349
+ *
350
+ * Its screen ANCHOR is derived here rather than passed, for `guidesWorking`'s
351
+ * reason: the layer's quad is rewritten by the in-canvas half every frame and
352
+ * no dependency array can see it move, so it has to be read where the repaint
353
+ * already happens.
354
+ */
355
+ keyLightArm: { layerId: string; dir: readonly [number, number] | null; onGrab: (e: React.PointerEvent) => void } | null;
356
+ } & Omit<
357
+ OverlayProps,
358
+ | "quadsCamera"
359
+ | "quadsWorking"
360
+ | "locked"
361
+ | "guidesWorking"
362
+ | "unionCamera"
363
+ | "unionWorking"
364
+ | "keyLight"
365
+ >) {
366
+ const [, bump] = useReducer((n: number) => n + 1, 0);
367
+ useEffect(() => {
368
+ feed.onOverlay = bump;
369
+ return () => {
370
+ feed.onOverlay = null;
371
+ };
372
+ }, [feed]);
373
+ return (
374
+ <MemoOverlays
375
+ {...rest}
376
+ quadsCamera={feed.quadsCamera}
377
+ quadsWorking={feed.quadsWorking}
378
+ locked={feed.locked}
379
+ unionCamera={feed.union?.camera ?? null}
380
+ unionWorking={feed.union?.working ?? null}
381
+ keyLight={keyLightAnchor(keyLightArm, feed)}
382
+ guidesWorking={projectGuides(designGuides, feed, rest.panes.working, size)}
383
+ />
384
+ );
385
+ }
386
+
387
+ /**
388
+ * The key-light handle, anchored on its layer's quad.
389
+ *
390
+ * Null when the layer has no quad in the working pane this frame — which is
391
+ * every frame outside its lifespan, and every frame it is behind the camera.
392
+ * Drawing a handle for a layer that is not on screen would put a sun in empty
393
+ * space and let the author drag the shading of something they cannot see.
394
+ */
395
+ function keyLightAnchor(
396
+ arm: { layerId: string; dir: readonly [number, number] | null; onGrab: (e: React.PointerEvent) => void } | null,
397
+ feed: StageFeed,
398
+ ): ScreenKeyLight | null {
399
+ if (arm === null) return null;
400
+ const index = feed.ids.indexOf(arm.layerId);
401
+ const quad = index < 0 ? undefined : feed.quadsWorking[index];
402
+ if (quad === undefined) return null;
403
+ return { at: quadCentre(quad), dir: arm.dir, onGrab: arm.onGrab };
404
+ }
405
+
406
+ /**
407
+ * A design-plane guide line, as a screen SEGMENT in the working pane.
408
+ *
409
+ * ── WHY IT IS HERE AND NOT IN A `useMemo` ON `StageApp` ─────────────────
410
+ *
411
+ * `feed.workingCamera` is a mutable object the in-canvas half rewrites every
412
+ * frame, so no dependency array can see it move. `OverlaysHost` re-renders on
413
+ * `feed.onOverlay`, which fires exactly when a projected quad moved — which is
414
+ * every frame of the drag these guides exist during. So this is computed where
415
+ * the repaint already happens, and nowhere else.
416
+ *
417
+ * Cheap by construction: guides are empty except mid-snap, and the common case
418
+ * returns the same frozen array every time so the memo below still bails.
419
+ *
420
+ * The endpoints span TWICE the canvas, so the line runs past the artwork the
421
+ * way the camera pane's full-height rule does rather than stopping at an edge
422
+ * that would read as part of the geometry.
423
+ */
424
+ function projectGuides(
425
+ guides: readonly SnapGuide[],
426
+ feed: StageFeed,
427
+ rect: PaneRect | null,
428
+ size: FrameSize,
429
+ ): readonly { a: readonly [number, number]; b: readonly [number, number] }[] {
430
+ const camera = feed.workingCamera;
431
+ if (guides.length === 0 || rect === null || camera === null) return EMPTY_SEGMENTS;
432
+ const out: { a: readonly [number, number]; b: readonly [number, number] }[] = [];
433
+ for (const g of guides) {
434
+ // Design (dx, dy) with the origin at the canvas centre, and the one mapping
435
+ // applied on the way out: world = (x, -y, -z).
436
+ const span = g.axis === "x" ? size.height : size.width;
437
+ const from = g.axis === "x" ? [g.at, span] : [-span, -g.at];
438
+ const to = g.axis === "x" ? [g.at, -span] : [span, -g.at];
439
+ out.push({
440
+ a: toScreen(_guideA.set(from[0]!, from[1]!, 0), camera, rect.width, rect.height),
441
+ b: toScreen(_guideB.set(to[0]!, to[1]!, 0), camera, rect.width, rect.height),
442
+ });
443
+ }
444
+ return out;
445
+ }
446
+
447
+ const EMPTY_SEGMENTS: readonly { a: readonly [number, number]; b: readonly [number, number] }[] = [];
448
+ const _guideA = new Vector3();
449
+ const _guideB = new Vector3();
450
+
451
+ /**
452
+ * The SVG bails unless a prop actually changed. StageApp re-renders on every
453
+ * seek and every gesture frame, and the overlay's props are stable across a
454
+ * seek by construction: the quads arrive as FRESH ARRAYS from StageTools
455
+ * exactly when a quad moved (feed.ts), the frame rect and the guides are
456
+ * memoized by the caller, and everything else is state. So the memo turns
457
+ * "the overlay re-renders whenever the stage does" into "the overlay
458
+ * re-renders when something it draws moved", which is what the quad gate in
459
+ * StageTools was already promising.
460
+ */
461
+ const MemoOverlays = memo(Overlays);
462
+
463
+ export function StageApp({ client }: { client: StageClient }) {
464
+ const state = useStageState(client);
465
+ const areaRef = useRef<HTMLDivElement | null>(null);
466
+ const feed = useMemo(createStageFeed, []);
467
+ const perfBox = useMemo(createPerfBox, []);
468
+ const [viewport, setViewport] = useState({ width: 0, height: 0 });
469
+ const [marquee, setMarquee] = useState<ScreenMarquee | null>(null);
470
+ /**
471
+ * The outline a click would select, in STATE because the overlay draws it.
472
+ *
473
+ * The one place in the hover lane that costs a render, so it is written only
474
+ * when the answer actually CHANGES — a pointermove inside one layer keeps the
475
+ * same quad object and commits nothing. `quadMoved`'s half-pixel gate is the
476
+ * same rule `StageTools` uses to decide the selection outline moved, so a
477
+ * stationary pointer over a stationary layer is free.
478
+ */
479
+ const [hover, setHover] = useState<{ pane: StagePane; quad: ScreenQuad } | null>(null);
480
+ /**
481
+ * The pen's path so far, in DESIGN px from the frame's top-left.
482
+ *
483
+ * DESIGN and not screen, even though the preview is drawn in screen space and
484
+ * has to be converted on every render. A point held in screen coordinates
485
+ * would stay where it is on the display while the author pans or zooms
486
+ * between clicks, and land somewhere they never pointed at.
487
+ *
488
+ * The GEOMETRY is penState.ts's and none of it is here: this component owns
489
+ * the pointer plumbing and the conversion, and nothing about where a handle
490
+ * goes.
491
+ */
492
+ const [pen, setPen] = useState<PenState>(PEN_EMPTY);
493
+ /** The space bar is DOWN, so a left drag pans. See `useKeyRelay`. */
494
+ const spaceRef = useRef(false);
495
+ /** …and a pan actually ran during this hold, so the release must not also
496
+ * play. A separate bit because key auto-repeat resurrects a shared one. */
497
+ const spacePanned = useRef(false);
498
+ /**
499
+ * Recompute the cursor with no pointer motion, filled in far below.
500
+ *
501
+ * Declared up HERE because the key relay is registered above everything
502
+ * `refreshCursor` needs (`intentAt`, the feed, the panes), and a ref is the
503
+ * one shape that lets a listener registered once reach a function rebuilt on
504
+ * every render — the pattern `endModalRef` and `wheelRef` already use.
505
+ */
506
+ const refreshCursorRef = useRef<() => void>(() => {});
507
+ /**
508
+ * The cursor a live orbit or dolly is holding, or null.
509
+ *
510
+ * Navigation was the one gesture family with no feedback at all: a pan closed
511
+ * the hand and the orbit right beside it said nothing, so "am I turning the
512
+ * view or dragging a layer" was answered only by watching the picture. It is
513
+ * a REF and read by `cursorFor` rather than written at the press, because
514
+ * every move of these drags commits and the per-commit refresh would
515
+ * overwrite an imperative write immediately.
516
+ */
517
+ const navCursorRef = useRef<string | null>(null);
518
+ /** `B` armed a one-shot box select (Blender's). Consumed by the next press,
519
+ * whether or not it marquees — an armed mode the author has to remember is
520
+ * worse than one that expires. */
521
+ const boxSelectRef = useRef(false);
522
+ /** The live modal transform's PILL state — the ref below is authoritative;
523
+ * this exists so the overlay re-renders on start/axis/end and on nothing
524
+ * else (mouse motion stays off React entirely). */
525
+ const [modal, setModal] = useState<ModalState | null>(null);
526
+ const modalRef = useRef<ModalLive | null>(null);
527
+ const modalRaf = useRef(0);
528
+ /** The cursor's last known canvas position and button state, fed by the
529
+ * pointer-move prop. The start lane needs an ANCHOR — a modal begins on a
530
+ * keydown, which carries no coordinates — and `buttons` gates starting
531
+ * while any drag lane may be live (a modal's end would close that drag's
532
+ * scrub).
533
+ *
534
+ * `inside` IS LOAD-BEARING, not bookkeeping. Without it the position alone
535
+ * answers "where is the cursor", and it lies in two ways that both open a
536
+ * modal nobody asked for: it starts at `[0, 0]`, which is INSIDE the working
537
+ * pane in every layout that has one, so `G`/`S` armed a camera modal before
538
+ * the pointer had ever entered the stage; and the stage is an iframe that
539
+ * keeps keyboard focus after the cursor moves onto the chrome, where no
540
+ * `pointermove` arrives to correct the stale position. Either way the modal
541
+ * opened invisibly, and its guards swallow the wheel, the first click in
542
+ * EITHER pane, and every non-modifier key including the space the pan gate
543
+ * reads — which is the whole of "I can't move or pan around". */
544
+ const lastPointer = useRef<{ at: readonly [number, number]; buttons: number; inside: boolean }>({
545
+ at: [0, 0],
546
+ buttons: 0,
547
+ inside: false,
548
+ });
549
+ /** The pane the cursor is over, so a crossing writes `activePane` ONCE. */
550
+ const hoverPane = useRef<StagePane | null>(null);
551
+
552
+ const doc = state.doc;
553
+ const size = doc?.size ?? { width: 1920, height: 1080 };
554
+ // How far the working pane may dolly, derived from THIS document's fit. Every
555
+ // navigation writer passes it; the derived-pose writers (fit, isolation seed)
556
+ // deliberately do not. Memoised on the two numbers because it runs a
557
+ // `defaultWorkingView` and every wheel event would otherwise repeat it.
558
+ const dolly = useMemo(() => dollyBounds(size), [size.width, size.height]);
559
+ // Stable for the stage's lifetime, so the runtime's mount effect can hold it
560
+ // in a ref and stay a mount-only effect. It bumps a plain counter on the feed
561
+ // and touches no React state: a layer arriving must not re-commit the stage.
562
+ const bumpSceneEpoch = useCallback(() => {
563
+ feed.sceneEpoch += 1;
564
+ }, [feed]);
565
+ // Straight across the bridge. Stable identity so the runtime's effect stays
566
+ // a mount-only one; `client` is the stage's own handle and never changes.
567
+ const pushMeshStats = useCallback(
568
+ (layerId: string, triangles: number | null) => {
569
+ client.handlers()?.onMeshStats(layerId, triangles);
570
+ },
571
+ [client],
572
+ );
573
+ const view = state.view;
574
+ const layout = state.layout;
575
+ const workingView = state.workingView;
576
+ const follows = state.cameraFollowsView;
577
+
578
+ // The area's client origin, maintained by the SAME ResizeObserver that
579
+ // measures the viewport — never read per event. `localPoint` runs on every
580
+ // pointermove of every drag, and a `getBoundingClientRect` there is a forced
581
+ // layout read against a layout the same move's render just invalidated. The
582
+ // area is `position: fixed; inset: 0` today (origin 0,0 always); the ref
583
+ // keeps this honest if that ever changes.
584
+ const areaOrigin = useRef<readonly [number, number]>([0, 0]);
585
+ useViewportSize(areaRef, setViewport, areaOrigin);
586
+
587
+ const panes = useMemo(() => paneRects(layout, viewport), [layout, viewport]);
588
+
589
+ // ── A MOVED DIVIDER INVALIDATES "WHICH PANE IS THE POINTER OVER" ─────────
590
+ //
591
+ // `onPointerHover` reports a pane crossing ONCE, deduped against
592
+ // `hoverPane`. But a panel collapse, a window resize or a divider drag moves
593
+ // the boundary under a STATIONARY cursor, with no pointermove to follow —
594
+ // and the dedupe then pins the disagreement indefinitely, so Home and the
595
+ // zoom keys keep addressing a pane the author is no longer pointing at.
596
+ //
597
+ // Keyed on `panes` and not on `layout.mode`, because all three of those
598
+ // change the rects without necessarily changing the mode.
599
+ useEffect(() => {
600
+ hoverPane.current = null;
601
+ }, [panes]);
602
+
603
+ // ── THE RESOLVED POSITION, AND ISOLATION ─────────────────────────────────
604
+ //
605
+ // UP HERE, above the gesture lanes, because `paneScreenScale` below is read
606
+ // by `startModal`, `runTransformGesture` and `runGizmoGesture` — all of which
607
+ // are declared before the render half of this component. The rest of the
608
+ // frame's derivations (effects, bins, the camera rig) stay where they are.
609
+ //
610
+ // Where the playhead lands in the document, resolved ONCE. It rides the SAME
611
+ // size-1 caches ShotRuntime reads (frameCache.ts), so the stage and the
612
+ // runtime cannot compute the frame twice — the second caller replays the
613
+ // first one's answer.
614
+ const at = useMemo(
615
+ () => (doc === null ? null : masterToSceneCached(doc, state.frame)),
616
+ [doc, state.frame],
617
+ );
618
+ // Isolation is derived from ONE id and the document the stage already has,
619
+ // rather than pushed as a set: a set the chrome computed could disagree with
620
+ // the tree after any edit, and the two would then argue about what is
621
+ // pickable. Both memos are keyed on the resolved position, so the frame
622
+ // follows a parent's animation while the author is inside a card.
623
+ const isolated = useMemo(
624
+ () =>
625
+ // NOT IN THIS SCENE MEANS NOT ISOLATED. `withDescendants` answers a set
626
+ // holding the seed whether or not the seed exists, so an id from another
627
+ // document — or from another scene, once the playhead has moved on —
628
+ // produces a "keep" set that no layer matches: every layer dims and
629
+ // `pickable` filters to empty. A uniformly dead pane with nothing to
630
+ // click is worse than no isolation, and this one guard is what makes the
631
+ // dim, the picking and the badge agree by construction.
632
+ state.isolation === null || at === null || at.scene.layersById[state.isolation] === undefined
633
+ ? null
634
+ : withDescendants(at.scene, [state.isolation]),
635
+ [state.isolation, at],
636
+ );
637
+ const isolationView = useMemo(
638
+ () =>
639
+ state.isolation === null || at === null || isolated === null || doc === null
640
+ ? null
641
+ : isolationFrame(at.scene, state.isolation, isolated, at.sceneFrame, doc.fps),
642
+ [state.isolation, at, isolated, doc],
643
+ );
644
+ /**
645
+ * The derived head-on rig, or null once the AUTHOR owns the camera.
646
+ *
647
+ * Every consumer that means "which camera is drawing this pane" reads this
648
+ * and not `isolationView`: the ortho rig in `StageTools`, the screen scale
649
+ * below, and the nav gizmo. `isolationView` itself stays the geometry, which
650
+ * the seeding effect still needs after the hand-over.
651
+ */
652
+ const headOnRig = state.isolationOrbit ? null : isolationView;
653
+
654
+ /**
655
+ * CSS px per design px in a pane, asked of the camera ACTUALLY IN THE SLOT.
656
+ *
657
+ * `workingScreenScale` derives it from the orbit rig's distance and lens —
658
+ * correct, until isolation swaps an orthographic camera in and stops reading
659
+ * `workingView` at all. Every consumer then measured against a pose that was
660
+ * drawing no pixels: a 400px card isolated in a 1080x1920 document gives a
661
+ * true scale near 4.1 against a frozen 0.37, so a depth drag ran an order of
662
+ * magnitude fast and the snap threshold bit at a distance with no relation to
663
+ * anything on screen.
664
+ *
665
+ * NOT ON THE FEED, tempting as that is. An `OrthographicCamera` carries its
666
+ * own extents but a `PerspectiveCamera` does not carry its target distance,
667
+ * so the perspective branch would still have to come back here for
668
+ * `workingView`. This is the one place that knows both.
669
+ *
670
+ * The aspect expression is character-for-character `StageTools`' own, so the
671
+ * two derivations of the ortho extent cannot drift apart.
672
+ */
673
+ const paneScreenScale = (rect: PaneRect): number =>
674
+ headOnRig === null
675
+ ? workingScreenScale(workingView, rect.height, WORKING_FOV_DEG)
676
+ : rect.height /
677
+ (2 * isolationExtent(headOnRig, rect.height === 0 ? 1 : rect.width / rect.height).halfHeight);
678
+
679
+ /**
680
+ * SEED THE ORBIT RIG WHERE THE HEAD-ON RIG STANDS, once per entry.
681
+ *
682
+ * So the author's first orbit continues from what is on screen instead of
683
+ * from wherever the camera was before they entered the subtree. It runs while
684
+ * the derived rig is still drawing, so it changes no pixel — it only makes
685
+ * the hand-over continuous.
686
+ *
687
+ * ORIGIN "fit", and that is load-bearing: this is a pose the stage derived
688
+ * ITSELF, so under follow mode it must not reach `writeFramingFromView` and
689
+ * rewrite the shot camera of a document the author merely opened a card in.
690
+ * The bridge is written directly for the same reason the document-open fit
691
+ * does it — the local `setWorkingView` is the gesture path.
692
+ */
693
+ const seededFor = useRef<string | null>(null);
694
+ useEffect(() => {
695
+ if (state.isolation === null) {
696
+ seededFor.current = null;
697
+ return;
698
+ }
699
+ if (state.isolationOrbit || isolationView === null || panes.working === null) return;
700
+ if (seededFor.current === state.isolation) return;
701
+ seededFor.current = state.isolation;
702
+ const rect = panes.working;
703
+ const { halfHeight } = isolationExtent(isolationView, rect.height === 0 ? 1 : rect.width / rect.height);
704
+ const seeded = workingViewForIsolation(isolationEye(isolationView).eye, isolationView.target, halfHeight);
705
+ client.bridge.setWorkingView(seeded);
706
+ client.handlers()?.onWorkingViewChange(seeded, "fit");
707
+ }, [client, state.isolation, state.isolationOrbit, isolationView, panes.working]);
708
+
709
+ /** What the in-pane badge says. Off `isolationView` and not off `isolation`
710
+ * alone, so a stale id — one the scene no longer holds — announces no mode
711
+ * the pane is not actually in. */
712
+ const isolationLabel = useMemo(() => {
713
+ if (state.isolation === null || at === null || isolationView === null) return null;
714
+ const label = at.scene.layersById[state.isolation]?.label ?? state.isolation;
715
+ // ── AND WHETHER IT IS ACTUALLY ON SCREEN ──────────────────────────────
716
+ //
717
+ // A layer outside its own lifespan at this frame is dimmed away with
718
+ // everything else, so the pane is uniformly dark with nothing to click and
719
+ // the badge said only "INSIDE CARD" — which reads as the mode being broken
720
+ // rather than as the playhead being somewhere the card is not.
721
+ //
722
+ // `isolated` is the subtree, so this is true when NO member is drawing:
723
+ // a group whose children are all off-lifespan is just as blank.
724
+ const drawing = (feed.frames ?? []).some((f) => isolated?.has(f.id) === true);
725
+ return drawing ? label : `${label} — NOT ON SCREEN AT THIS FRAME`;
726
+ // `feed.frames` is a mutable field, so this cannot be keyed on it. The
727
+ // playhead is what changes the answer, and `at` carries it.
728
+ }, [state.isolation, at, isolationView, isolated, feed]);
729
+ // The camera pane is what the view offset, the fit and the zoom anchor are
730
+ // all measured against — `view.ts` still knows nothing about panes, it is
731
+ // simply handed a smaller viewport. Falling back to the whole canvas keeps
732
+ // those three total while the working pane is the only one on screen.
733
+ const cameraViewport = panes.camera ?? viewport;
734
+ // WHENEVER THE WORKING PANE IS ON SCREEN, not only in a split. A working-only
735
+ // layout has no camera pane at all, so leaving `ShotRuntime` to present would
736
+ // draw the shot camera across a viewport the author asked to see from
737
+ // somewhere else. The camera-only layout is the one — and the only one — that
738
+ // stays on the runtime's own path, which is the path an export shares.
739
+ const ownsPresent = panes.working !== null;
740
+
741
+ const fit = useCallback(
742
+ (): ShotView => fitView(cameraViewport, size),
743
+ [cameraViewport, size.width, size.height],
744
+ );
745
+ /**
746
+ * Frame the whole canvas, KEEPING where the author is standing.
747
+ *
748
+ * Blender's Frame All moves the target and the distance and never the
749
+ * angles, and numpad-dot in this editor already worked that way
750
+ * (`fitWorkingViewToSelection`). Home did not: it went back to the canonical
751
+ * three-quarter pose, so an author who had lined up an elevation lost it to
752
+ * a key they pressed to see everything.
753
+ *
754
+ * DONE IN THE FITTER and not in `defaultWorkingView`, because that function
755
+ * is also `INITIAL_WORKING_VIEW` and the document-open fit — both of which
756
+ * genuinely want the canonical pose. Read at CALL time, so the angles are
757
+ * whatever they are when Home is pressed rather than whatever they were when
758
+ * this callback was built.
759
+ */
760
+ const fitWorking = useCallback((): WorkingView => {
761
+ const at = client.getState().workingView;
762
+ return {
763
+ ...defaultWorkingView(size, WORKING_FOV_DEG),
764
+ theta: at.theta,
765
+ phi: at.phi,
766
+ // Spread conditionally: `exactOptionalPropertyTypes` is on, so writing
767
+ // `ortho: undefined` is not the same as omitting it.
768
+ ...(at.ortho === undefined ? {} : { ortho: at.ortho }),
769
+ };
770
+ }, [client, size.width, size.height]);
771
+ useEffect(() => {
772
+ client.setFitter(fit);
773
+ client.setWorkingFitter(fitWorking);
774
+ return () => {
775
+ client.setFitter(null);
776
+ client.setWorkingFitter(null);
777
+ };
778
+ }, [client, fit, fitWorking]);
779
+
780
+ // The chrome's data seam onto the overlay geometry. Rendering the outline is
781
+ // the stage's job (see Overlays), but a panel that wants to sit beside the
782
+ // selection needs the numbers.
783
+ useEffect(() => {
784
+ client.setQuadSource((pane) => {
785
+ const quads = pane === "working" ? feed.quadsWorking : feed.quadsCamera;
786
+ // Length, not `feed.ids.length`: a pane that is off screen holds no quads
787
+ // even though the selection is not empty, and answering with a shorter
788
+ // array zipped against the ids would pair a layer with another's outline.
789
+ return quads.length === 0 ? null : feed.ids.slice(0, quads.length).map((id, i) => ({ id, pts: quads[i]! }));
790
+ });
791
+ return () => client.setQuadSource(null);
792
+ }, [client, feed]);
793
+
794
+ // Fit once, when a document first arrives at a laid-out viewport. Re-fitting
795
+ // on every doc push would fight the author's own zoom on every autosave.
796
+ const fittedFor = useRef<string | null>(null);
797
+ /** The exact view the last auto-fit produced, so a later refit can tell "the
798
+ * author has not touched it" from "the author framed something". */
799
+ const lastFit = useRef<ShotView | null>(null);
800
+ useEffect(() => {
801
+ if (doc === null || viewport.width === 0 || fittedFor.current === doc.id) return;
802
+ fittedFor.current = doc.id;
803
+ const next = fit();
804
+ lastFit.current = next;
805
+ client.bridge.setView(next);
806
+ client.handlers()?.onViewChange(next);
807
+ // Both panes get framed by the same event: opening a document into a split
808
+ // and finding the working pane looking at the previous document's canvas
809
+ // would be the same bug the camera fit exists to prevent.
810
+ //
811
+ // ORIGIN "fit", and that is not decoration. This line is a MIRROR of a view
812
+ // the stage derived itself; follow mode listens on the same handler an
813
+ // orbit uses, so without the discriminator opening a shot while follow is
814
+ // armed silently overwrites its camera and autosaves it.
815
+ const working = fitWorking();
816
+ client.bridge.setWorkingView(working);
817
+ client.handlers()?.onWorkingViewChange(working, "fit");
818
+ }, [client, doc, fit, fitWorking, viewport.width]);
819
+
820
+ /**
821
+ * THE CAMERA PANE REFITS WHEN IT CHANGES SIZE, but only if the author never
822
+ * framed anything themselves.
823
+ *
824
+ * Entering a split halves the pane's width, and the frame the author was
825
+ * looking at is then cut off with no key pressed to explain it. Refitting is
826
+ * what every design tool does; what none of them do is overwrite a zoom the
827
+ * author chose, which is why this compares against the LAST AUTO-FIT rather
828
+ * than refitting unconditionally.
829
+ *
830
+ * Reference equality is the right test: `setView` only ever passes fresh
831
+ * objects, so `view === lastFit.current` means nothing has written since.
832
+ */
833
+ useEffect(() => {
834
+ if (doc === null || panes.camera === null || lastFit.current === null) return;
835
+ if (view !== lastFit.current) return;
836
+ const next = fit();
837
+ if (next.zoom === view.zoom && next.panX === view.panX && next.panY === view.panY) return;
838
+ lastFit.current = next;
839
+ client.bridge.setView(next);
840
+ client.handlers()?.onViewChange(next);
841
+ }, [client, doc, fit, panes.camera, view]);
842
+
843
+ const setView = useCallback(
844
+ (next: ShotView) => {
845
+ client.bridge.setView(next);
846
+ client.handlers()?.onViewChange(next);
847
+ },
848
+ [client],
849
+ );
850
+
851
+ const setWorkingView = useCallback(
852
+ (next: WorkingView, origin: WorkingViewOrigin = "gesture") => {
853
+ const s = client.getState();
854
+ // ── INSIDE ISOLATION, THE FIRST GESTURE TAKES THE CAMERA ────────────
855
+ //
856
+ // The derived orthographic head-on rig is the ENTRY pose, not a cage.
857
+ // While it owns the slot nothing reads `workingView`, so this used to
858
+ // swallow the write — which is correct as far as it goes (under follow,
859
+ // projecting an unseen pose onto the shot camera rewrites and autosaves
860
+ // the document) but leaves an author who orbits with a pane that does
861
+ // nothing and says nothing. Blender's local view scopes what you see and
862
+ // leaves the camera free; so does this now.
863
+ //
864
+ // The chrome owns the mode, flips its bit and pushes it back down, so the
865
+ // ordinary orbit rig takes over from the NEXT commit. This gesture's own
866
+ // pose still lands, because `workingView` was seeded to the head-on rig's
867
+ // own pose on entry (see the seeding effect) — so the first orbit
868
+ // continues from what is on screen rather than jumping to wherever the
869
+ // camera happened to be before the author entered.
870
+ //
871
+ // ORIGIN MATTERS. Only a real navigation gesture takes the camera. A
872
+ // "fit" is the stage mirroring a pose it derived itself, which must not
873
+ // silently leave head-on mode.
874
+ if (s.isolation !== null && !s.isolationOrbit) {
875
+ if (origin === "fit") return;
876
+ client.handlers()?.onIsolationOrbitTaken();
877
+ }
878
+ client.bridge.setWorkingView(next);
879
+ client.handlers()?.onWorkingViewChange(next, origin);
880
+ },
881
+ [client],
882
+ );
883
+
884
+ const onReady = useCallback(
885
+ (info: { backend: string }) => {
886
+ if (doc === null) return;
887
+ client.handlers()?.onReady({
888
+ width: doc.size.width,
889
+ height: doc.size.height,
890
+ durationFrames: durationOf(doc),
891
+ backend: info.backend,
892
+ });
893
+ },
894
+ [client, doc],
895
+ );
896
+
897
+ // The project's own modules for every `instance` layer. Resolved HERE
898
+ // because this file is the only one served through the project's Vite; the
899
+ // chrome is a prebuilt bundle with no view of the project graph.
900
+ const instances = useInstanceModules(client, doc);
901
+
902
+ useKeyRelay(client, spaceRef, spacePanned, refreshCursorRef);
903
+
904
+ /**
905
+ * The pen's terminators: Enter or a double-click commits, Escape abandons.
906
+ *
907
+ * ── WHY THE KEY LISTENER IS IN THE CAPTURE PHASE ────────────────────────
908
+ *
909
+ * `useKeyRelay` forwards every keystroke to the chrome, where Escape CLEARS
910
+ * THE SELECTION. Both are correct and only one of them may happen: an author
911
+ * abandoning a half-drawn path must not also lose what they had selected. A
912
+ * capture-phase listener on window runs before the relay's bubble-phase one,
913
+ * and `stopPropagation` there means the event never reaches it.
914
+ *
915
+ * Mounted only while the pen HOLDS POINTS, so with no path in progress
916
+ * Escape is the chrome's as it always was.
917
+ */
918
+ /**
919
+ * Hand the path to the chrome and clear the pen.
920
+ *
921
+ * The anchors AND both handles move to canvas-centre origin together: a
922
+ * handle is an absolute point in the same space as the anchor it belongs to,
923
+ * not an offset from it, so converting the anchor alone would leave every
924
+ * curve pointing at where the old origin used to be.
925
+ *
926
+ * Under two anchors `penData` answers null and this sends nothing — one
927
+ * anchor is a layer with no geometry and no way to select it, and refusing
928
+ * IS the behaviour (the same posture `onDraw` documents for an empty pen).
929
+ */
930
+ const commitPen = useCallback(
931
+ (next: PenState): void => {
932
+ const data = penData(next);
933
+ setPen(PEN_EMPTY);
934
+ if (data === null) return;
935
+ client.handlers()?.onPenCommit({
936
+ closed: data.closed,
937
+ points: data.points.map((p) => ({
938
+ p: centreOrigin(p.p, size),
939
+ ...(p.in === undefined ? {} : { in: centreOrigin(p.in, size) }),
940
+ ...(p.out === undefined ? {} : { out: centreOrigin(p.out, size) }),
941
+ })),
942
+ });
943
+ },
944
+ [client, size.width, size.height],
945
+ );
946
+
947
+ /**
948
+ * The pen in CAMERA-PANE px, for the preview.
949
+ *
950
+ * Every anchor and both its handles go through the same projection, for the
951
+ * reason `commitPen` gives about the origin shift: a handle is a point in the
952
+ * anchor's space, not an offset from it. `closeHot` is answered by
953
+ * `penCloses` rather than by a second distance test here, so the highlight
954
+ * and the click agree by construction.
955
+ */
956
+ const penScreen = useMemo(() => {
957
+ if (pen.points.length === 0) return null;
958
+ const project = (p: readonly [number, number]): [number, number] =>
959
+ designToScreen([p[0], p[1]], view, cameraViewport, size);
960
+ return {
961
+ closed: pen.closed,
962
+ cursor: pen.cursor === null ? null : project(pen.cursor),
963
+ closeHot:
964
+ pen.cursor !== null &&
965
+ penCloses(pen, pen.cursor, PEN_CLOSE_SLOP_PX / Math.max(view.zoom, MIN_INVERTIBLE_SCALE)),
966
+ points: pen.points.map((p) => ({
967
+ p: project(p.p),
968
+ ...(p.in === undefined ? {} : { in: project(p.in) }),
969
+ ...(p.out === undefined ? {} : { out: project(p.out) }),
970
+ })),
971
+ };
972
+ }, [pen, view, cameraViewport, size.width, size.height]);
973
+
974
+ useEffect(() => {
975
+ if (state.tool !== "path") {
976
+ // Arming another tool abandons the pen. The points are the pen's, and a
977
+ // path that committed later out of a tool the author had left would
978
+ // appear from nowhere.
979
+ if (pen.points.length > 0) setPen(PEN_EMPTY);
980
+ return;
981
+ }
982
+ if (pen.points.length === 0) return;
983
+ const area = areaRef.current;
984
+ const commit = (): void => commitPen(pen);
985
+ const onKey = (ev: KeyboardEvent): void => {
986
+ // Resolved through the KEYMAP, not by comparing key names here. The
987
+ // chrome's keymap is the one table that knows Escape means deselect
988
+ // when no path is open, and `penActive` is what tells it a path is —
989
+ // so the precedence lives in one readable place instead of being an
990
+ // accident of which listener happened to be in the capture phase.
991
+ const action = resolveKey(ev, { editing: false, hasSelection: false, penActive: true });
992
+ if (action?.kind !== "penCommit" && action?.kind !== "penAbandon") return;
993
+ ev.preventDefault();
994
+ ev.stopPropagation();
995
+ if (action.kind === "penCommit") commit();
996
+ else setPen(PEN_EMPTY);
997
+ };
998
+ window.addEventListener("keydown", onKey, true);
999
+ area?.addEventListener("dblclick", commit);
1000
+ return () => {
1001
+ window.removeEventListener("keydown", onKey, true);
1002
+ area?.removeEventListener("dblclick", commit);
1003
+ };
1004
+ }, [client, pen, size.width, size.height, state.tool]);
1005
+
1006
+ const localPoint = (e: { clientX: number; clientY: number }): [number, number] => {
1007
+ return [e.clientX - areaOrigin.current[0], e.clientY - areaOrigin.current[1]];
1008
+ };
1009
+
1010
+ // ── THE MODAL TRANSFORM CONTROLLER (G / R / S) ──────────────────────────
1011
+ //
1012
+ // A modal transform is the EXISTING gesture lane re-driven: it opens the
1013
+ // same onGestureStart / onGesture / onGestureEnd (or onFramingGesture*)
1014
+ // scrub a pointer drag opens, so animate mode, snapping, refusals and
1015
+ // one-undo-per-gesture all fall out unchanged. Only the input differs —
1016
+ // mouse MOTION with no button held, terminated by click/Enter (confirm) or
1017
+ // Escape/right-click (cancel). It registers ZERO pointer listeners: motion
1018
+ // rides the pointer-move prop below, termination rides the pointerdown
1019
+ // lane's first branch, and keys ride one capture-phase listener (the pen's
1020
+ // pattern). The mapping tables live in modalState.ts.
1021
+
1022
+ const emitModal = (live: ModalLive): void => {
1023
+ const handlers = client.handlers();
1024
+ if (handlers === undefined || handlers === null) return;
1025
+ const travel = { x: live.virtual[0] - live.anchor[0], y: live.virtual[1] - live.anchor[1] };
1026
+ // ── A TYPED VALUE OUTRANKS THE POINTER ────────────────────────────────
1027
+ //
1028
+ // `G X 200 Enter`. While the buffer holds something, the mouse stops
1029
+ // driving the transform entirely — Blender's own behaviour, and the reason
1030
+ // backspacing it empty hands the gesture back rather than cancelling it.
1031
+ //
1032
+ // A PLANAR MOVE HAS NO SCALAR to replace: `designDelta` answers two
1033
+ // components from two rays, and picking one for the author would guess.
1034
+ // Blender requires an axis for a numeric move too, and the refusal says so
1035
+ // in the pill rather than silently ignoring the digits.
1036
+ const exact = typedValue(live.typed);
1037
+ if (exact !== null && live.state.mode === "move" && live.state.axis === null) {
1038
+ live.refusal = "TYPE AN AXIS FIRST · X Y Z";
1039
+ modalReadoutSink.write?.(live.refusal);
1040
+ return;
1041
+ }
1042
+ // ── A SCALE IS A FACTOR, AND ZERO IS NOT ONE ──────────────────────────
1043
+ //
1044
+ // `size` has no sign and no zero to come back from, so `applyGesture`
1045
+ // refuses a non-positive ratio by name. The modal used to clamp the typed
1046
+ // value to 0.1 before it ever got there, so `S 0 Enter` shrank the layer to
1047
+ // a tenth of itself rather than being refused — the number the author typed
1048
+ // silently replaced by another one. The table returns null; this names it.
1049
+ if (exact !== null && live.state.mode === "scale" && !(exact > 0)) {
1050
+ live.refusal = "A SCALE NEEDS A FACTOR ABOVE ZERO";
1051
+ modalReadoutSink.write?.(live.refusal);
1052
+ return;
1053
+ }
1054
+ let planar: { dx: number; dy: number; dz: number } | null = null;
1055
+ if (live.state.mode === "move" && live.ray !== null && live.plane !== null) {
1056
+ const to = feed.rayAt?.("working", live.virtual[0], live.virtual[1]) ?? null;
1057
+ planar = to === null ? null : designDelta(live.ray, to, live.plane);
1058
+ }
1059
+ // A LIVE BUFFER IS SHOWN AS TYPED, not as the number it parses to, so the
1060
+ // author can see `1.` while they are half way through `1.5`.
1061
+ const echo = live.typed === "" ? null : `${live.typed}_`;
1062
+ if (live.state.target === "camera") {
1063
+ const delta = framingDeltaFor(live.state, travel, planar, DOLLY_FALLOFF_PX, exact);
1064
+ // THE PILL'S NUMBERS COME OFF THE EMITTED DELTA, never re-derived from
1065
+ // the travel: a readout computed a second way is one that can disagree
1066
+ // with the thing moving under it. Written imperatively, because this runs
1067
+ // per pointermove and the pill is memoised on state alone.
1068
+ modalReadoutSink.write?.(live.refusal ?? echo ?? modalReadout(delta));
1069
+ if (delta !== null) handlers.onFramingGesture(delta);
1070
+ return;
1071
+ }
1072
+ // THE PANE'S SCALE IS RE-READ, not the one snapshotted at start. A window
1073
+ // resize or a panel collapse mid-transform moves the divider, and a scale
1074
+ // captured before it describes a pane that no longer exists — so the layer
1075
+ // would suddenly grow at the wrong rate for the rest of the gesture.
1076
+ // `travel` is measured in the virtual cursor's own accumulated px and is
1077
+ // unaffected; only the px-to-design conversion has to stay current.
1078
+ // Always the WORKING pane: modals only start over it (the keymap's
1079
+ // `workingPane` gate), and the camera branch above returned already.
1080
+ const scale = panes.working === null ? live.screenScale : paneScreenScale(panes.working);
1081
+ const delta = layerGestureFor(live.state, travel, planar, scale, live.pivot, exact, live.snap);
1082
+ modalReadoutSink.write?.(live.refusal ?? echo ?? modalReadout(delta));
1083
+ if (delta !== null) handlers.onGesture(delta);
1084
+ };
1085
+
1086
+ const flushModal = (): void => {
1087
+ modalRaf.current = 0;
1088
+ const live = modalRef.current;
1089
+ if (live !== null) emitModal(live);
1090
+ };
1091
+
1092
+ const endModal = (
1093
+ commit: boolean,
1094
+ /**
1095
+ * Hand the cursor back? False ONLY on a live switch (`G` then `R`), which
1096
+ * discards one modal and starts another inside the same keystroke.
1097
+ *
1098
+ * Chrome refuses a `requestPointerLock` that follows an exit too closely,
1099
+ * so the exit-then-request pair a switch used to make left the new modal
1100
+ * UNLOCKED for the rest of the gesture — and an unlocked modal cancels the
1101
+ * moment the pointer leaves the stage, which on a long rotate is most of
1102
+ * the time. Keeping the lock across the seam is the whole fix; every other
1103
+ * caller really is ending the gesture and still releases.
1104
+ */
1105
+ release = true,
1106
+ ): void => {
1107
+ const live = modalRef.current;
1108
+ if (live === null) return;
1109
+ modalRef.current = null;
1110
+ if (modalRaf.current !== 0) {
1111
+ cancelAnimationFrame(modalRaf.current);
1112
+ modalRaf.current = 0;
1113
+ }
1114
+ // Released FIRST, so the cursor is back under the author's control before
1115
+ // anything else runs — and unconditionally for a real end, because a lock
1116
+ // that outlived its modal would freeze the pointer with nothing left to
1117
+ // drive.
1118
+ if (release && document.pointerLockElement === areaRef.current) document.exitPointerLock?.();
1119
+ // The final position lands before the end on a commit — dragGesture's own
1120
+ // rule, so the layer cannot land a frame behind the pointer.
1121
+ if (commit) emitModal(live);
1122
+ const handlers = client.handlers();
1123
+ if (live.state.target === "camera") handlers?.onFramingGestureEnd(commit);
1124
+ else handlers?.onGestureEnd(commit);
1125
+ setModal(null);
1126
+ };
1127
+ // Stable identity for the mount-once effects below; the function itself
1128
+ // only touches refs and the (stable) client, so a stale closure is safe.
1129
+ const endModalRef = useRef(endModal);
1130
+ endModalRef.current = endModal;
1131
+
1132
+ const startModal = (
1133
+ mode: ModalMode,
1134
+ local: readonly [number, number],
1135
+ rect: PaneRect,
1136
+ /** The outgoing modal's `lastRaw` on a LIVE SWITCH (`G` then `R`), so the
1137
+ * first move after it measures one step and not the whole prior travel.
1138
+ * Absent on a fresh start, where the press point is both. */
1139
+ lastRaw: readonly [number, number] = local,
1140
+ ): void => {
1141
+ const handlers = client.handlers();
1142
+ if (handlers === undefined || handlers === null) return;
1143
+ const target = modalTarget(state.selection.length > 0);
1144
+ // ── NOTHING ON SCREEN, NOTHING TO TRANSFORM ─────────────────────────
1145
+ //
1146
+ // Hoisted ABOVE the mode branch, because it used to live inside the move
1147
+ // arm only: `G` returned silently when no selected layer had a frame at
1148
+ // this playhead, while `R` and `S` armed happily and transformed a layer
1149
+ // nobody could see. All three want the same answer.
1150
+ const onScreen = feed.frames?.find((f) => state.selection.includes(f.id)) ?? null;
1151
+ if (target === "layers" && onScreen === null) return;
1152
+ let ray: Ray | null = null;
1153
+ let plane: Plane | null = null;
1154
+ if (mode === "move") {
1155
+ // The anchor ray and the plane, captured at start. Layers unproject on
1156
+ // the FRAME plane through the first selected layer — the same plane
1157
+ // the letter G already means in this editor — and the camera on z = 0,
1158
+ // the plane its rectangle sits in. No ray means no camera on screen:
1159
+ // nothing to start, better than a scrub that can never be fed.
1160
+ ray = feed.rayAt?.("working", local[0], local[1]) ?? null;
1161
+ if (ray === null) return;
1162
+ if (target === "layers") {
1163
+ // ANY selected layer that is on screen this frame, not `selection[0]`.
1164
+ // The selection is DOCUMENT-ordered (see the store's `select`), so its
1165
+ // first entry is whichever id sorts lowest — not the one the author
1166
+ // grabbed, and not necessarily one that is alive at this frame. Taking
1167
+ // the first that has a frame means a multi-select whose lowest member
1168
+ // is outside its lifespan still transforms, instead of refusing the
1169
+ // whole gesture with no explanation. Found once, above the branch.
1170
+ plane = gesturePlane("frame", onScreen!.matrixWorld);
1171
+ } else {
1172
+ plane = Z0_PLANE;
1173
+ }
1174
+ }
1175
+ const screenScale = paneScreenScale(rect);
1176
+ // THE MEDIAN OF WHAT IS SELECTED, which is Blender's default pivot, and
1177
+ // null for one layer — a single layer scaling about the median of itself is
1178
+ // the same thing as scaling in place, and null says so without arithmetic.
1179
+ // Off `feed.frames`, whose `matrixWorld` is the pose actually on screen at
1180
+ // this frame, so an animated parent is already accounted for.
1181
+ // ROTATE JOINS SCALE. The rotate ring passes a pivot and `R` did not, so a
1182
+ // multi-selection orbited about its median from the ring and spun in place
1183
+ // from the key. `layerGestureFor` puts it on the ROLL row only; a tilt about
1184
+ // a shared point is not expressible (see there).
1185
+ const pivot = mode === "scale" || mode === "rotate" ? selectionPivot(feed, state.selection) : null;
1186
+ const modalState: ModalState = { mode, target, axis: null };
1187
+ // ARMED ONLY IF THE SCRUB ACTUALLY OPENED. A pointer drag can absorb a
1188
+ // refusal silently — the author releases and tries again — but a modal
1189
+ // stays armed, so one that opened over another lane's scrub would drive
1190
+ // THAT scrub with its own deltas and commit it. `false` here means a
1191
+ // gesture or a wheel flick still holds the history.
1192
+ const opened =
1193
+ target === "camera"
1194
+ ? handlers.onFramingGestureStart()
1195
+ : handlers.onGestureStart(initialGestureKind(modalState), state.selection, screenScale);
1196
+ if (opened === false) return;
1197
+ // ── THE CURSOR IS PINNED FOR THE DURATION ─────────────────────────────
1198
+ //
1199
+ // A modal is driven by motion with no button held, so the pointer is free
1200
+ // to leave the stage — and `onPointerLeave` then cancelled the transform.
1201
+ // On a laptop trackpad that is a large scale or a long move most of the
1202
+ // time. Locking removes the edge entirely; the emit integrates
1203
+ // `movementX/Y` instead of reading an absolute point.
1204
+ //
1205
+ // ALLOWED HERE because a keydown carries transient user activation, which
1206
+ // is what `requestPointerLock` requires. A browser that refuses (or an
1207
+ // iframe without the permission) rejects, and the modal runs exactly as it
1208
+ // did before — the fallback is today's behaviour, not a broken one.
1209
+ //
1210
+ // NOT RE-REQUESTED WHEN IT IS ALREADY HELD, which is the state a live
1211
+ // switch leaves (see `endModal`'s `release`). Asking again would be a
1212
+ // second promise to swallow for no change of state.
1213
+ if (document.pointerLockElement !== areaRef.current) {
1214
+ void areaRef.current?.requestPointerLock?.()?.catch?.(() => {});
1215
+ }
1216
+ modalRef.current = {
1217
+ state: modalState,
1218
+ rect,
1219
+ anchor: local,
1220
+ virtual: local,
1221
+ lastRaw,
1222
+ ray,
1223
+ plane,
1224
+ screenScale,
1225
+ pivot,
1226
+ refusal: null,
1227
+ typed: "",
1228
+ snap: false,
1229
+ };
1230
+ setModal(modalState);
1231
+ };
1232
+
1233
+ const onModalKey = (ev: KeyboardEvent): void => {
1234
+ const live = modalRef.current;
1235
+ if (live !== null) {
1236
+ // A live modal owns EVERYTHING except the modifier keys themselves
1237
+ // (shift is its precision). Swallowing ⌘Z and Delete here is
1238
+ // deliberate: a relayed transaction while the modal's scrub is open is
1239
+ // the one thing the history model cannot absorb. The keymap's own lane
1240
+ // stays honest about what those combos MEAN — the suppression is this
1241
+ // listener's to own, not the table's to hide.
1242
+ if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Alt" || ev.key === "Meta") return;
1243
+ ev.preventDefault();
1244
+ ev.stopPropagation();
1245
+ // AUTO-REPEAT IS SWALLOWED, not acted on. The axis keys TOGGLE, so a
1246
+ // held X flips the constraint on and off at the OS repeat rate and the
1247
+ // one the author ends up with is decided by how long they held the key
1248
+ // — release on an even count and the axis they pressed to constrain is
1249
+ // cleared instead. Nothing in this lane wants a repeat: the terminators
1250
+ // are one-shot and the starters cannot reach here.
1251
+ if (ev.repeat) return;
1252
+ // `hasSelection` IS THE MODAL'S OWN SUBJECT, not a constant. It was
1253
+ // hard-coded true, so `R` re-armed a ROTATE against the camera — which
1254
+ // has no roll, so `emitModal` could never produce a delta, the pill still
1255
+ // read CAMERA MOVE, and a typed `X 100 Enter` fell past the move-only
1256
+ // axis refusal into `framingDeltaFor` and wrote a real 100px camera pan.
1257
+ const action = resolveKey(keyOf(ev), {
1258
+ editing: false,
1259
+ hasSelection: live.state.target !== "camera",
1260
+ transformModal: true,
1261
+ });
1262
+ if (action === null) return;
1263
+ if (action.kind === "modalConfirm") endModal(true);
1264
+ else if (action.kind === "modalCancel") endModal(false);
1265
+ else if (action.kind === "modalStart") {
1266
+ // ANOTHER TRANSFORM LETTER SWITCHES LIVE. Discarded rather than
1267
+ // committed, so `G` then `R` rotates instead of leaving a move behind
1268
+ // — and restarted at the ORIGINAL ANCHOR, so the rotation is measured
1269
+ // from where the author pressed rather than from wherever the pointer
1270
+ // had wandered while they were moving.
1271
+ //
1272
+ // ── BUT `lastRaw` CARRIES OVER ──────────────────────────────────
1273
+ //
1274
+ // The anchor is where the transform is measured FROM; `lastRaw` is
1275
+ // where the POINTER last was. Re-seeding both to the anchor made the
1276
+ // next pointermove read a step of "everything travelled during the
1277
+ // move" and apply it in one jump. Under pointer lock the deltas are
1278
+ // integrated from `movementX/Y` so it never showed; on the unlocked
1279
+ // fallback path (an iframe with no permission, a browser that refused)
1280
+ // it was the whole prior travel at once.
1281
+ const anchor = live.anchor;
1282
+ const rect = live.rect;
1283
+ const lastRaw = live.lastRaw;
1284
+ endModal(false, false);
1285
+ startModal(action.mode, anchor, rect, lastRaw);
1286
+ }
1287
+ else if (action.kind === "modalAxis") {
1288
+ const next = modalAxisToggle(live.state, action.axis);
1289
+ if (next === live.state) {
1290
+ // CLAIMED AND IGNORED. The key was already swallowed by the
1291
+ // `preventDefault` above, so saying nothing here is
1292
+ // indistinguishable from a key that never arrived.
1293
+ live.refusal = axisRefusal(live.state, action.axis);
1294
+ modalReadoutSink.write?.(live.refusal ?? "");
1295
+ return;
1296
+ }
1297
+ live.state = next;
1298
+ live.refusal = null;
1299
+ setModal(next);
1300
+ // Re-project what is already travelled under the new constraint.
1301
+ if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
1302
+ } else if (action.kind === "modalNumber") {
1303
+ const next = typeNumber(live.typed, action.ch);
1304
+ // A second decimal point is rejected rather than swallowed, so `1.2.3`
1305
+ // cannot exist. Nothing to redraw when the buffer did not change.
1306
+ if (next === null) return;
1307
+ live.typed = next;
1308
+ live.refusal = null;
1309
+ if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
1310
+ } else if (action.kind === "modalNumberBack") {
1311
+ // Emptying it hands the transform back to the MOUSE rather than
1312
+ // cancelling, which is what makes a mistyped digit recoverable.
1313
+ live.typed = live.typed.slice(0, -1);
1314
+ live.refusal = null;
1315
+ if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
1316
+ }
1317
+ return;
1318
+ }
1319
+ if (doc === null) return;
1320
+ const at = lastPointer.current;
1321
+ // `inside` before the position is read at all: off the stage there is no
1322
+ // cursor to anchor to and the coordinate below is a leftover (see the ref).
1323
+ // `buttons !== 0` means a drag lane may be live; the pen's emptiness
1324
+ // keeps its own capture listener and this one from fighting over a key.
1325
+ if (!at.inside || at.buttons !== 0 || pen.points.length > 0) return;
1326
+ const hit = paneAt(panes, at.at[0], at.at[1]);
1327
+ if (hit === null || hit.pane !== "working") return;
1328
+ const action = resolveKey(keyOf(ev), {
1329
+ editing: isEditable(ev.target),
1330
+ hasSelection: state.selection.length > 0,
1331
+ workingPane: true,
1332
+ });
1333
+ // `B` ARMS A BOX SELECT for the next press, and it is consumed HERE rather
1334
+ // than in the chrome: the marquee is the stage's own lane, and a bit that
1335
+ // crossed the bridge would be one round trip behind the press it arms.
1336
+ if (action?.kind === "boxSelect") {
1337
+ boxSelectRef.current = true;
1338
+ ev.preventDefault();
1339
+ ev.stopPropagation();
1340
+ return;
1341
+ }
1342
+ if (action?.kind !== "modalStart") return;
1343
+ ev.preventDefault();
1344
+ ev.stopPropagation();
1345
+ startModal(action.mode, hit.local, panes.working as PaneRect);
1346
+ };
1347
+ const onModalKeyRef = useRef(onModalKey);
1348
+ onModalKeyRef.current = onModalKey;
1349
+
1350
+ useEffect(() => {
1351
+ // Registered ONCE, reading the current handler through a ref (the wheel
1352
+ // lane's pattern). Capture phase so a consumed start/terminator never
1353
+ // reaches useKeyRelay's bubble listener — same reason the pen's is.
1354
+ const down = (ev: KeyboardEvent): void => onModalKeyRef.current(ev);
1355
+ // A blur mid-modal (clicked into the chrome, alt-tabbed) cancels: keys
1356
+ // and motion stop arriving, and an abandoned open scrub outlives them.
1357
+ const blur = (): void => endModalRef.current(false);
1358
+ window.addEventListener("keydown", down, true);
1359
+ window.addEventListener("blur", blur);
1360
+ return () => {
1361
+ window.removeEventListener("keydown", down, true);
1362
+ window.removeEventListener("blur", blur);
1363
+ endModalRef.current(false);
1364
+ };
1365
+ }, []);
1366
+
1367
+ // A document switch mid-modal cancels — the store's doc-swap discard is
1368
+ // the backstop, not the plan. The `B` arm goes with it: an armed one-shot is
1369
+ // a mode the author is in, and modes do not survive the document they were
1370
+ // armed over.
1371
+ useEffect(
1372
+ () => () => {
1373
+ endModalRef.current(false);
1374
+ boxSelectRef.current = false;
1375
+ },
1376
+ [doc?.id],
1377
+ );
1378
+
1379
+ /**
1380
+ * The pen's rubber band and close highlight follow the cursor between
1381
+ * presses, and only then.
1382
+ *
1383
+ * Cheap by construction: it returns before touching state unless the pen
1384
+ * actually holds a point, so with any other tool armed this is one comparison
1385
+ * per pointer event. `penReduce`'s `move` with no drag open advances `cursor`
1386
+ * alone, so hover needs no branch of its own in the model.
1387
+ */
1388
+ /** Every pointer event updates the cursor box, not just moves. `buttons` is
1389
+ * a GATE on starting a modal, and a move-only writer leaves it stale in
1390
+ * both directions: no `pointermove` is emitted on release, so one stuck at
1391
+ * 1 makes G/R/S silently arm tools forever after; and a press with no
1392
+ * motion leaves it at 0, which would let a modal open on top of a live
1393
+ * drag's scrub. */
1394
+ const onPointerTrack = (e: React.PointerEvent<HTMLDivElement>): void => {
1395
+ lastPointer.current = { at: localPoint(e), buttons: e.buttons, inside: true };
1396
+ };
1397
+
1398
+ /**
1399
+ * The cursor left the stage: no modal can be started, and a live one is
1400
+ * cancelled.
1401
+ *
1402
+ * A modal is driven by MOTION over the pane. Off the stage none arrives, so
1403
+ * it can neither be aimed nor confirmed — while its guards go on eating the
1404
+ * wheel, the first press in either pane and the whole keyboard, until the
1405
+ * author guesses Escape. `window`'s blur cancel is not this: blur needs a
1406
+ * CLICK into the chrome, and hovering it is enough to strand one.
1407
+ */
1408
+ /**
1409
+ * The container's cursor, written IMPERATIVELY and exactly once.
1410
+ *
1411
+ * ── WHY NOT REACT ───────────────────────────────────────────────────────
1412
+ *
1413
+ * It changes on every pointermove, and a move already costs a stage commit
1414
+ * and a GL draw when a gesture is live. Routing the cursor through state
1415
+ * would add a full re-render per move to change one string. The last-written
1416
+ * guard makes the common case (the cursor did not change) a string compare.
1417
+ *
1418
+ * ── AND WHY THERE MUST BE ONLY ONE WRITER ───────────────────────────────
1419
+ *
1420
+ * The JSX style object used to carry `cursor: spaceRef.current ? ... `, read
1421
+ * from a REF during render — so it only reached the DOM when something
1422
+ * unrelated happened to commit, which is why the stage appeared to have no
1423
+ * cursor behaviour at all. Leaving that in place alongside this would be
1424
+ * worse than the original bug: React would win on its next commit and the
1425
+ * cursor would be intermittently right, which nobody can debug.
1426
+ */
1427
+ // Seeded EMPTY, mirroring the element's own inline style rather than the
1428
+ // effective value. Seeding it "default" makes the first write to "default" a
1429
+ // no-op, so a stage whose first answer is the default never writes at all and
1430
+ // the ref stops describing the DOM.
1431
+ const cursorRef = useRef("");
1432
+ const setCursor = useCallback((next: string): void => {
1433
+ if (next === cursorRef.current) return;
1434
+ cursorRef.current = next;
1435
+ const el = areaRef.current;
1436
+ if (el !== null) el.style.cursor = next;
1437
+ }, []);
1438
+
1439
+ /**
1440
+ * Arm or disarm the navigation cursor.
1441
+ *
1442
+ * Two writes, and both are needed. The IMPERATIVE one is what puts the shape
1443
+ * on screen at the press, the way the pan lane already does it. The REF is
1444
+ * what makes it survive: an orbit calls `setWorkingView` on every move, every
1445
+ * one of those is a commit, and while a real browser reports a held button
1446
+ * (so `refreshCursor` bails) nothing guarantees that — a synthetic move, or a
1447
+ * commit from anywhere else in the stage, would otherwise re-answer the
1448
+ * cursor from what is UNDER the pointer, which during a navigation drag is
1449
+ * not the subject.
1450
+ */
1451
+ const setNavCursor = useCallback(
1452
+ (next: string | null): void => {
1453
+ navCursorRef.current = next;
1454
+ if (next !== null) {
1455
+ setCursor(next);
1456
+ return;
1457
+ }
1458
+ // Disarming drops to the default BEFORE asking for the real answer,
1459
+ // because `refreshCursor` bails while a button is still reported down —
1460
+ // and at a pointerup the tracked `buttons` is still the drag's. Without
1461
+ // this the closed hand would outlive the gesture until the next move.
1462
+ setCursor("default");
1463
+ refreshCursorRef.current();
1464
+ },
1465
+ [setCursor],
1466
+ );
1467
+
1468
+ /**
1469
+ * The cursor for what is under the pointer, in precedence order.
1470
+ *
1471
+ * The order matters and is not the same as the press lane's: a held space bar
1472
+ * outranks everything because the NEXT press pans whatever it is over, and a
1473
+ * live gesture holds whatever shape it started with because the thing being
1474
+ * dragged has already left the pointer's own position behind.
1475
+ */
1476
+ const cursorFor = useCallback(
1477
+ (pane: StagePane | null, intent: StageIntent | null): string => {
1478
+ // A LIVE NAVIGATION DRAG OUTRANKS EVERYTHING, and it has to be answered
1479
+ // here rather than written imperatively at the press. An orbit or a dolly
1480
+ // calls `setWorkingView` on every move, every one of those is a commit,
1481
+ // and the per-commit effect below re-answers the cursor from what is
1482
+ // UNDER the pointer — which during a navigation drag is not the subject.
1483
+ // An imperative write would therefore survive exactly until the first
1484
+ // move, which is the moment it starts mattering.
1485
+ if (navCursorRef.current !== null) return navCursorRef.current;
1486
+ if (spaceRef.current) return "grab";
1487
+ if (pane === null || intent === null) return "default";
1488
+ switch (intent.kind) {
1489
+ case "grab":
1490
+ if (intent.grab.handleAngle === undefined) return "move";
1491
+ // A UNION HANDLE GETS THE RESIZE CURSOR TOO. What the drag writes
1492
+ // differs (a ratio about a pivot, not one layer's edge), but what the
1493
+ // POINTER does is identical — the corner follows it outward — and a
1494
+ // cursor that said otherwise would be describing the implementation
1495
+ // rather than the gesture.
1496
+ return intent.grab.kind === "roll"
1497
+ ? rotateCursorFor(intent.grab.handleAngle)
1498
+ : intent.grab.kind === "resize" || intent.grab.kind === "scale"
1499
+ ? resizeCursorFor(intent.grab.handleAngle)
1500
+ : "move";
1501
+ case "arm":
1502
+ case "frustum":
1503
+ return "crosshair";
1504
+ case "empty":
1505
+ // The armed TOOL, at last: `ToolSpec.cursor` has carried a column for
1506
+ // all 13 rows, and been tested, with no reader in the stage at all.
1507
+ // Forced back to the default in the working pane, because the draw
1508
+ // tools only act in the camera one (a free orbit has no screen
1509
+ // rectangle that corresponds to a drawn box) and a crosshair over a
1510
+ // pane that will answer with an orbit is a promise the lane breaks.
1511
+ return pane === "working" && isDrawTool(state.tool) ? "default" : specOf(state.tool).cursor;
1512
+ }
1513
+ },
1514
+ [state.tool],
1515
+ );
1516
+
1517
+ const onPointerLeave = (): void => {
1518
+ lastPointer.current = { ...lastPointer.current, inside: false };
1519
+ setCursor("default");
1520
+ setHover(null);
1521
+ // A LIVE MODAL UNDER POINTER LOCK DOES NOT LEAVE. The lock freezes the
1522
+ // cursor, so a `pointerleave` while it holds is the browser's doing and
1523
+ // not the author's — cancelling there would throw away a transform for a
1524
+ // reason they never caused.
1525
+ if (document.pointerLockElement === areaRef.current) return;
1526
+ endModalRef.current(false);
1527
+ };
1528
+
1529
+ const onPointerHover = (e: React.PointerEvent<HTMLDivElement>): void => {
1530
+ const at = localPoint(e);
1531
+ lastPointer.current = { at, buttons: e.buttons, inside: true };
1532
+ const live = modalRef.current;
1533
+ if (live !== null) {
1534
+ // The modal is driven by MOTION with no button held. Raw steps
1535
+ // accumulate into a virtual cursor (shift scales them — precision) and
1536
+ // the emit coalesces to the frame, dragGesture's own pattern.
1537
+ //
1538
+ // UNDER POINTER LOCK the absolute position is frozen and only
1539
+ // `movementX/Y` is real, so the raw point is integrated from the deltas.
1540
+ // That is what lets a transform run past the edge of the window without
1541
+ // the pointer leaving and cancelling it — Blender wraps the cursor for
1542
+ // the same reason.
1543
+ const locked = document.pointerLockElement === areaRef.current;
1544
+ const raw: readonly [number, number] = locked
1545
+ ? [live.lastRaw[0] + e.movementX, live.lastRaw[1] + e.movementY]
1546
+ : [at[0] - live.rect.x, at[1] - live.rect.y];
1547
+ const moved = raw[0] !== live.lastRaw[0] || raw[1] !== live.lastRaw[1];
1548
+ live.virtual = advanceVirtual(live.virtual, live.lastRaw, raw, e.shiftKey);
1549
+ live.lastRaw = raw;
1550
+ // ── A REFUSAL DESCRIBES A KEY, NOT THE TRANSFORM ────────────────────
1551
+ //
1552
+ // It was only ever cleared by another KEY, so "SIZE HAS NO Z" sat in the
1553
+ // pill while the author kept moving the mouse and the size kept changing
1554
+ // — the live numbers never came back and the message described nothing
1555
+ // that was still happening. Any real motion is the author moving on.
1556
+ if (moved) live.refusal = null;
1557
+ // Read PER EVENT, like shift's precision: the author holds and releases
1558
+ // ctrl mid-gesture to nudge onto a round number and off again.
1559
+ live.snap = e.ctrlKey || e.metaKey;
1560
+ if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
1561
+ return;
1562
+ }
1563
+ const hit = paneAt(panes, at[0], at[1]);
1564
+ // ── THE PANE UNDER THE CURSOR IS THE ACTIVE ONE ───────────────────────
1565
+ //
1566
+ // `activePane` decides what Home fits and what the zoom keys and the
1567
+ // header's zoom buttons address, and until now only a PRESS or a WHEEL
1568
+ // wrote it. In split that leaves it wherever it was: entering split from
1569
+ // camera-only keeps `"camera"` (the store forces the pane to the mode in
1570
+ // the single-pane layouts, so this is split's alone), and Home then fits a
1571
+ // pane the author is not pointing at while the one they are pointing at
1572
+ // does nothing. Hover is the answer the author already believes, and it is
1573
+ // the same rule the modal starters and the shading bar read.
1574
+ //
1575
+ // Once per CROSSING, not once per move: `onActivePane` is a store write
1576
+ // and the chrome re-renders on it.
1577
+ if (hit !== null && hit.pane !== hoverPane.current) {
1578
+ hoverPane.current = hit.pane;
1579
+ client.handlers()?.onActivePane(hit.pane);
1580
+ }
1581
+ // THE CURSOR SAYS WHAT THE PRESS WOULD DO, off the same `intentAt` the
1582
+ // press itself switches on. Not behind the `onActivePane` gate above: that
1583
+ // one fires once per pane CROSSING, and the cursor has to change every time
1584
+ // the pointer crosses a handle.
1585
+ //
1586
+ // No React work: `setCursor` is a string compare and, on a change, one
1587
+ // style write. `intentAt` is three feed calls, the same three the press
1588
+ // makes, and the press lane already runs them per press.
1589
+ if (e.buttons === 0) {
1590
+ const intent = hit === null ? null : intentAt(hit.pane, hit.local[0], hit.local[1]);
1591
+ setCursor(hit === null ? cursorFor(null, null) : cursorFor(hit.pane, intent));
1592
+ // The arm under the pointer lights up. A plain field write, read by
1593
+ // `MoveGizmo` inside the frame loop, so a hover costs no React work at
1594
+ // all — see `feed.hoverArm`.
1595
+ feed.hoverArm = intent?.kind === "arm" ? intent.arm.axis : null;
1596
+ // THE HIGHLIGHT, only over empty space. Over a handle or an already
1597
+ // selected body the press is a transform, not a selection change, so
1598
+ // previewing a selection there would promise something the click will
1599
+ // not do.
1600
+ const over =
1601
+ hit === null || intent === null || intent.kind !== "empty"
1602
+ ? null
1603
+ : hit;
1604
+ const quad = over === null ? null : (feed.hoverQuad?.(over.pane, over.local[0], over.local[1]) ?? null);
1605
+ setHover((prev) => {
1606
+ if (quad === null || over === null) return prev === null ? prev : null;
1607
+ if (prev !== null && prev.pane === over.pane && !quadMoved(prev.quad, quad)) return prev;
1608
+ return { pane: over.pane, quad };
1609
+ });
1610
+ }
1611
+ if (state.tool !== "path" || pen.points.length === 0 || pen.drag !== null) return;
1612
+ if (hit === null || hit.pane !== "camera") return;
1613
+ const design = screenToDesign(hit.local, view, cameraViewport, size);
1614
+ setPen((prev) => penReduce(prev, { kind: "move", at: design, dragThreshold: PEN_DRAG_THRESHOLD_PX }));
1615
+ };
1616
+
1617
+ /**
1618
+ * Double-click: go inside what is under the cursor.
1619
+ *
1620
+ * Reads the pick list fresh rather than trusting the selection the
1621
+ * pointerdown lane just made — a double-click whose first press landed on a
1622
+ * layer and whose second landed a few pixels away, on another, should enter
1623
+ * what the author is pointing at now.
1624
+ */
1625
+ const onDoubleClick = (e: React.MouseEvent<HTMLDivElement>): void => {
1626
+ if (doc === null || isDrawTool(state.tool)) return;
1627
+ const hit = paneAt(panes, ...localPoint(e));
1628
+ if (hit === null) return;
1629
+ const hits = feed.pick?.(hit.pane, hit.local[0], hit.local[1]) ?? [];
1630
+ if (hits.length > 0) client.handlers()?.onEnterLayer(hits, hit.pane);
1631
+ };
1632
+
1633
+ /**
1634
+ * WHAT A PRESS AT THIS POINT WOULD DO. Position only, no buttons, no keys.
1635
+ *
1636
+ * ── WHY THIS IS ONE FUNCTION AND NOT A SEQUENCE OF `return`s ────────────
1637
+ *
1638
+ * It used to be the tail of `onPointerDown`, and it had exactly one caller,
1639
+ * so nothing could ask "what would happen here" without actually making it
1640
+ * happen. That is why the stage had no hover cursor: the answer existed only
1641
+ * as a side effect. Now the press lane switches on it and the hover lane maps
1642
+ * it to a cursor, so the shape under the pointer IS the thing the press does.
1643
+ *
1644
+ * ── THE ORDERING, WHICH IS FORCED ───────────────────────────────────────
1645
+ *
1646
+ * ARMS FIRST. An arm is drawn ON TOP OF the thing it belongs to and overlaps
1647
+ * it, so a gizmo hit ranked below `grabAt` could never be reached: the body
1648
+ * grab would take every press the arm sits over, which is most of the arm at
1649
+ * a shallow orbit. The arms are small screen-space targets nothing else
1650
+ * claims, exactly like the frustum's apex.
1651
+ *
1652
+ * AND THE APEX STILL WORKS UNDERNEATH, because the arms start 30 screen px
1653
+ * out — more than this test's slop plus the apex's own — so a press aimed at
1654
+ * the apex misses all three and falls through to the frustum lane. That
1655
+ * inequality is the whole reason ARM_START_PX has a value rather than a look.
1656
+ *
1657
+ * THEN THE SELECTION. A press on it is a transform, not a selection change: a
1658
+ * handle resizes, the body moves. Pressing an UNselected layer falls through
1659
+ * to the click lane, because dragging something the author had not selected
1660
+ * is the classic way to move the wrong thing.
1661
+ *
1662
+ * THEN THE CAMERA GIZMO, and its rank is the subtlety. The frustum's base
1663
+ * quad is COPLANAR WITH EVERY LAYER — it is the rectangle the layers live in
1664
+ * — so a frustum hit that outranked a layer would make layers unselectable in
1665
+ * this pane, and one that outranked the orbit everywhere inside the frame
1666
+ * would take away the pane's primary gesture. Behind the layers and in front
1667
+ * of empty space is the only ordering in which all three stay reachable. The
1668
+ * APEX is exempt from that veto: it is a small target floating away from the
1669
+ * plane, and nothing else claims it.
1670
+ *
1671
+ * NO SIDE EFFECTS. It is called on every hover, so it may not write state,
1672
+ * open a scrub or notify the chrome.
1673
+ */
1674
+ const intentAt = (pane: StagePane, x: number, y: number): StageIntent => {
1675
+ if (pane === "working") {
1676
+ const arm = feed.gizmoHit?.(x, y) ?? null;
1677
+ if (arm !== null) return { kind: "arm", arm };
1678
+ }
1679
+ const grab = feed.grabAt?.(pane, x, y, state.selection) ?? null;
1680
+ if (grab !== null) return { kind: "grab", grab };
1681
+ if (pane === "working") {
1682
+ const part = feed.frustumHit?.(x, y) ?? null;
1683
+ const overLayer = part === "body" && (feed.pick?.(pane, x, y) ?? []).length > 0;
1684
+ if (part !== null && !overLayer) return { kind: "frustum", part };
1685
+ }
1686
+ return { kind: "empty" };
1687
+ };
1688
+
1689
+ /**
1690
+ * Recompute the cursor from the LAST KNOWN pointer position.
1691
+ *
1692
+ * For everything that changes the answer without moving the pointer: arming a
1693
+ * tool, pressing or releasing the space bar, a layer becoming selected from a
1694
+ * panel. Read through a ref by the listeners that are registered once (the
1695
+ * key relay), which is the same shape `endModalRef` and `wheelRef` use.
1696
+ */
1697
+ const refreshCursor = (): void => {
1698
+ const { at, inside, buttons } = lastPointer.current;
1699
+ // A live modal owns the pointer's meaning, and a DRAG has already left the
1700
+ // pointer's own position behind — a pan's closed hand must survive every
1701
+ // commit the drag itself causes, and this runs on all of them.
1702
+ if (!inside || buttons !== 0 || modalRef.current !== null) return;
1703
+ const hit = paneAt(panes, at[0], at[1]);
1704
+ setCursor(
1705
+ hit === null
1706
+ ? cursorFor(null, null)
1707
+ : cursorFor(hit.pane, intentAt(hit.pane, hit.local[0], hit.local[1])),
1708
+ );
1709
+ };
1710
+ refreshCursorRef.current = refreshCursor;
1711
+
1712
+ // The armed tool changes the cursor with no pointer motion at all, and a
1713
+ // selection arriving from the outliner adds handles under a stationary
1714
+ // pointer. Both are commits, so one effect per commit is the whole cost.
1715
+ useEffect(() => {
1716
+ refreshCursorRef.current();
1717
+ });
1718
+
1719
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
1720
+ // A press with no motion still moves the gate: without this, holding the
1721
+ // button still still reads `buttons: 0` and a modal could open on top of
1722
+ // the drag's own scrub.
1723
+ onPointerTrack(e);
1724
+ // MIDDLE PRESS: kill the default before anything else looks at it.
1725
+ // Windows and Linux Chrome open AUTOSCROLL on an unprevented middle
1726
+ // mousedown — the anchor puck appears, the cursor changes, and every
1727
+ // subsequent move scrolls instead of reaching this pane. That is both
1728
+ // Blender bindings this stage added, middle-drag orbit and shift+middle
1729
+ // pan, dead on the platform they were reported broken on.
1730
+ // `touchAction: "none"` does not cover it: autoscroll rides the
1731
+ // compatibility MOUSE event, and preventing the pointerdown's default is
1732
+ // what suppresses that.
1733
+ if (e.button === 1) e.preventDefault();
1734
+ // A press terminates a live modal — LEFT confirms, right cancels — and does
1735
+ // NOTHING ELSE. First branch, so a confirm click can never fall through to
1736
+ // pick / marquee / orbit.
1737
+ //
1738
+ // MIDDLE IS IGNORED rather than treated as a confirm. Blender binds it to
1739
+ // auto-constrain, so a Blender user reaches for it MID-TRANSFORM expecting
1740
+ // an axis — and committing the transform instead is worse than doing
1741
+ // nothing, because it is silent and it costs an undo to get back. The
1742
+ // `preventDefault` above still stands: Windows autoscroll must not open
1743
+ // over a live modal either.
1744
+ if (modalRef.current !== null) {
1745
+ if (e.button === 1) return;
1746
+ endModal(e.button !== 2);
1747
+ return;
1748
+ }
1749
+ if (doc === null) return;
1750
+ const canvasStart = localPoint(e);
1751
+ const hit = paneAt(panes, canvasStart[0], canvasStart[1]);
1752
+ // A press in no pane is a press on a gutter the layout left over. Doing
1753
+ // nothing is the only honest answer; picking a pane for it would answer a
1754
+ // click with a camera the author was not pointing at.
1755
+ if (hit === null) return;
1756
+ const { pane, local: start } = hit;
1757
+ const rect = (pane === "camera" ? panes.camera : panes.working) as PaneRect;
1758
+ const el = e.currentTarget;
1759
+ el.setPointerCapture(e.pointerId);
1760
+ client.handlers()?.onActivePane(pane);
1761
+ // ── THE `B` ARM IS SPENT BY *THIS* PRESS, WHATEVER THE PRESS DOES ─────
1762
+ //
1763
+ // It used to be read and cleared down in the empty-space lane, below every
1764
+ // `return` above it — so a press that landed on a layer, a handle, a gizmo
1765
+ // arm or the frustum left the bit SET, and a working-pane drag some time
1766
+ // later marqueed instead of orbiting, with nothing on screen to say why.
1767
+ // Reading it here, where the press is first known to be a press, is what
1768
+ // makes "armed, and spent by the next press" true rather than nearly true.
1769
+ const boxArmed = boxSelectRef.current;
1770
+ boxSelectRef.current = false;
1771
+
1772
+ // ── A RIGHT PRESS OPENS THE MENU, AND DOES NOTHING ELSE ───────────────
1773
+ //
1774
+ // ABOVE the draw and pen lanes on purpose. This is the change the gate
1775
+ // further down reserved for itself: `e.button !== 0` sits BELOW those
1776
+ // lanes, so a right DRAG with the rect tool armed still inserted a layer —
1777
+ // under the browser's own context menu, which is a layer the author did not
1778
+ // ask for and did not see arrive.
1779
+ //
1780
+ // Below the modal terminator above, so a right-click that cancels a
1781
+ // transform cancels it and stops; opening a menu on the way out of a
1782
+ // gesture would be answering a question nobody asked.
1783
+ //
1784
+ // On the PRESS rather than the release, so this opens no gesture of its
1785
+ // own: no move/up pair to register, and sourceHygiene's one-registration
1786
+ // rule for hand-rolled lanes is untouched.
1787
+ if (e.button === 2) {
1788
+ const hits = feed.pick?.(pane, start[0], start[1]) ?? [];
1789
+ client.handlers()?.onContextMenu(hits, pane, { x: e.clientX, y: e.clientY });
1790
+ return;
1791
+ }
1792
+
1793
+ // Deltas are the same in canvas space and in pane space (the two differ by
1794
+ // a constant), so every drag below measures from the CANVAS press point and
1795
+ // only the hit tests use the pane-local one.
1796
+ const dragFrom = (ev: PointerEvent): [number, number] => {
1797
+ const at = localPoint(ev);
1798
+ return [at[0] - canvasStart[0], at[1] - canvasStart[1]];
1799
+ };
1800
+
1801
+ // The pan gate is PANE-DEPENDENT. The camera pane keeps the designer
1802
+ // convention (middle or space = pan). The working pane speaks Blender:
1803
+ // middle-drag ORBITS (the branch below), shift+middle pans, and space
1804
+ // still pans — both muscle memories stay valid at once.
1805
+ // ── BLENDER'S EMULATE-3-BUTTON, so a trackpad can orbit ───────────────
1806
+ //
1807
+ // alt + left stands in for the middle button, which a laptop does not
1808
+ // have — and without it the working pane's orbit was reachable only by
1809
+ // dragging EMPTY SPACE, so an author with a full-bleed background layer
1810
+ // could not turn the view at all.
1811
+ //
1812
+ // Alt is free on a working-pane press: it is read as `aboutCentre` inside a
1813
+ // resize drag and nowhere else. Shift+alt+left therefore pans, which costs
1814
+ // that one combo its additive marquee — named here rather than discovered.
1815
+ //
1816
+ // THE WORKING PANE ONLY, and that gate is the fix. Folding it into the
1817
+ // camera pane's `pans` made alt+left a PAN there, which ate the two
1818
+ // gestures alt already owns on that side: `aboutCentre` on a resize, and
1819
+ // every draw tool that an author holds alt through. The camera pane has a
1820
+ // middle button's worth of pan already (middle, and space), so it needs no
1821
+ // stand-in for a button a laptop lacks.
1822
+ const emulatesMiddle = e.button === 0 && e.altKey && pane === "working";
1823
+ const middle = e.button === 1 || emulatesMiddle;
1824
+ const pans =
1825
+ pane === "camera"
1826
+ ? isPanGesture(e, spaceRef.current)
1827
+ : spaceRef.current || (middle && e.shiftKey);
1828
+ // ctrl/meta + middle DOLLIES, at the wheel's own falloff so a drag and a
1829
+ // notch are the same step. Blender's third mouse binding, and the only one
1830
+ // this pane was missing.
1831
+ if (!pans && pane === "working" && middle && (e.ctrlKey || e.metaKey)) {
1832
+ const fromWorking = workingView;
1833
+ // BRACKETED LIKE EVERY OTHER NAVIGATION LANE. Under follow mode a view
1834
+ // change IS a document write, and an unbracketed one goes through
1835
+ // `store.edit` -> `transaction()` per rAF-flushed move — an undo entry
1836
+ // every frame, with the redo stack cleared each time, and the Escape
1837
+ // restore itself another edit. The travel gate is what keeps a middle
1838
+ // CLICK from opening a scrub it never feeds, which would then block
1839
+ // `edit`/`undo`/`redo` until something else closed it.
1840
+ let dragged = false;
1841
+ const following = follows;
1842
+ // ns-resize, not a zoom cursor: the gesture is a VERTICAL DRAG and that is
1843
+ // what the arrows say. zoom-in/zoom-out are click affordances and would
1844
+ // promise an action a drag does not have.
1845
+ setNavCursor("ns-resize");
1846
+ dragGesture(
1847
+ el,
1848
+ e.pointerId,
1849
+ (ev) => {
1850
+ const dy = dragFrom(ev)[1];
1851
+ if (!dragged && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
1852
+ if (!dragged && following) client.handlers()?.onFramingGestureStart();
1853
+ dragged = true;
1854
+ setWorkingView(dollyBy(fromWorking, -dy, dolly));
1855
+ },
1856
+ (commit) => {
1857
+ setNavCursor(null);
1858
+ if (!dragged) return;
1859
+ if (!commit) setWorkingView(fromWorking);
1860
+ if (following) client.handlers()?.onFramingGestureEnd(commit);
1861
+ },
1862
+ );
1863
+ return;
1864
+ }
1865
+ if (pans) {
1866
+ // A closed hand while the drag runs, and back to whatever the pointer is
1867
+ // over when it ends. This is the one cursor a live gesture is allowed to
1868
+ // change, because a pan is the one gesture whose subject IS the pointer.
1869
+ setCursor("grabbing");
1870
+ const fromView = view;
1871
+ const fromWorking = workingView;
1872
+ // With follow armed, a working-pane pan moves the CAMERA too, so it is
1873
+ // bracketed exactly as an orbit is and the whole hold is one undo entry.
1874
+ const following = follows && pane === "working";
1875
+ if (following) client.handlers()?.onFramingGestureStart();
1876
+ dragGesture(
1877
+ el,
1878
+ e.pointerId,
1879
+ (ev) => {
1880
+ const [dx, dy] = dragFrom(ev);
1881
+ if (pane === "camera") setView(panBy(fromView, dx, dy));
1882
+ // The working pane pans its TARGET at the target's own depth, which
1883
+ // is what keeps the point under the cursor under the cursor. It needs
1884
+ // the pane height because that is what the fov spans, not the canvas.
1885
+ else setWorkingView(panWorkingBy(fromWorking, dx, dy, rect.height, WORKING_FOV_DEG));
1886
+ },
1887
+ (commit) => {
1888
+ // THE VIEW GOES BACK TOO, not only the framing the scrub restores.
1889
+ // Escape means the pan never happened, and a moved view left under a
1890
+ // restored camera is exactly the disagreement follow mode exists to
1891
+ // prevent — the next orbit would cross the gap in one jump.
1892
+ if (!commit) {
1893
+ if (pane === "camera") setView(fromView);
1894
+ else setWorkingView(fromWorking);
1895
+ }
1896
+ if (following) client.handlers()?.onFramingGestureEnd(commit);
1897
+ // The hold PANNED, so releasing space must not also play. Marked on
1898
+ // its OWN bit and not by clearing `spaceRef`: the bar is still down,
1899
+ // and the next auto-repeat keydown would set a shared flag straight
1900
+ // back and play the shot anyway.
1901
+ spacePanned.current = true;
1902
+ refreshCursorRef.current();
1903
+ },
1904
+ );
1905
+ return;
1906
+ }
1907
+
1908
+ // ── MIDDLE-DRAG ORBITS THE WORKING PANE (Blender's muscle memory) ─────
1909
+ //
1910
+ // Before the grab and the frustum lanes on purpose: middle-drag OVER a
1911
+ // layer must orbit, not move it. The same travel gate, follow bracketing
1912
+ // and Escape restore as the empty-space orbit below — a middle CLICK does
1913
+ // nothing and costs nothing.
1914
+ if (pane === "working" && middle) {
1915
+ const fromWorking = workingView;
1916
+ const following = follows;
1917
+ let dragged = false;
1918
+ // A closed hand, from the press, exactly as the pan lane does it. Not
1919
+ // gated on the travel threshold like the gesture start is: an undo entry
1920
+ // for a click would be a real cost and a cursor for one is not, and
1921
+ // waiting would mean the affordance appears after the author has already
1922
+ // committed to the drag.
1923
+ setNavCursor("grabbing");
1924
+ dragGesture(
1925
+ el,
1926
+ e.pointerId,
1927
+ (ev) => {
1928
+ const [dx, dy] = dragFrom(ev);
1929
+ if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
1930
+ if (!dragged && following) client.handlers()?.onFramingGestureStart();
1931
+ dragged = true;
1932
+ setWorkingView(orbitBy(fromWorking, dx, dy));
1933
+ },
1934
+ (commit) => {
1935
+ setNavCursor(null);
1936
+ if (!dragged) return;
1937
+ if (!commit) setWorkingView(fromWorking);
1938
+ if (following) client.handlers()?.onFramingGestureEnd(commit);
1939
+ },
1940
+ );
1941
+ return;
1942
+ }
1943
+
1944
+ // ── THE INSERT LANES, ABOVE THE GRAB AND THE PICK ─────────────────────
1945
+ //
1946
+ // An armed insert tool outranks everything the canvas already holds. With
1947
+ // the rect tool armed, a drag that happens to start over the selection must
1948
+ // draw a rectangle rather than move the layer under it — that is what
1949
+ // "armed" means, and every design tool behaves this way.
1950
+ //
1951
+ // CAMERA PANE ONLY. A box is drawn on the design plane, and in a free orbit
1952
+ // there is no screen rectangle that corresponds to one: the same drag would
1953
+ // describe a different quadrilateral from every angle. The working pane
1954
+ // keeps its orbit, which is the gesture it has nowhere else to put.
1955
+ if (pane === "camera") {
1956
+ if (isDrawTool(state.tool)) {
1957
+ runDrawGesture(state.tool, start, dragFrom, el, e.pointerId, rect);
1958
+ return;
1959
+ }
1960
+ if (state.tool === "path") {
1961
+ // A press places an anchor; HOLDING AND DRAGGING pulls its bezier
1962
+ // handles. The drag rides `dragGesture` like every other lane in this
1963
+ // file rather than hand-rolling a move/up pair: that helper is the one
1964
+ // place pointercancel and Escape are handled, and a lane without them
1965
+ // leaks its listener when the browser takes the pointer away.
1966
+ //
1967
+ // The two thresholds are SCREEN px (they are about the author's
1968
+ // finger), so they cross into the reducer divided by the zoom.
1969
+ const at = screenToDesign(start, view, cameraViewport, size);
1970
+ const zoom = Math.max(view.zoom, MIN_INVERTIBLE_SCALE);
1971
+ const next = penReduce(pen, { kind: "press", at, closeSlop: PEN_CLOSE_SLOP_PX / zoom });
1972
+ if (next.closed) {
1973
+ commitPen(next);
1974
+ return;
1975
+ }
1976
+ setPen(next);
1977
+ dragGesture(
1978
+ el,
1979
+ e.pointerId,
1980
+ (ev) => {
1981
+ // PANE-LOCAL, which is what `screenToDesign` measures against:
1982
+ // `localPoint` is canvas-local and the two differ by the pane's
1983
+ // origin, so the press point plus the delta is the conversion
1984
+ // (deltas being equal in both spaces is exactly why `dragFrom`
1985
+ // exists).
1986
+ const [dx, dy] = dragFrom(ev);
1987
+ const to = screenToDesign([start[0] + dx, start[1] + dy], view, cameraViewport, size);
1988
+ setPen((prev) => penReduce(prev, { kind: "move", at: to, dragThreshold: PEN_DRAG_THRESHOLD_PX / zoom }));
1989
+ },
1990
+ () => setPen((prev) => penReduce(prev, { kind: "release" })),
1991
+ );
1992
+ return;
1993
+ }
1994
+ }
1995
+
1996
+ // ── ONLY THE PRIMARY BUTTON DRIVES THE LANES BELOW ────────────────────
1997
+ //
1998
+ // Everything from here down assumed a left press. It never checked, so a
1999
+ // right press on a selected layer opened a transform scrub the author had
2000
+ // no idea was running, and a right drag in empty space marqueed. The middle
2001
+ // button's own lanes (orbit, pan, dolly) have all had their say above.
2002
+ //
2003
+ // It sits below the draw and pen lanes, which used to mean a right drag
2004
+ // with the rect tool armed still inserted a layer. That is FIXED: the
2005
+ // context-menu branch above returns on every `button === 2` press, so no
2006
+ // right press reaches this far and the gate now only turns away buttons
2007
+ // three and up. Left where it is because a lane that assumes a left press
2008
+ // should say so, whether or not something upstream already guaranteed it.
2009
+ if (e.button !== 0) return;
2010
+
2011
+ // ── WHAT IS UNDER THE POINTER, ASKED ONCE ─────────────────────────────
2012
+ //
2013
+ // `intentAt` is the arms / grab / frustum / empty-space ordering, and the
2014
+ // hover lane asks it the identical question to choose a cursor. One
2015
+ // function, so the shape the pointer takes and the thing the press does
2016
+ // cannot disagree — which is the whole failure a hover cursor exists to
2017
+ // prevent.
2018
+ const intent = intentAt(pane, start[0], start[1]);
2019
+ if (intent.kind === "arm") {
2020
+ runGizmoGesture(intent.arm, rect, start, canvasStart, el, e.pointerId);
2021
+ return;
2022
+ }
2023
+ if (intent.kind === "grab") {
2024
+ runTransformGesture(intent.grab, pane, rect, start, canvasStart, el, e.pointerId);
2025
+ return;
2026
+ }
2027
+ if (intent.kind === "frustum") {
2028
+ runFramingGesture(intent.part, start, canvasStart, el, e.pointerId);
2029
+ return;
2030
+ }
2031
+
2032
+ // ── EMPTY SPACE: A CLICK UNTIL IT TRAVELS ─────────────────────────────
2033
+ //
2034
+ // What it becomes then depends on the pane.
2035
+ //
2036
+ // CAMERA: a marquee, which is what a flat design surface means by
2037
+ // dragging nothing.
2038
+ //
2039
+ // WORKING: an ORBIT, because that is the only gesture with nowhere else
2040
+ // to live — pan is already space/middle and dolly is already the wheel,
2041
+ // and a 3D view whose primary drag was a marquee is a 3D view you
2042
+ // cannot turn. SHIFT makes it a marquee instead: shift already means
2043
+ // "extend the selection" in the pick policy, so the one marquee this
2044
+ // pane offers is the additive one, which is the one still worth having
2045
+ // once the plain drag is spent.
2046
+ //
2047
+ // THE TRAVEL GATE IS SHARED, and that is the load-bearing part. An orbit
2048
+ // that started on the pointerdown would swallow every click in the working
2049
+ // pane, and clicking a layer there would silently do nothing.
2050
+ // `B` ARMED A ONE-SHOT BOX SELECT, so a Blender user gets the marquee they
2051
+ // reached for without also having to know that shift means it here. Read
2052
+ // and cleared up at `paneAt`, so a press that never reaches this lane still
2053
+ // spends it.
2054
+ const orbits = pane === "working" && !e.shiftKey && !boxArmed;
2055
+ const following = orbits && follows;
2056
+ const fromWorking = workingView;
2057
+ let dragged = false;
2058
+ // The orbit branch only: a marquee draws its own band, and a closed hand
2059
+ // over a rubber band would say the wrong thing about what is moving.
2060
+ if (orbits) setNavCursor("grabbing");
2061
+ dragGesture(
2062
+ el,
2063
+ e.pointerId,
2064
+ (ev) => {
2065
+ const [dx, dy] = dragFrom(ev);
2066
+ if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
2067
+ // The travel gate is also where a follow scrub OPENS, and for the same
2068
+ // reason it exists at all: a click must not cost an undo entry.
2069
+ if (!dragged && following) client.handlers()?.onFramingGestureStart();
2070
+ dragged = true;
2071
+ if (orbits) {
2072
+ setWorkingView(orbitBy(fromWorking, dx, dy));
2073
+ return;
2074
+ }
2075
+ // CLAMPED TO ITS OWN PANE, at the source. The band stays in the pane
2076
+ // the drag started in (the overlay clips it there), so a marquee
2077
+ // dragged across the divider used to be a clipped VIEW of a larger
2078
+ // invisible rectangle — and `pickRect` selected against the invisible
2079
+ // one. Clamping here makes the drawn band and the selection the same
2080
+ // rectangle by construction rather than by two agreeing clips.
2081
+ const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
2082
+ setMarquee({ pane, x0: start[0], y0: start[1], x1: mx, y1: my });
2083
+ },
2084
+ (commit, ev) => {
2085
+ setNavCursor(null);
2086
+ // An orbit moved the author, not the selection.
2087
+ if (dragged && orbits) {
2088
+ if (!commit) setWorkingView(fromWorking);
2089
+ if (following) client.handlers()?.onFramingGestureEnd(commit);
2090
+ return;
2091
+ }
2092
+ setMarquee(null);
2093
+ // NO TERMINATING EVENT, NO SELECTION CHANGE. Escape and a cancelled
2094
+ // pointer both end here with `ev: null`, and the final point and the
2095
+ // modifiers are only readable off the event that ended the drag —
2096
+ // answering from the press point would pick something the author had
2097
+ // already dragged away from.
2098
+ if (!commit || ev === null) return;
2099
+ const [dx, dy] = dragFrom(ev);
2100
+ const modifiers = { shift: ev.shiftKey, meta: ev.metaKey || ev.ctrlKey, alt: ev.altKey };
2101
+ if (dragged) {
2102
+ // The SAME clamp the drawn band uses, so the selection is the
2103
+ // rectangle the author actually saw.
2104
+ const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
2105
+ const hits = feed.pickRect?.(pane, { x0: start[0], y0: start[1], x1: mx, y1: my }) ?? [];
2106
+ // A marquee REPLACES the selection unless shift is held — the same
2107
+ // rule every design tool has, and the reason the pick handler takes
2108
+ // the modifiers rather than deciding for itself.
2109
+ //
2110
+ // `marquee: true` is what tells the chrome this list is a SET rather
2111
+ // than a hit stack. Without it the click policy took `hits[0]` and a
2112
+ // box over five layers selected one.
2113
+ client.handlers()?.onPick(hits, { ...modifiers, meta: false, marquee: true }, pane);
2114
+ return;
2115
+ }
2116
+ client.handlers()?.onPick(feed.pick?.(pane, start[0] + dx, start[1] + dy) ?? [], modifiers, pane);
2117
+ },
2118
+ );
2119
+ };
2120
+
2121
+ /**
2122
+ * Rubber-band a new layer's box, and hand its two corners to the chrome.
2123
+ *
2124
+ * THE PREVIEW IS THE MARQUEE'S OWN RECTANGLE, reused rather than re-drawn:
2125
+ * both are "the box between the press and the cursor", and a second dashed
2126
+ * rect drawn a different way is a second thing to keep in step with the pane
2127
+ * translation and the clip.
2128
+ *
2129
+ * ponytail: a line or an arrow therefore previews as the BOX it spans rather
2130
+ * than as the stroke it will become. Give the overlay a second preview shape
2131
+ * when someone is drawing enough arrows to care.
2132
+ *
2133
+ * The corners cross as DESIGN COORDINATES because only this half can convert
2134
+ * (see `onDraw`), and a sub-threshold drag crosses as two IDENTICAL corners
2135
+ * because only this half knows how far the pointer travelled in screen px.
2136
+ */
2137
+ const runDrawGesture = (
2138
+ tool: DrawTool,
2139
+ start: readonly [number, number],
2140
+ dragFrom: (ev: PointerEvent) => [number, number],
2141
+ el: HTMLDivElement,
2142
+ pointerId: number,
2143
+ rect: PaneRect,
2144
+ ): void => {
2145
+ let dragged = false;
2146
+ dragGesture(
2147
+ el,
2148
+ pointerId,
2149
+ (ev) => {
2150
+ const [dx, dy] = dragFrom(ev);
2151
+ if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
2152
+ dragged = true;
2153
+ // CLAMPED, exactly as the marquee is and for the same reason. The
2154
+ // marquee lane got this and its sibling here did not: a draw dragged
2155
+ // across the divider previewed clipped (the overlay clips to the pane)
2156
+ // and then INSERTED a layer sized to the full invisible drag, so the
2157
+ // shape the author got was not the shape they saw.
2158
+ const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
2159
+ setMarquee({ pane: "camera", x0: start[0], y0: start[1], x1: mx, y1: my });
2160
+ },
2161
+ (commit, ev) => {
2162
+ setMarquee(null);
2163
+ // Escape and a cancelled pointer insert NOTHING. Same rule the marquee
2164
+ // lane keeps for the same reason: the final point only exists on the
2165
+ // event that ended the drag, and there is none.
2166
+ if (!commit || ev === null) return;
2167
+ const [dx, dy] = dragFrom(ev);
2168
+ const from = screenToDesign(start, view, cameraViewport, size);
2169
+ const at = clampInPane(rect, start[0] + dx, start[1] + dy);
2170
+ const to = dragged ? screenToDesign(at, view, cameraViewport, size) : from;
2171
+ client.handlers()?.onDraw(tool, [centreOrigin(from, size), centreOrigin(to, size)]);
2172
+ },
2173
+ );
2174
+ };
2175
+
2176
+ /**
2177
+ * Run a move / resize / tilt / depth drag.
2178
+ *
2179
+ * THREE THINGS MAKE THIS ONE UNDO ENTRY AND EXACTLY REVERSIBLE, and none of
2180
+ * them is in this function: `onGestureStart` opens a history scrub, every
2181
+ * delta is measured FROM THE PRESS so the scrub can re-derive from its base,
2182
+ * and Escape ends it with `commit: false`, which restores that base exactly
2183
+ * because the document never left it.
2184
+ *
2185
+ * The delta is computed by unprojecting BOTH rays onto one plane — which
2186
+ * plane is the V-vs-G rule, and it lives in interactions/move.ts.
2187
+ *
2188
+ * IT WORKS UNCHANGED IN THE WORKING PANE. `designDelta` takes two RAYS and a
2189
+ * PLANE and no camera, so which camera cast the rays is not a question it can
2190
+ * ask; a move dragged from an orbit view is the same arithmetic on rays that
2191
+ * happen to come in at an angle. Only the SCREEN-space kinds below are
2192
+ * camera-bound, and `grabAt` never returns one in the working pane.
2193
+ */
2194
+ const runTransformGesture = (
2195
+ grab: Grab,
2196
+ pane: StagePane,
2197
+ rect: PaneRect,
2198
+ start: readonly [number, number],
2199
+ canvasStart: readonly [number, number],
2200
+ el: HTMLDivElement,
2201
+ pointerId: number,
2202
+ ): void => {
2203
+ const handlers = client.handlers();
2204
+ if (handlers === undefined || handlers === null || feed.rayAt === null) return;
2205
+ // ── THE ROTATE RING: AN ANGLE SWEPT, NOT A DISTANCE DRAGGED ──────────
2206
+ //
2207
+ // Its own lane, above the ray work, because it needs neither a ray nor a
2208
+ // plane: a roll is measured on screen, about the projected centre of the
2209
+ // thing being turned. A linear px scale (which is what `R` uses, and what
2210
+ // it should use — it has no drawn handle to follow) would make the box slip
2211
+ // out from under the cursor the moment the author swept past a corner.
2212
+ //
2213
+ // TOTAL FROM THE PRESS, like every other gesture here, so a coalesced move
2214
+ // cannot drift and Escape restores the base exactly.
2215
+ if (grab.kind === "roll") {
2216
+ // BOTH CARRIED ON THE GRAB, from the quad that was actually pressed. They
2217
+ // used to be re-derived from `selection[0]`, which is the lowest id in
2218
+ // document order rather than the one under the cursor.
2219
+ const from = grab.handleAngle;
2220
+ const about = grab.centre;
2221
+ if (from === undefined || about === undefined) return;
2222
+ const [cx, cy] = about;
2223
+ const pivot = selectionPivot(feed, state.selection);
2224
+ handlers.onGestureStart("roll", state.selection, clampZoom(view.zoom));
2225
+ dragGesture(
2226
+ el,
2227
+ pointerId,
2228
+ (ev) => {
2229
+ const at = localPoint(ev);
2230
+ // Unwrapped through `atan2`'s branch cut, so a sweep past due west
2231
+ // does not jump by a full turn.
2232
+ let deg = ((Math.atan2(at[1] - rect.y - cy, at[0] - rect.x - cx) - from) * 180) / Math.PI;
2233
+ deg = ((((deg + 180) % 360) + 360) % 360) - 180;
2234
+ // SHIFT is the same precision the modal lane gives, and CTRL the same
2235
+ // 15 degree quantise Blender uses.
2236
+ if (ev.shiftKey) deg *= PRECISION_FACTOR;
2237
+ if (ev.ctrlKey || ev.metaKey) deg = Math.round(deg / ROLL_SNAP_DEG) * ROLL_SNAP_DEG;
2238
+ // Copied out of the stage's own snapshot: `GestureDelta` is the wire
2239
+ // shape the store re-derives from, and sharing an alias lets one
2240
+ // mutate the other.
2241
+ handlers.onGesture({ kind: "roll", dx: deg, dy: 0, pivot: pivot === null ? null : [...pivot] });
2242
+ },
2243
+ (commit) => handlers.onGestureEnd(commit),
2244
+ );
2245
+ return;
2246
+ }
2247
+ // A RESIZE IS ON THE LAYER'S OWN PLANE, always. The handle is drawn on that
2248
+ // plane and the edge it moves lives in it, so the frame plane would measure
2249
+ // a foreshortened distance on any tilted layer. Select's move joins it (the
2250
+ // V-vs-G rule, interactions/move.ts); G's move is deliberately flat.
2251
+ //
2252
+ // A UNION SCALE joins them for the same reason and with a stronger claim:
2253
+ // its box IS a plane the members all lie in, by `unionQuad`'s own predicate.
2254
+ const plane = gesturePlane(
2255
+ grab.kind === "resize" ||
2256
+ grab.kind === "scale" ||
2257
+ (grab.kind === "move" && state.tool === "select")
2258
+ ? "layer"
2259
+ : "frame",
2260
+ grab.matrixWorld,
2261
+ );
2262
+ // No ray means the pane has no camera on screen, so there is no gesture to
2263
+ // start — better than opening a history scrub that can never be fed.
2264
+ const from = feed.rayAt(pane, start[0], start[1]);
2265
+ if (from === null) return;
2266
+ // ── THE UNION SCALE'S TWO SNAPSHOTS ───────────────────────────────────
2267
+ //
2268
+ // The box and the press point, both frozen at the press and both in DESIGN
2269
+ // px. The box is rebuilt from its members every frame, so reading it live
2270
+ // would move the pivot with the layers the scale is moving; the anchor is
2271
+ // where the ratio is measured from, and re-deriving it per move is how a
2272
+ // gesture stops being a total from the press.
2273
+ //
2274
+ // A press with no honest point on the plane (edge-on) opens no scrub at
2275
+ // all, the same refusal `designDelta` makes mid-drag.
2276
+ const box = grab.kind === "scale" ? designBox(grab.union) : null;
2277
+ const anchor = grab.kind === "scale" ? designPoint(from, plane) : null;
2278
+ if (grab.kind === "scale" && (box === null || anchor === null)) return;
2279
+ // THE PANE'S OWN SCREEN SCALE, which only this half can answer. The chrome
2280
+ // divides its snap threshold by it, and the two panes derive it from
2281
+ // different things entirely: a flat zoom here, an orbit distance there.
2282
+ handlers.onGestureStart(
2283
+ grab.kind,
2284
+ state.selection,
2285
+ pane === "camera" ? clampZoom(view.zoom) : paneScreenScale(rect),
2286
+ );
2287
+
2288
+ dragGesture(
2289
+ el,
2290
+ pointerId,
2291
+ (ev) => {
2292
+ const at = localPoint(ev);
2293
+ const dx = at[0] - canvasStart[0];
2294
+ const dy = at[1] - canvasStart[1];
2295
+ const to = feed.rayAt?.(pane, start[0] + dx, start[1] + dy) ?? null;
2296
+ const delta = to === null ? null : designDelta(from, to, plane);
2297
+ // A null delta is an EDGE-ON layer, which has no answer — skipping the
2298
+ // gesture is the only honest response, and writing NaN is the
2299
+ // alternative.
2300
+ if (delta === null) return;
2301
+ // ── A RESIZE IS THE SAME UNPROJECTION AS A MOVE ───────────────────
2302
+ //
2303
+ // It used to be a screen-px drag divided by `view.zoom`, and that was
2304
+ // wrong in two independent ways at once. `view.zoom` is screen px per
2305
+ // design px AT z = 0 (view.ts), but the camera's distance is
2306
+ // `planeDistance(H, fov, framing.zoom)` (resolve.ts) and nothing
2307
+ // multiplied the two — so every resize was off by the framing's own
2308
+ // zoom even on the canvas plane, and off by the perspective divide at
2309
+ // any depth. Neither error was visible as a jump; the handle just did
2310
+ // not keep up with the cursor, which reads as "resize feels wrong".
2311
+ //
2312
+ // Two rays on the layer's own plane have no zoom term at all, so this
2313
+ // is exact for BOTH panes and for a perspective or an orthographic
2314
+ // camera alike. That is what lets the working pane have handles.
2315
+ if (grab.kind === "resize") {
2316
+ handlers.onGesture({
2317
+ kind: "resize",
2318
+ dx: delta.dx,
2319
+ dy: delta.dy,
2320
+ dz: delta.dz,
2321
+ ...(grab.handle === undefined ? {} : { handle: grab.handle }),
2322
+ keepAspect: ev.shiftKey,
2323
+ aboutCentre: ev.altKey,
2324
+ });
2325
+ return;
2326
+ }
2327
+ // ── A UNION HANDLE IS A RATIO, NOT AN EDGE ────────────────────────
2328
+ //
2329
+ // Same two rays and the same plane as a resize; only the arithmetic on
2330
+ // the far side differs. ALT re-pivots to the box's centre mid-drag,
2331
+ // which is the modifier a single-layer resize spends on `aboutCentre`
2332
+ // and means the same thing here.
2333
+ if (grab.kind === "scale") {
2334
+ if (box === null || anchor === null || grab.handle === undefined) return;
2335
+ const scale = unionScale(box, grab.handle, [anchor[0], anchor[1]], delta, ev.altKey);
2336
+ // A degenerate arm has no ratio — see `unionScale`. Skipping the emit
2337
+ // holds the last good one rather than writing a NaN size.
2338
+ if (scale === null) return;
2339
+ handlers.onGesture({
2340
+ kind: "scale",
2341
+ dx: 0,
2342
+ dy: 0,
2343
+ ratio: scale.ratio,
2344
+ axis: scale.axis,
2345
+ // The design z the whole box lies in, so a member's own depth
2346
+ // offset from the pivot is zero and a uniform scale cannot drift
2347
+ // the selection towards or away from the camera.
2348
+ pivot: [scale.pivot[0], scale.pivot[1], anchor[2]],
2349
+ });
2350
+ return;
2351
+ }
2352
+ handlers.onGesture({ kind: "move", ...delta });
2353
+ },
2354
+ (commit) => handlers.onGestureEnd(commit),
2355
+ );
2356
+ };
2357
+
2358
+ /**
2359
+ * Drag one arm of the move gizmo: a translation along ONE design axis, of
2360
+ * whichever subject the gizmo is hanging on.
2361
+ *
2362
+ * ── IT IS THE SAME SCRUB EVERY OTHER TRANSFORM OPENS ────────────────────
2363
+ *
2364
+ * For layers: `onGestureStart("move", …)` / N× `onGesture` / `onGestureEnd`.
2365
+ * For the camera: the framing trio the frustum's own two grips already use.
2366
+ * Either way every emit is a TOTAL FROM THE PRESS — the two rules that make a
2367
+ * drag one undo entry and exactly reversible (see `dragGesture`). So an arm
2368
+ * drag, a body drag and `G X` all land in the store the same way, and the
2369
+ * store's base/block/refusal fork sees one kind of thing.
2370
+ *
2371
+ * THE SUBJECT COMES OFF THE GRAB, snapshotted at the press, and is never
2372
+ * re-read from the selection mid-drag. A selection arriving from the chrome
2373
+ * while an arm is held would otherwise turn a camera dolly into a layer move
2374
+ * halfway through, inside one history scrub.
2375
+ *
2376
+ * ── WHY IT SOLVES A LINE RATHER THAN A PLANE ────────────────────────────
2377
+ *
2378
+ * `axisParam` is the closest point on the axis line to the pointer ray, so
2379
+ * the grabbed arm stays under the cursor at every orbit. Unprojecting onto a
2380
+ * plane and zeroing the other two components — what the MODAL lane does,
2381
+ * because it has no drawn arm to stay under — drifts as the view turns and
2382
+ * runs away entirely at a grazing angle.
2383
+ *
2384
+ * The axis parameter is already in DESIGN units, and needs no `-y` flip: a
2385
+ * translation of `s` along the arm's world direction is `s` along the design
2386
+ * axis it was built from, because `DESIGN_AXES` carries unit vectors and the
2387
+ * one mapping has no scale in it.
2388
+ *
2389
+ * Null from `axisParam` is a ray PARALLEL to the arm — every point on it is
2390
+ * equally close — and skips the emit rather than writing NaN, the same
2391
+ * refusal `designDelta` makes for an edge-on layer.
2392
+ */
2393
+ const runGizmoGesture = (
2394
+ arm: GizmoGrab,
2395
+ rect: PaneRect,
2396
+ start: readonly [number, number],
2397
+ canvasStart: readonly [number, number],
2398
+ el: HTMLDivElement,
2399
+ pointerId: number,
2400
+ ): void => {
2401
+ const handlers = client.handlers();
2402
+ if (handlers === undefined || handlers === null) return;
2403
+ const from = feed.rayAt?.("working", start[0], start[1]) ?? null;
2404
+ const anchor = from === null ? null : paramAt(arm, from);
2405
+ // No ray, or a press that grazed the arm end-on: there is nothing to
2406
+ // measure against, and opening a scrub that can never be fed is worse than
2407
+ // letting the press fall through to nothing.
2408
+ if (anchor === null) return;
2409
+ const camera = arm.subject === "camera";
2410
+ // The apex's own world z IS the camera's distance to the canvas — the
2411
+ // gizmo hangs on `[x, -y, distance]` (frustumGeometry) — so the dolly's
2412
+ // ratio needs no second lookup and cannot go stale against the pose the
2413
+ // arm was drawn at.
2414
+ const distance = arm.origin[2];
2415
+ if (camera) handlers.onFramingGestureStart();
2416
+ else {
2417
+ handlers.onGestureStart("move", state.selection, paneScreenScale(rect));
2418
+ }
2419
+
2420
+ dragGesture(
2421
+ el,
2422
+ pointerId,
2423
+ (ev) => {
2424
+ const at = localPoint(ev);
2425
+ const to = feed.rayAt?.(
2426
+ "working",
2427
+ start[0] + at[0] - canvasStart[0],
2428
+ start[1] + at[1] - canvasStart[1],
2429
+ );
2430
+ const now = to === null || to === undefined ? null : paramAt(arm, to);
2431
+ if (now === null) return;
2432
+ const d = now - anchor;
2433
+ if (camera) {
2434
+ const delta = framingArmDelta(arm.axis, d, distance);
2435
+ if (delta !== null) handlers.onFramingGesture(delta);
2436
+ return;
2437
+ }
2438
+ handlers.onGesture({
2439
+ kind: "move",
2440
+ dx: arm.axis === "x" ? d : 0,
2441
+ dy: arm.axis === "y" ? d : 0,
2442
+ dz: arm.axis === "z" ? d : 0,
2443
+ });
2444
+ },
2445
+ (commit) => {
2446
+ if (camera) handlers.onFramingGestureEnd(commit);
2447
+ else handlers.onGestureEnd(commit);
2448
+ },
2449
+ );
2450
+ };
2451
+
2452
+ /**
2453
+ * Drag the CAMERA: the body pans its framing, the apex zooms it.
2454
+ *
2455
+ * Working pane only, because the gizmo only exists there — it is the camera
2456
+ * pane's camera drawn as an object, and it cannot appear inside the view it
2457
+ * represents.
2458
+ *
2459
+ * The two halves take their delta from different places on purpose. The BODY
2460
+ * unprojects both rays onto z = 0, the plane its rectangle sits in, so the
2461
+ * grabbed point stays under the cursor whatever angle the author is orbiting
2462
+ * from — the same unprojection a layer move uses, and `designDelta` needs no
2463
+ * camera to do it. The APEX is a screen-space drag: its distance from the
2464
+ * plane IS the zoom (`planeDistance`), the pointer has no depth, and there is
2465
+ * no projection that would make dragging a point along the view axis feel
2466
+ * like anything. Exponential at the wheel's own falloff, so an apex drag and
2467
+ * a wheel notch are the same step.
2468
+ *
2469
+ * What the deltas MEAN — a base write, a refusal, or a block — is the store's
2470
+ * fork, not the stage's. See camera.ts.
2471
+ */
2472
+ const runFramingGesture = (
2473
+ part: "apex" | "body",
2474
+ start: readonly [number, number],
2475
+ canvasStart: readonly [number, number],
2476
+ el: HTMLDivElement,
2477
+ pointerId: number,
2478
+ ): void => {
2479
+ const handlers = client.handlers();
2480
+ if (handlers === undefined || handlers === null) return;
2481
+ const from = part === "body" ? (feed.rayAt?.("working", start[0], start[1]) ?? null) : null;
2482
+ // No ray means no camera on screen for this pane, so there is nothing to
2483
+ // measure against — better than a scrub that can never be fed.
2484
+ if (part === "body" && from === null) return;
2485
+ handlers.onFramingGestureStart();
2486
+
2487
+ dragGesture(
2488
+ el,
2489
+ pointerId,
2490
+ (ev) => {
2491
+ const at = localPoint(ev);
2492
+ const dx = at[0] - canvasStart[0];
2493
+ const dy = at[1] - canvasStart[1];
2494
+ if (part === "apex") {
2495
+ handlers.onFramingGesture({ dx: 0, dy: 0, dzoom: Math.exp(-dy / DOLLY_FALLOFF_PX) });
2496
+ return;
2497
+ }
2498
+ const to = feed.rayAt?.("working", start[0] + dx, start[1] + dy) ?? null;
2499
+ const delta = to === null || from === null ? null : designDelta(from, to, Z0_PLANE);
2500
+ if (delta !== null) handlers.onFramingGesture({ dx: delta.dx, dy: delta.dy, dzoom: 1 });
2501
+ },
2502
+ (commit) => handlers.onFramingGestureEnd(commit),
2503
+ );
2504
+ };
2505
+
2506
+ const onWheel = (e: WheelLike): void => {
2507
+ if (doc === null) return;
2508
+ // A LIVE MODAL OWNS THE VIEW, the way it owns the keyboard. Its anchor ray
2509
+ // and screen scale are captured once at `startModal`; a dolly re-poses the
2510
+ // working camera under them, so the next emit differences a post-dolly ray
2511
+ // against a pre-dolly anchor and commits the camera's motion as the
2512
+ // layer's. Ignoring the wheel keeps the transform anchored to where the
2513
+ // author started it — Blender's own answer is the same, and the author can
2514
+ // confirm or cancel in one keystroke if they wanted to dolly first.
2515
+ if (modalRef.current !== null) return;
2516
+ const at = localPoint(e);
2517
+ const hit = paneAt(panes, at[0], at[1]);
2518
+ if (hit === null) return;
2519
+ client.handlers()?.onActivePane(hit.pane);
2520
+ if (hit.pane === "working") {
2521
+ // `deltaMode` scaled exactly as `wheelToView` scales it — Firefox reports
2522
+ // lines where Chrome reports pixels — so one notch is the same step in
2523
+ // whichever pane it lands in.
2524
+ const rect = panes.working as PaneRect;
2525
+ const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rect.height : 1;
2526
+ // ── A TRACKPAD CAN PAN THIS PANE AT LAST ────────────────────────────
2527
+ //
2528
+ // A two-finger sideways scroll reports `deltaX`, and a shifted vertical
2529
+ // one is the same gesture on a mouse wheel. Both used to fall through to
2530
+ // the dolly, so the working pane had NO pan for anyone without a middle
2531
+ // button: the camera pane panned on a plain wheel and this one zoomed,
2532
+ // which is also why the two felt inconsistent.
2533
+ //
2534
+ // ctrl/meta STAYS ON THE DOLLY, deliberately. `panZoom.ts` is explicit
2535
+ // that a trackpad reports a PINCH as ctrl + wheel, so that binding is
2536
+ // what makes two-finger zoom work at all.
2537
+ const pans = !e.ctrlKey && !e.metaKey && (e.deltaX !== 0 || e.shiftKey);
2538
+ if (pans) {
2539
+ // NEGATED, because a wheel delta is the opposite sense from a drag
2540
+ // delta — the same reason `wheelToView` subtracts.
2541
+ const dx = (e.shiftKey ? e.deltaY : e.deltaX) * scale;
2542
+ const dy = (e.shiftKey ? 0 : e.deltaY) * scale;
2543
+ setWorkingView(panWorkingBy(workingView, -dx, -dy, rect.height, WORKING_FOV_DEG), "wheel");
2544
+ return;
2545
+ }
2546
+ // ORIGIN "wheel": under follow this is a document write with no press and
2547
+ // no release to bracket it, so the chrome closes it over silence instead.
2548
+ setWorkingView(dollyBy(workingView, e.deltaY * scale, dolly), "wheel");
2549
+ return;
2550
+ }
2551
+ setView(wheelToView(e, hit.local, view, cameraViewport, size));
2552
+ };
2553
+
2554
+ /**
2555
+ * The wheel is registered BY HAND, non-passive, and it is the only way this
2556
+ * works.
2557
+ *
2558
+ * React attaches `onWheel` as a PASSIVE listener on the root container
2559
+ * (react-dom does this for wheel, touchstart and touchmove), and a passive
2560
+ * listener cannot `preventDefault`. So ctrl/meta + wheel — which is exactly
2561
+ * the gesture a trackpad pinch reports, and the one `wheelToView` reads as
2562
+ * zoom — reached the BROWSER as a page-zoom request at the same time as it
2563
+ * reached us. Both fired: the canvas zoomed and the entire chrome scaled
2564
+ * with it. Preventing the default on a listener we own is the whole fix.
2565
+ *
2566
+ * Every wheel event is prevented, not only the zooming ones: a plain wheel
2567
+ * pans this canvas, and letting it also scroll whatever the browser thinks
2568
+ * is scrollable behind us is the same class of double-handling.
2569
+ *
2570
+ * The handler is read through a ref so the listener registers ONCE. Re-adding
2571
+ * it whenever `view` changed would mean removing and re-adding a listener on
2572
+ * every frame of a zoom gesture.
2573
+ *
2574
+ * EVENTS ARE COALESCED TO THE FRAME, deltas summed. A trackpad fires wheel
2575
+ * far above display rate and each application costs a stage commit and a GL
2576
+ * draw. Summing is exact for every consumer: the pan is linear in the
2577
+ * deltas, and the zoom and the dolly are exponential (`exp(-(a+b)/k) =
2578
+ * exp(-a/k)·exp(-b/k)`), so one flush of a summed delta lands on the same
2579
+ * view as applying each event. The default is still prevented PER EVENT —
2580
+ * only the application defers. A modifier or deltaMode change flushes what
2581
+ * is pending first, so a pinch starting mid-scroll cannot fold into a pan.
2582
+ */
2583
+ const wheelRef = useRef(onWheel);
2584
+ wheelRef.current = onWheel;
2585
+ useEffect(() => {
2586
+ const el = areaRef.current;
2587
+ if (el === null) return;
2588
+ let pending: WheelLike | null = null;
2589
+ let raf = 0;
2590
+ const flush = (): void => {
2591
+ raf = 0;
2592
+ if (pending === null) return;
2593
+ const batch = pending;
2594
+ pending = null;
2595
+ wheelRef.current(batch);
2596
+ };
2597
+ const onWheelNative = (e: WheelEvent): void => {
2598
+ e.preventDefault();
2599
+ // SHIFT joins the flush set: it decides whether a vertical wheel pans or
2600
+ // dollies the working pane, so folding a shifted notch into an unshifted
2601
+ // batch would apply both to whichever the batch ended up claiming.
2602
+ if (
2603
+ pending !== null &&
2604
+ (pending.ctrlKey !== e.ctrlKey ||
2605
+ pending.metaKey !== e.metaKey ||
2606
+ pending.shiftKey !== e.shiftKey ||
2607
+ pending.deltaMode !== e.deltaMode)
2608
+ ) {
2609
+ flush();
2610
+ }
2611
+ if (pending === null) {
2612
+ pending = {
2613
+ clientX: e.clientX,
2614
+ clientY: e.clientY,
2615
+ deltaX: e.deltaX,
2616
+ deltaY: e.deltaY,
2617
+ deltaMode: e.deltaMode,
2618
+ ctrlKey: e.ctrlKey,
2619
+ metaKey: e.metaKey,
2620
+ shiftKey: e.shiftKey,
2621
+ };
2622
+ } else {
2623
+ pending.deltaX += e.deltaX;
2624
+ pending.deltaY += e.deltaY;
2625
+ pending.clientX = e.clientX;
2626
+ pending.clientY = e.clientY;
2627
+ }
2628
+ if (raf === 0) raf = requestAnimationFrame(flush);
2629
+ };
2630
+ el.addEventListener("wheel", onWheelNative, { passive: false });
2631
+ return () => {
2632
+ if (raf !== 0) cancelAnimationFrame(raf);
2633
+ el.removeEventListener("wheel", onWheelNative);
2634
+ };
2635
+ }, []);
2636
+
2637
+ const laidOut = viewport.width > 0 && viewport.height > 0;
2638
+ const offset =
2639
+ laidOut && panes.camera !== null ? viewOffsetFor(view, panes.camera, size) : null;
2640
+
2641
+ // The overlay's derived props, IDENTITY-STABLE across everything that does
2642
+ // not move them — a seek re-renders this component, and fresh objects here
2643
+ // would defeat MemoOverlays entirely. The guides are almost always empty,
2644
+ // and empty must be the SAME empty every render.
2645
+ const overlayFrame = useMemo(
2646
+ () => frameRect(view, cameraViewport, size),
2647
+ [view, cameraViewport, size.width, size.height],
2648
+ );
2649
+ const overlayGuides = useMemo(
2650
+ () =>
2651
+ state.guides.length === 0
2652
+ ? EMPTY_GUIDES
2653
+ : state.guides.map((g) => ({
2654
+ axis: g.axis,
2655
+ // Design FRAME coordinates arrive with the origin at the canvas
2656
+ // centre, the way the schema measures; `designToScreen` wants them
2657
+ // from the frame's top-left. Measured against the CAMERA PANE,
2658
+ // because that is the viewport its projection was computed for.
2659
+ at:
2660
+ g.axis === "x"
2661
+ ? designToScreen([size.width / 2 + g.at, 0], view, cameraViewport, size)[0]
2662
+ : designToScreen([0, size.height / 2 + g.at], view, cameraViewport, size)[1],
2663
+ })),
2664
+ [state.guides, view, cameraViewport, size.width, size.height],
2665
+ );
2666
+ // Where the playhead lands in the document, resolved ONCE. The post stack and
2667
+ // the camera rig both hang off it, and both are read out here rather than
2668
+ // inside the canvas because the playhead and the document live out here —
2669
+ // neither consumer may hold its own copy of either.
2670
+ // Every derivation below rides the SAME size-1 caches ShotRuntime reads
2671
+ // (frameCache.ts), so the stage and the runtime cannot compute the frame
2672
+ // twice — the second caller replays the first one's answer.
2673
+ const effects = at?.scene.effects ?? null;
2674
+ const defs = useMemo(() => (doc === null ? {} : componentDefsOfCached(doc)), [doc]);
2675
+ const hasMesh = at === null ? false : sceneHasMeshCached(at.scene, defs);
2676
+ // The bin partition, from the same resolved position as the post stack. Both
2677
+ // panes draw through it (SplitRenderer), so the editor cannot disagree with
2678
+ // the export about what a model covers. Empty when nothing is on screen AND
2679
+ // for every scene without a live mesh layer — `renderBins` treats empty and
2680
+ // single identically (one plain draw), and gating on `hasMesh` here matches
2681
+ // exactly what ShotScene computes, which is what lets the cache be shared.
2682
+ // The definitions ride into both: a def-blind partition leaves the mesh bin
2683
+ // closed for a scene whose only model is inside a component, and the two
2684
+ // panes would then disagree with the export about what a model covers.
2685
+ const bins = useMemo(
2686
+ () => (at === null || !hasMesh ? EMPTY_BINS : binsAtCached(at.scene, at.sceneFrame, defs)),
2687
+ [at, defs, hasMesh],
2688
+ );
2689
+ // The pill's stable prop — built from the modal STATE alone, so MemoOverlays
2690
+ // re-renders on start/axis/end and never on mouse motion.
2691
+ const modalBadge = useMemo<ScreenModal | null>(
2692
+ () =>
2693
+ modal === null
2694
+ ? null
2695
+ : {
2696
+ label: modalLabel(modal),
2697
+ camera: modal.target === "camera",
2698
+ // The glyph and its axis tint. Both ride the same memo as the label
2699
+ // because both are modal STATE — they change when the author arms a
2700
+ // mode or presses an axis key, never when the pointer moves.
2701
+ mode: modal.mode,
2702
+ axis: modal.axis,
2703
+ },
2704
+ [modal],
2705
+ );
2706
+
2707
+ /**
2708
+ * The corner axis indicator, which is a pure function of the view's two
2709
+ * angles — so it re-derives exactly when the working pane's picture does,
2710
+ * and never on a seek or a selection change.
2711
+ *
2712
+ * A click SNAPS, through the same `presetWorkingView` the numpad keys go
2713
+ * through and with the same origin an orbit uses: with follow armed, snapping
2714
+ * the view is a camera write, which is what follow means.
2715
+ */
2716
+ /**
2717
+ * A press on the nav gizmo: DRAG TO ORBIT, click to snap.
2718
+ *
2719
+ * The identical lane an orbit on empty space runs — same `dragGesture`, same
2720
+ * `DRAG_THRESHOLD_PX`, same follow bracketing, same Escape restore — so the
2721
+ * widget cannot develop its own idea of what turning the view means. It only
2722
+ * SNAPS when the travel never crossed the threshold, which is what makes one
2723
+ * gesture serve both.
2724
+ *
2725
+ * The event is stopped HERE and not in the overlay: the overlay would have to
2726
+ * stop it to keep the container's own lane out, and then there would be no
2727
+ * event left to open a drag with.
2728
+ */
2729
+ const grabNav = useCallback(
2730
+ (e: React.PointerEvent, ball: NavBall | null) => {
2731
+ e.stopPropagation();
2732
+ e.preventDefault();
2733
+ const el = areaRef.current;
2734
+ if (el === null) return;
2735
+ const start = localPoint(e);
2736
+ const from = client.getState().workingView;
2737
+ const following = client.getState().cameraFollowsView;
2738
+ let dragged = false;
2739
+ el.setPointerCapture(e.pointerId);
2740
+ dragGesture(
2741
+ el,
2742
+ e.pointerId,
2743
+ (ev) => {
2744
+ const at = localPoint(ev);
2745
+ const dx = at[0] - start[0];
2746
+ const dy = at[1] - start[1];
2747
+ if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
2748
+ // The travel gate is where a follow scrub opens, for the reason it
2749
+ // exists at all: a click must not cost an undo entry.
2750
+ if (!dragged && following) client.handlers()?.onFramingGestureStart();
2751
+ dragged = true;
2752
+ setWorkingView(orbitBy(from, dx, dy));
2753
+ },
2754
+ (commit) => {
2755
+ if (!dragged) {
2756
+ // A CLICK. The disc has no axis to snap to, so it does nothing —
2757
+ // which is right: its whole job is the drag.
2758
+ if (commit && ball !== null) setWorkingView(viewForBall(from, ball));
2759
+ return;
2760
+ }
2761
+ if (!commit) setWorkingView(from);
2762
+ if (following) client.handlers()?.onFramingGestureEnd(commit);
2763
+ },
2764
+ );
2765
+ },
2766
+ [client, setWorkingView],
2767
+ );
2768
+ /**
2769
+ * The selected toon mesh, or null. The whole gate for the key-light handle.
2770
+ *
2771
+ * ONE layer, because a key direction is one value and a handle over a
2772
+ * multi-selection would silently write it to whichever member it felt like.
2773
+ * TOON only, because `keyDir` is inert under `unlit` — a handle that moved a
2774
+ * number nothing reads is worse than no handle. And never while the derived
2775
+ * isolation rig is drawing, for the nav gizmo's reason: the arm is projected
2776
+ * through `workingView`'s basis, which nothing is using then.
2777
+ */
2778
+ const keyLightLayer = useMemo(() => {
2779
+ if (panes.working === null || headOnRig !== null || state.selection.length !== 1) return null;
2780
+ const id = state.selection[0]!;
2781
+ const layer = at?.scene.layersById[id];
2782
+ if (layer === undefined || layer.kind !== "mesh") return null;
2783
+ if (layer.mesh.shading?.mode !== "toon") return null;
2784
+ return { id, dir: layer.mesh.shading.keyDir ?? DEFAULT_KEY_DIR };
2785
+ }, [panes.working, headOnRig, state.selection, at]);
2786
+
2787
+ /**
2788
+ * Drag the key. The gesture trio's shape, and the nav gizmo's plumbing.
2789
+ *
2790
+ * Deltas are measured FROM THE PRESS against the direction at the press, so a
2791
+ * coalesced drag lands exactly where an uncoalesced one does and Escape
2792
+ * restores by discarding rather than by replaying.
2793
+ */
2794
+ const grabKeyLight = useCallback(
2795
+ (e: React.PointerEvent) => {
2796
+ // Stopped HERE and not in the overlay, for `grabNav`'s reason: the
2797
+ // overlay would have to stop it to keep the container's lane out, and
2798
+ // then there would be no event left to open a drag with.
2799
+ e.stopPropagation();
2800
+ e.preventDefault();
2801
+ const el = areaRef.current;
2802
+ const light = keyLightLayer;
2803
+ if (el === null || light === null) return;
2804
+ const start = localPoint(e);
2805
+ const from = light.dir;
2806
+ const view = client.getState().workingView;
2807
+ let opened = false;
2808
+ el.setPointerCapture(e.pointerId);
2809
+ dragGesture(
2810
+ el,
2811
+ e.pointerId,
2812
+ (ev) => {
2813
+ const at2 = localPoint(ev);
2814
+ const dx = at2[0] - start[0];
2815
+ const dy = at2[1] - start[1];
2816
+ if (!opened && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
2817
+ if (!opened) {
2818
+ // The travel gate is where the scrub opens, for the reason it
2819
+ // exists at all: a click must not cost an undo entry. A refusal
2820
+ // here abandons the drag rather than emitting into nothing.
2821
+ if (client.handlers()?.onKeyLightStart(light.id) !== true) return;
2822
+ opened = true;
2823
+ }
2824
+ client.handlers()?.onKeyLight(rotateKeyDir(from, view, dx, dy));
2825
+ },
2826
+ (commit) => {
2827
+ if (!opened) return;
2828
+ client.handlers()?.onKeyLightEnd(commit);
2829
+ },
2830
+ );
2831
+ },
2832
+ [client, keyLightLayer],
2833
+ );
2834
+
2835
+ const keyLightArm = useMemo(
2836
+ () =>
2837
+ keyLightLayer === null
2838
+ ? null
2839
+ : { layerId: keyLightLayer.id, dir: keyDirOnScreen(keyLightLayer.dir, workingView), onGrab: grabKeyLight },
2840
+ [keyLightLayer, workingView, grabKeyLight],
2841
+ );
2842
+
2843
+ const nav = useMemo(
2844
+ // HIDDEN WHILE THE DERIVED RIG DRAWS. The balls are a projection of
2845
+ // `workingView`'s basis, and nothing is using that basis then — so the
2846
+ // widget drew one camera's axes over another camera's picture, offered
2847
+ // `cursor: pointer`, and did nothing when clicked. Once the author has
2848
+ // taken the camera it is correct again and its clicks land.
2849
+ () =>
2850
+ panes.working === null || headOnRig !== null
2851
+ ? null
2852
+ : { balls: navBalls(workingView), onGrab: grabNav },
2853
+ [panes.working, headOnRig, workingView, grabNav],
2854
+ );
2855
+
2856
+ // A transition window mounts its own priority-1 compositor inside
2857
+ // ShotRuntime, so the probe has to count it too — the comment below promises
2858
+ // this expression mirrors EXACTLY when a priority-1 renderer exists, and a
2859
+ // probe that claims priority when one already has it is the bug it guards.
2860
+ const inTransition = doc !== null && transitionAtCached(doc, state.frame) !== null;
2861
+ // The gizmo's pose. `cameraRigAt` is the ONE answer to "where is the camera
2862
+ // at this frame", including the dolly-vs-zoom distinction — deriving it a
2863
+ // second time inside the gizmo would be a second camera model.
2864
+ const rig = useMemo(
2865
+ () => (at === null || doc === null ? null : cameraRigAtCached(at.scene, doc.size.height, at.sceneFrame, doc.fps)),
2866
+ [at, doc],
2867
+ );
2868
+
2869
+ /** Where the camera IS, for the arms to hang on when nothing is selected.
2870
+ * Off `rig` through the same `frustumShape` the gizmo's own vertices come
2871
+ * from, so the arms cannot sit anywhere but on the drawn apex. */
2872
+ const cameraApex = useMemo<Vec3 | null>(
2873
+ () => (rig === null ? null : frustumShape(rig, size).apex),
2874
+ [rig, size],
2875
+ );
2876
+
2877
+ return (
2878
+ <div
2879
+ ref={areaRef}
2880
+ data-shot-area
2881
+ // No `onWheel` prop: React would register it passive and the browser
2882
+ // would page-zoom alongside us. It is added by hand above.
2883
+ onPointerDown={onPointerDown}
2884
+ // GO INSIDE whatever is under the cursor. A React prop, like the hover
2885
+ // below and for the same reason: it opens no gesture and holds no state,
2886
+ // so it is not one of the hand-rolled lanes `sourceHygiene.test.ts`
2887
+ // holds to one registration each.
2888
+ //
2889
+ // The pointerdown lane has already run and selected, which is the
2890
+ // intended compound — the author sees what they are about to enter
2891
+ // highlighted before they enter it.
2892
+ onDoubleClick={onDoubleClick}
2893
+ // THE PEN'S HOVER, and nothing else's. A React prop rather than an
2894
+ // `addEventListener`, deliberately: the hand-rolled pointer lanes in this
2895
+ // file are held to one registration each (`sourceHygiene.test.ts`)
2896
+ // because a lane that rolls its own move/up pair has no `pointercancel`
2897
+ // and leaks a history scrub. This listener opens no gesture and holds no
2898
+ // state, so React's own teardown is the whole story.
2899
+ //
2900
+ // Without it the pen's rubber band and its close highlight have no input
2901
+ // at all: `cursor` would only advance while a button was held, so the
2902
+ // author would never see that the next click closes the loop.
2903
+ onPointerMove={onPointerHover}
2904
+ // The cursor box, kept honest on RELEASE too. React props rather than
2905
+ // `addEventListener` for the reason the hover prop states: they open no
2906
+ // gesture and hold no state, so React's own teardown is the whole story
2907
+ // and the hand-rolled-lane rule in sourceHygiene.test.ts is untouched.
2908
+ onPointerUp={onPointerTrack}
2909
+ onPointerCancel={onPointerTrack}
2910
+ onLostPointerCapture={onPointerTrack}
2911
+ // The cursor LEFT. Same posture as the props above — no gesture, no
2912
+ // state of its own — and it is what keeps a modal from being armed by a
2913
+ // keystroke aimed at the chrome, or stranded there once it is.
2914
+ onPointerLeave={onPointerLeave}
2915
+ // ALWAYS. The browser's own menu over a 3D canvas offers "save image"
2916
+ // and "reload" and nothing an author of a shot could want, and every
2917
+ // right press now has a meaning of its own: it opens the stage's menu,
2918
+ // or it cancels a modal. The one-shot suppressor this replaced existed
2919
+ // only for the cancel case; a rule with no exceptions needs no flag.
2920
+ onContextMenu={(e) => {
2921
+ e.preventDefault();
2922
+ }}
2923
+ style={{
2924
+ position: "fixed",
2925
+ inset: 0,
2926
+ overflow: "hidden",
2927
+ background: "var(--stage-backdrop, #0e0e10)",
2928
+ // NO `cursor` HERE. It is written imperatively by `setCursor`, and a
2929
+ // second writer React re-applies on every commit makes the right cursor
2930
+ // intermittent. See `setCursor`.
2931
+ touchAction: "none",
2932
+ }}
2933
+ >
2934
+ {doc === null || !laidOut ? null : (
2935
+ <>
2936
+ <ShotRuntime
2937
+ doc={doc}
2938
+ frame={state.frame}
2939
+ viewport={viewport}
2940
+ viewOffset={offset}
2941
+ // Only while the working pane is on screen. In camera mode the
2942
+ // runtime presents itself exactly as an export does — header.
2943
+ renderControl={ownsPresent ? "external" : "default"}
2944
+ // Quantised to the ladder, never the raw zoom: the raster cache
2945
+ // keys on this value, so a continuous zoom would re-raster the
2946
+ // whole document per wheel event. The ladder's top rung is the
2947
+ // same 4x cap this line used to spell out by hand.
2948
+ rasterScale={quantizeRasterScale(clampZoom(view.zoom))}
2949
+ // A far layer costs what it looks like, not what it was authored
2950
+ // at. The editor only: an export names its own scale and never
2951
+ // reaches the branch, which is what keeps preview === export a
2952
+ // property of the code rather than a rule someone remembers.
2953
+ adaptiveRaster
2954
+ instances={instances.instances}
2955
+ instanceContracts={instances.contracts}
2956
+ {...(isolated === null ? {} : { dim: { keep: isolated, opacity: DIM_OPACITY } })}
2957
+ onLayerGraph={bumpSceneEpoch}
2958
+ onMeshStats={pushMeshStats}
2959
+ onReady={onReady}
2960
+ >
2961
+ <StageTools
2962
+ feed={feed}
2963
+ panes={panes}
2964
+ workingView={workingView}
2965
+ selection={state.selection}
2966
+ layers={at?.scene ?? null}
2967
+ sceneFrame={at?.sceneFrame ?? 0}
2968
+ isolationRoot={isolated === null ? null : state.isolation}
2969
+ isolated={isolated}
2970
+ // The DERIVED rig, so a null here is the author having taken the
2971
+ // camera and `StageTools` falling back to the orbit one with no
2972
+ // branch of its own to keep in step.
2973
+ isolationView={headOnRig}
2974
+ />
2975
+ {/* presenterMounted mirrors exactly the conditions under which a
2976
+ priority-1 renderer exists on this canvas: SplitRenderer when
2977
+ a working pane owns the present, or ShotRuntime's own
2978
+ ScenePresenter (post chain or mesh bin). See PerfProbe for why
2979
+ the probe's priority must not be positive when neither is. */}
2980
+ {PERF_HUD_ENABLED ? (
2981
+ <PerfProbe
2982
+ box={perfBox}
2983
+ presenterMounted={
2984
+ ownsPresent ||
2985
+ hasMesh ||
2986
+ (effects !== null && hasPostChain(effects)) ||
2987
+ inTransition
2988
+ }
2989
+ />
2990
+ ) : null}
2991
+ {ownsPresent ? (
2992
+ <>
2993
+ {/* The working pane's placement aids. Mounted with the
2994
+ renderer that draws them and nowhere else: all three are
2995
+ EDITOR_LAYER objects the shot camera cannot see, so in
2996
+ camera-only mode they would be scene-graph nodes nothing
2997
+ ever renders. */}
2998
+ {/* ── BOTH CAMERA AIDS ARE SCOPED OUT WHILE ISOLATED ──────
2999
+ The grid IS the design z = 0 plane, and a card at z = 400
3000
+ seen head-on has it running across the pane as a wall at an
3001
+ unrelated angle. The frustum is the SHOT camera drawn as an
3002
+ object, and inside a subtree it frames something the pane
3003
+ cannot show. Worse, with an empty selection `modalTarget`
3004
+ retargets the move arms at that camera, so a drag would
3005
+ write `framing` from a view with no relation to it. */}
3006
+ {isolated === null ? (
3007
+ <GridPlane frame={size} target={workingView.target} distance={workingView.distance} />
3008
+ ) : null}
3009
+ <MoveGizmo
3010
+ feed={feed}
3011
+ panes={panes}
3012
+ selection={state.selection}
3013
+ cameraApex={isolated === null ? cameraApex : null}
3014
+ />
3015
+ {isolated === null ? (
3016
+ <FrustumGizmo feed={feed} panes={panes} rig={rig} frame={size} />
3017
+ ) : null}
3018
+ <SplitRenderer
3019
+ feed={feed}
3020
+ panes={panes}
3021
+ effects={effects}
3022
+ bins={bins}
3023
+ hasMesh={hasMesh}
3024
+ frame={state.frame}
3025
+ shading={state.shading}
3026
+ />
3027
+ </>
3028
+ ) : null}
3029
+ </ShotRuntime>
3030
+ <OverlaysHost
3031
+ feed={feed}
3032
+ width={viewport.width}
3033
+ height={viewport.height}
3034
+ panes={panes}
3035
+ frame={overlayFrame}
3036
+ hover={hover}
3037
+ followsView={follows}
3038
+ isolation={isolationLabel}
3039
+ marquee={marquee}
3040
+ pen={penScreen}
3041
+ guides={overlayGuides}
3042
+ designGuides={state.guides}
3043
+ size={size}
3044
+ modal={modalBadge}
3045
+ nav={nav}
3046
+ keyLightArm={keyLightArm}
3047
+ inset={state.inset}
3048
+ />
3049
+ {PERF_HUD_ENABLED ? (
3050
+ <PerfHud box={perfBox} layers={at === null ? 0 : Object.keys(at.scene.layersById).length} />
3051
+ ) : null}
3052
+ </>
3053
+ )}
3054
+ </div>
3055
+ );
3056
+ }
3057
+
3058
+ /**
3059
+ * The world DESIGN point a multi-selection scales away from: the mean of its
3060
+ * members' origins, at the frame on screen.
3061
+ *
3062
+ * NULL FOR ONE LAYER, and that is not an optimisation. One layer's median is
3063
+ * its own origin, so the offset from it is zero and the position never moves —
3064
+ * which is exactly what `pivot: null` means downstream, said without arithmetic
3065
+ * that could round.
3066
+ *
3067
+ * The MEAN and not the bounding box's centre: `project.ts` and `Overlays` both
3068
+ * state that a screen bounding box lies about tilt, and the same is true in the
3069
+ * world. A mean of origins is a real point that needs no box.
3070
+ *
3071
+ * Off `matrixWorld`, whose 13th to 15th elements are the translation column, so
3072
+ * the one mapping (design y down, z away) is undone on the way out.
3073
+ */
3074
+ /**
3075
+ * A pane-local point, held inside its pane.
3076
+ *
3077
+ * ── AT THE SOURCE, IN ONE PLACE ─────────────────────────────────────────
3078
+ *
3079
+ * The overlay CLIPS its bands to the pane, so a drag that crosses the divider
3080
+ * draws a clipped view of a larger invisible rectangle — and the lane behind it
3081
+ * then acts on the invisible one. The marquee selected against it; the draw
3082
+ * tools inserted a layer sized to it. Both are the same bug and now share the
3083
+ * same clamp, so the rectangle drawn and the rectangle acted on are one
3084
+ * rectangle by construction rather than by two clips agreeing.
3085
+ */
3086
+ function clampInPane(rect: PaneRect, x: number, y: number): [number, number] {
3087
+ return [Math.max(0, Math.min(rect.width, x)), Math.max(0, Math.min(rect.height, y))];
3088
+ }
3089
+
3090
+ /**
3091
+ * The union box's world corners, as the DESIGN rectangle the scale measures in.
3092
+ *
3093
+ * `unionQuad` only answers for a box whose edges run along the world axes, so
3094
+ * the two opposite corners carry the whole rectangle and `(x, -y)` is the only
3095
+ * conversion needed. TL is `quad[0]` and BR is `quad[2]`, and world +y is up, so
3096
+ * the flip is what turns the world's TOP corner into design's SMALLEST y.
3097
+ */
3098
+ function designBox(union: WorldQuad | undefined): DesignBox | null {
3099
+ if (union === undefined) return null;
3100
+ return { x0: union[0].x, y0: -union[0].y, x1: union[2].x, y1: -union[2].y };
3101
+ }
3102
+
3103
+ function selectionPivot(feed: StageFeed, selection: readonly string[]): Vec3 | null {
3104
+ if (selection.length < 2) return null;
3105
+ let n = 0;
3106
+ let x = 0;
3107
+ let y = 0;
3108
+ let z = 0;
3109
+ // One frame per id, the FIRST — the same rule `StageTools`' `byId` follows. A
3110
+ // cloner stamps its children N times under one id, and averaging every clone
3111
+ // would put the pivot in the empty space between them.
3112
+ const seen = new Set<string>();
3113
+ for (const frame of feed.frames ?? []) {
3114
+ if (!selection.includes(frame.id) || seen.has(frame.id)) continue;
3115
+ seen.add(frame.id);
3116
+ const e = frame.matrixWorld.elements;
3117
+ x += e[12]!;
3118
+ y += e[13]!;
3119
+ z += e[14]!;
3120
+ n += 1;
3121
+ }
3122
+ // Fewer than two ON SCREEN is the same case as fewer than two selected: a
3123
+ // layer outside its lifespan at this frame has no position to average.
3124
+ return n < 2 ? null : [x / n, -(y / n), -(z / n)];
3125
+ }
3126
+
3127
+ /** A three `Ray` into the plain numbers `axisParam` takes. The one place the
3128
+ * gizmo lane crosses from three into the three-free maths module, so nothing
3129
+ * in gizmoMath.ts has to know what a Ray is. `Raycaster` hands out a
3130
+ * normalized direction, which is what the unit-direction solve assumes. */
3131
+ function paramAt(arm: GizmoGrab, ray: Ray): number | null {
3132
+ return axisParam(
3133
+ arm.origin,
3134
+ arm.direction,
3135
+ [ray.origin.x, ray.origin.y, ray.origin.z],
3136
+ [ray.direction.x, ray.direction.y, ray.direction.z],
3137
+ );
3138
+ }
3139
+
3140
+ /**
3141
+ * The pointer plumbing every committing drag in this file shares.
3142
+ *
3143
+ * THREE THINGS MAKE A DRAG ONE UNDO ENTRY AND EXACTLY REVERSIBLE, and two of
3144
+ * them are here: the gesture ends exactly once however it ends, and Escape ends
3145
+ * it with `commit: false`. The third is the caller's, and is the reason every
3146
+ * `move` above measures FROM THE PRESS: the scrub re-derives from its base.
3147
+ *
3148
+ * Module scope, so it closes over nothing and EVERY caller in this file gets
3149
+ * the same teardown — which is how one of them ends up leaking a `keydown`
3150
+ * listener that fires against a finished gesture, or registering `pointerup`
3151
+ * alone and leaving a history scrub open forever when the browser cancels the
3152
+ * pointer instead of releasing it.
3153
+ *
3154
+ * `end` is handed the event that ENDED the drag, or null when nothing did
3155
+ * (Escape, `pointercancel`). The marquee lane needs it: the final point and the
3156
+ * modifiers exist only on that event, and a `commit` boolean cannot carry them.
3157
+ */
3158
+ function dragGesture(
3159
+ el: HTMLDivElement,
3160
+ pointerId: number,
3161
+ move: (ev: PointerEvent) => void,
3162
+ end: (commit: boolean, ev: PointerEvent | null) => void,
3163
+ ): void {
3164
+ let live = true;
3165
+ // MOVES ARE COALESCED TO THE FRAME, last event wins. A pointermove costs a
3166
+ // full stage commit and a GL draw (and, for a document-writing gesture, a
3167
+ // fresh Doc3D and a chrome-wide notification), while the display can only
3168
+ // show one result per frame — so applying every event buys nothing and
3169
+ // stalls the thread exactly when the author is watching most closely.
3170
+ // Deltas are measured FROM THE PRESS by every caller, so dropping
3171
+ // intermediate events cannot make a drag drift; the LAST event always
3172
+ // applies, on the frame or at the release below.
3173
+ let pending: PointerEvent | null = null;
3174
+ let raf = 0;
3175
+ const flush = (): void => {
3176
+ raf = 0;
3177
+ if (!live || pending === null) return;
3178
+ const ev = pending;
3179
+ pending = null;
3180
+ move(ev);
3181
+ };
3182
+ const onMove = (ev: PointerEvent): void => {
3183
+ pending = ev;
3184
+ if (raf === 0) raf = requestAnimationFrame(flush);
3185
+ };
3186
+ const finish = (commit: boolean, ev: PointerEvent | null): void => {
3187
+ if (!live) return;
3188
+ live = false;
3189
+ if (raf !== 0) cancelAnimationFrame(raf);
3190
+ // The FINAL position always lands before the end: a move still pending at
3191
+ // release would otherwise drop the last frame of travel and the layer
3192
+ // would land where the pointer was a frame ago. A discard skips it — the
3193
+ // gesture is being thrown away.
3194
+ if (commit && pending !== null) {
3195
+ const last = pending;
3196
+ pending = null;
3197
+ move(last);
3198
+ }
3199
+ pending = null;
3200
+ el.removeEventListener("pointermove", onMove);
3201
+ el.removeEventListener("pointerup", up);
3202
+ el.removeEventListener("pointercancel", cancel);
3203
+ window.removeEventListener("keydown", onKey);
3204
+ try {
3205
+ el.releasePointerCapture(pointerId);
3206
+ } catch {
3207
+ /* already released */
3208
+ }
3209
+ end(commit, ev);
3210
+ };
3211
+ const up = (ev: PointerEvent): void => finish(true, ev);
3212
+ const cancel = (): void => finish(false, null);
3213
+ const onKey = (ev: KeyboardEvent): void => {
3214
+ if (ev.key !== "Escape") return;
3215
+ ev.preventDefault();
3216
+ finish(false, null);
3217
+ };
3218
+ el.addEventListener("pointermove", onMove);
3219
+ el.addEventListener("pointerup", up);
3220
+ el.addEventListener("pointercancel", cancel);
3221
+ window.addEventListener("keydown", onKey);
3222
+ }
3223
+
3224
+ /**
3225
+ * Design px from the frame's TOP-LEFT (what `screenToDesign` answers) into
3226
+ * design px from the CANVAS CENTRE (what the schema stores).
3227
+ *
3228
+ * One conversion, at the boundary, in the direction the guides already cross in
3229
+ * the other — see the `designToScreen` call the overlay makes for them. Doing
3230
+ * it in the chrome instead would mean the chrome deriving canvas geometry it
3231
+ * only half knows.
3232
+ */
3233
+ function centreOrigin(
3234
+ point: readonly [number, number],
3235
+ frame: { width: number; height: number },
3236
+ ): [number, number] {
3237
+ return [point[0] - frame.width / 2, point[1] - frame.height / 2];
3238
+ }
3239
+
3240
+ function durationOf(doc: {
3241
+ scenes: readonly string[];
3242
+ scenesById: Record<string, { durationFrames: number }>;
3243
+ }): number {
3244
+ let total = 0;
3245
+ for (const id of doc.scenes) total += Math.max(1, Math.trunc(doc.scenesById[id]?.durationFrames ?? 0));
3246
+ return total;
3247
+ }
3248
+
3249
+ /**
3250
+ * Every instance layer's component and declared contract, re-resolved when the
3251
+ * set of component IDS changes and not on every keystroke.
3252
+ *
3253
+ * KEYED ON THE IDS, not on the document: an inspector edit mints a new `Doc3D`
3254
+ * on every character typed, and re-running the resolver there would re-await a
3255
+ * cached promise per keystroke and hand `ShotRuntime` a fresh object identity
3256
+ * each time — remounting every instanced component, which for a module with
3257
+ * internal state is visible as a flicker and for one behind Suspense is a
3258
+ * re-fetch. The module cache in instanceModules.ts makes the awaits free; the
3259
+ * identity is what has to stay stable.
3260
+ */
3261
+ function useInstanceModules(client: StageClient, doc: Doc3D | null): InstanceResolution {
3262
+ const [resolved, setResolved] = useState<InstanceResolution>(EMPTY_INSTANCES);
3263
+ // Joined on a character no registry id can contain (an id is
3264
+ // "<module>#<Export>", and a module path holds no newline), so two different
3265
+ // id sets can never collapse to the same key.
3266
+ const key = doc === null ? "" : instanceComponentIdsOf(doc).join("\n");
3267
+ useEffect(() => {
3268
+ if (doc === null || key === "") {
3269
+ setResolved(EMPTY_INSTANCES);
3270
+ return;
3271
+ }
3272
+ let live = true;
3273
+ void resolveInstanceModules(doc).then((next) => {
3274
+ if (!live) return;
3275
+ setResolved(next);
3276
+ // Reported, never thrown: a module that will not resolve leaves its layer
3277
+ // empty, and the author needs to know WHY rather than losing the editor.
3278
+ for (const message of next.errors) client.handlers()?.onError(message);
3279
+ });
3280
+ return () => {
3281
+ live = false;
3282
+ };
3283
+ // `doc` is deliberately not a dependency — see the header. It is read
3284
+ // through the closure at the moment the id set changes, which is the only
3285
+ // moment its instance layers can differ.
3286
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3287
+ }, [client, key]);
3288
+ return resolved;
3289
+ }
3290
+
3291
+ const EMPTY_INSTANCES: InstanceResolution = { instances: {}, contracts: {}, errors: [] };
3292
+
3293
+ function useStageState(client: StageClient): StageClientState {
3294
+ const [state, setState] = useState(client.getState);
3295
+ useEffect(() => client.subscribe(() => setState(client.getState())), [client]);
3296
+ return state;
3297
+ }
3298
+
3299
+ /** The container's CSS size, which is the canvas's size and therefore the unit
3300
+ * every screen coordinate in this lane is measured in. `origin` receives the
3301
+ * container's client origin from the same measurement, so the pointer lanes
3302
+ * can convert client coordinates without a per-event layout read. */
3303
+ function useViewportSize(
3304
+ ref: React.RefObject<HTMLDivElement | null>,
3305
+ set: (size: { width: number; height: number }) => void,
3306
+ origin?: React.RefObject<readonly [number, number]>,
3307
+ ): void {
3308
+ useEffect(() => {
3309
+ const el = ref.current;
3310
+ if (el === null) return;
3311
+ const measure = (): void => {
3312
+ const rect = el.getBoundingClientRect();
3313
+ if (origin !== undefined) origin.current = [rect.left, rect.top];
3314
+ set({ width: Math.round(rect.width), height: Math.round(rect.height) });
3315
+ };
3316
+ measure();
3317
+ const observer = new ResizeObserver(measure);
3318
+ observer.observe(el);
3319
+ return () => observer.disconnect();
3320
+ }, [ref, set, origin]);
3321
+ }
3322
+
3323
+ /**
3324
+ * Forward the stage's own key events to the chrome, and hold the space bar's
3325
+ * two bits.
3326
+ *
3327
+ * KEYBOARD EVENTS GO TO ONE DOCUMENT. The stage is an iframe, so once the
3328
+ * author has moved the pointer onto the canvas — which is most of the time,
3329
+ * since StagePanel focuses the frame on pointer entry — the chrome's window
3330
+ * listener never sees a keystroke. Without this relay every shortcut would work
3331
+ * only while a panel had focus, which is the version of this bug that reads as
3332
+ * "the shortcuts are flaky".
3333
+ *
3334
+ * ── WHY THE RELAY AND THE SPACE FLAG ARE ONE HOOK ───────────────────────
3335
+ *
3336
+ * They were two, and the split was a silent bug: both registered a `keyup` on
3337
+ * `window`, and DOM listeners fire in registration order, so the flag hook —
3338
+ * called one line earlier — cleared the bit before the relay could read it.
3339
+ * Space therefore NEVER reached the chrome from a focused stage, so it never
3340
+ * played or paused. One hook, one listener pair, and the order is a statement
3341
+ * rather than an accident of which line came first.
3342
+ *
3343
+ * ── AND WHY THE BAR NEEDS TWO BITS, NOT ONE ─────────────────────────────
3344
+ *
3345
+ * `held` gates the pan. `panned` says a pan actually ran, and is what makes the
3346
+ * release swallow the play/pause. One flag carrying both could not survive
3347
+ * KEY AUTO-REPEAT: the pan lane cleared it at the end of the drag, the OS fired
3348
+ * the next repeat keydown milliseconds later and set it straight back, and the
3349
+ * release played the shot anyway. Which happened depended on repeat timing,
3350
+ * which is the definition of flaky.
3351
+ *
3352
+ * A fresh press clears `panned`; a repeat does not, so a hold that panned stays
3353
+ * marked for as long as the author keeps the bar down.
3354
+ *
3355
+ * Refs, not state: both are read at pointerdown and neither changes a pixel, so
3356
+ * state would re-render the whole stage twice per press of the space bar.
3357
+ * `blur` clears them too, because `keyup` never arrives if the author alt-tabs
3358
+ * mid-hold and the stage would be stuck in pan mode.
3359
+ */
3360
+ function useKeyRelay(
3361
+ client: StageClient,
3362
+ held: React.RefObject<boolean>,
3363
+ panned: React.RefObject<boolean>,
3364
+ refreshCursor: React.RefObject<() => void>,
3365
+ ): void {
3366
+ useEffect(() => {
3367
+ const down = (e: KeyboardEvent): void => {
3368
+ if (e.key === " ") {
3369
+ if (!e.repeat) panned.current = false;
3370
+ // The bar changes the cursor to a grab hand with no pointer motion at
3371
+ // all, so the refresh rides the same listener that owns the flag. AFTER
3372
+ // the flag is set, because `cursorFor` reads it — refreshing first
3373
+ // recomputes the answer the bar was pressed to change. Only on the
3374
+ // FIRST press: an auto-repeat re-deriving the same answer at the OS
3375
+ // repeat rate buys nothing.
3376
+ const first = !held.current;
3377
+ held.current = true;
3378
+ if (first) refreshCursor.current();
3379
+ return; // the chrome hears space on KEYUP, and only if no pan ran
3380
+ }
3381
+ if (client.handlers()?.onKey(keyOf(e), isEditable(e.target)) === true) e.preventDefault();
3382
+ };
3383
+ const up = (e: KeyboardEvent): void => {
3384
+ if (e.key !== " ") return;
3385
+ held.current = false;
3386
+ refreshCursor.current();
3387
+ if (!panned.current) client.handlers()?.onKey(keyOf(e), isEditable(e.target));
3388
+ panned.current = false;
3389
+ };
3390
+ const clear = (): void => {
3391
+ held.current = false;
3392
+ panned.current = false;
3393
+ refreshCursor.current();
3394
+ };
3395
+ window.addEventListener("keydown", down);
3396
+ window.addEventListener("keyup", up);
3397
+ window.addEventListener("blur", clear);
3398
+ return () => {
3399
+ window.removeEventListener("keydown", down);
3400
+ window.removeEventListener("keyup", up);
3401
+ window.removeEventListener("blur", clear);
3402
+ };
3403
+ }, [client, held, panned, refreshCursor]);
3404
+ }
3405
+
3406
+ function keyOf(e: KeyboardEvent) {
3407
+ return {
3408
+ key: e.key,
3409
+ shiftKey: e.shiftKey,
3410
+ altKey: e.altKey,
3411
+ ctrlKey: e.ctrlKey,
3412
+ metaKey: e.metaKey,
3413
+ code: e.code,
3414
+ };
3415
+ }
3416
+
3417
+ function isEditable(target: EventTarget | null): boolean {
3418
+ if (target === null || typeof (target as HTMLElement).tagName !== "string") return false;
3419
+ const el = target as HTMLElement;
3420
+ return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA";
3421
+ }
3422
+
3423
+