@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,581 @@
1
+ // The half of the stage that lives INSIDE the canvas.
2
+ //
3
+ // It renders nothing. Its whole job is to keep `feed` pointing at the live
4
+ // camera and the live layer transforms, so the pointer lane and the overlay —
5
+ // both of which are ordinary DOM outside the canvas — can ask geometry
6
+ // questions without ever touching three themselves.
7
+ //
8
+ // ── PRIORITY 0, AND WHY THE OVERLAY IS NOT PUSHED AT ────────────────────
9
+ //
10
+ // `useFrame(…, 0)` runs in the same pass as the camera and layer writes, so the
11
+ // quads it computes are the ones that are about to be drawn — not last frame's.
12
+ // Under `frameloop="never"` that pass happens once per commit, so this costs
13
+ // nothing while nothing moves, and exactly one recompute per seek, pan or zoom.
14
+ //
15
+ // It bumps React only when a corner moved by more than half a CSS pixel. Below
16
+ // that the SVG rounds to the same path and the render is pure cost, and the
17
+ // camera moves on every frame of a dolly.
18
+ import { useMemo } from "react";
19
+ import { useFrame, useThree } from "@react-three/fiber";
20
+ import { OrthographicCamera, PerspectiveCamera, Raycaster, Vector2 } from "three";
21
+ import type { Camera, Object3D } from "three";
22
+ import {
23
+ SHOT_LAYER_BOX,
24
+ SHOT_LAYER_KEY,
25
+ SHOT_LAYER_LOCKED,
26
+ SHOT_LAYER_ORDER,
27
+ SHOT_LAYER_PICKABLE,
28
+ } from "@dailies-studio/shot";
29
+ import { memoLast, outermost } from "@dailies-studio/shot/doc";
30
+ import type { LayerLookup } from "@dailies-studio/shot/doc";
31
+ import { nearestHandle, quadCentre, rotateRingAt } from "./handles.js";
32
+ import {
33
+ pickInRect,
34
+ pickLayers,
35
+ pickLayersByDistance,
36
+ projectQuad,
37
+ quadMoved,
38
+ screenQuad,
39
+ unionQuad,
40
+ } from "./project.js";
41
+ import type { LayerFrame, ScreenQuad } from "./project.js";
42
+ import type { Grab, StageFeed } from "./feed.js";
43
+ import type { PaneRect, StagePane } from "./panes.js";
44
+ import { WORKING_FOV_DEG, workingPose } from "./workingView.js";
45
+ import type { WorkingView } from "./workingView.js";
46
+ import { isolationExtent, isolationEye } from "./isolation.js";
47
+ import type { IsolationFrame } from "./isolation.js";
48
+
49
+ /** Editor-only geometry lives on layer bit 1, and only the working camera sees
50
+ * it. Nothing draws on it yet — the frustum gizmo is the first thing that
51
+ * will — but the mask has to be right BEFORE something depends on it, because
52
+ * the failure is a gizmo leaking into the graded image. */
53
+ export const EDITOR_LAYER = 1;
54
+
55
+
56
+ /** Does the union box need repainting? Its APPEARANCE is a state change in its
57
+ * own right — it comes and goes as a selection is tilted or added to — so a
58
+ * null on either side is a change, and two boxes compare corner by corner. */
59
+ function unionMoved(a: StageFeed["union"], b: StageFeed["union"]): boolean {
60
+ if (a === null || b === null) return a !== b;
61
+ for (const pane of ["camera", "working"] as const) {
62
+ const before = a[pane];
63
+ const after = b[pane];
64
+ if (before === null || after === null) {
65
+ if (before !== after) return true;
66
+ continue;
67
+ }
68
+ if (quadMoved(before, after)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** Reused per frame; picking allocates enough already without a raycaster. */
74
+ const RAYCASTER = new Raycaster();
75
+ const NDC = new Vector2();
76
+
77
+ /**
78
+ * `collectFrames`, replayed while nothing about the graph's MEMBERSHIP changed.
79
+ *
80
+ * This was the single biggest per-frame CPU item on the stage: a full
81
+ * `Object3D.traverse` of every node in the scene, every frame, whether or not
82
+ * anything had moved — and during an orbit or a dolly nothing HAS moved, in the
83
+ * only sense this walk cares about.
84
+ *
85
+ * The key is what membership actually depends on, and nothing more:
86
+ * - `root`, three's scene object, which is stable for the canvas's lifetime but
87
+ * belongs in the key because a second canvas would be a different graph;
88
+ * - `layers`, the document's scene at this frame. Every edit mints a new object
89
+ * (the whole store is immutable), and an edit is what changes a layer's box,
90
+ * its lock or its pickability — all of which this walk COPIES rather than
91
+ * referencing;
92
+ * - `sceneFrame`, because a lifetime ends and a layer leaves the tree;
93
+ * - `epoch`, the mount counter, because a model or an instance module resolving
94
+ * commits a subtree that no value above predicts.
95
+ *
96
+ * What is deliberately NOT in the key is motion. `matrixWorld` is stored as a
97
+ * LIVE REFERENCE (see `collectFrames`), so an orbit, a pan, a scrub within one
98
+ * frame and a gesture drag all move the matrices the cached frames already
99
+ * point at. That is why replaying the walk is exact rather than merely close.
100
+ */
101
+ const collectFramesCached = memoLast(
102
+ (root: Object3D, _layers: LayerLookup | null, _sceneFrame: number, _epoch: number): LayerFrame[] =>
103
+ collectFrames(root),
104
+ );
105
+
106
+ export function StageTools({
107
+ feed,
108
+ panes,
109
+ workingView,
110
+ selection,
111
+ layers,
112
+ sceneFrame,
113
+ isolationRoot,
114
+ isolated,
115
+ isolationView,
116
+ }: {
117
+ feed: StageFeed;
118
+ /** The two pane rectangles, in CSS px from the CANVAS top-left. Everything
119
+ * this component answers is measured inside ONE of them. */
120
+ panes: { working: PaneRect | null; camera: PaneRect | null };
121
+ workingView: WorkingView;
122
+ selection: readonly string[];
123
+ /** The scene at the playhead, for the hover lane's parent walk. `layers` and
124
+ * not `scene`, because `scene` in this file is three's own scene graph. Null
125
+ * before a document has arrived, or between scenes. */
126
+ layers: LayerLookup | null;
127
+ /** The playhead, in the scene's own frames. Read only as part of the frame
128
+ * walk's cache key: a lifetime ending is a layer leaving the graph. */
129
+ sceneFrame: number;
130
+ /** The isolated layer itself, which is the BOUNDARY the parent walk stops at
131
+ * — not the same thing as `isolated`, which is its whole subtree. */
132
+ isolationRoot: string | null;
133
+ /** The isolated subtree's ids, or null when nothing is isolated. Picking is
134
+ * scoped to it — see the filter below. */
135
+ isolated: ReadonlySet<string> | null;
136
+ /** The head-on frame for that subtree, or null. Drives the working pane's
137
+ * camera while isolation is on. */
138
+ isolationView: IsolationFrame | null;
139
+ }): null {
140
+ const scene = useThree((s) => s.scene);
141
+ const camera = useThree((s) => s.camera);
142
+
143
+ // The working pane's camera. Owned HERE and not by the renderer that draws
144
+ // with it: this runs at priority 0, the renderer at priority 1, so the camera
145
+ // that answers a pick is the same object in the same state as the one the
146
+ // pixel came from. See the note on `feed.workingCamera`.
147
+ const workingCamera = useMemo(() => {
148
+ const cam = new PerspectiveCamera(WORKING_FOV_DEG, 1, 0.1, 10_000);
149
+ cam.layers.enable(EDITOR_LAYER);
150
+ return cam;
151
+ }, []);
152
+
153
+ /** The isolation pane's camera. Its own object rather than a mode on the one
154
+ * above, because an orthographic projection is a different class — and
155
+ * because `feed.workingCamera` is read by picking, projection and the
156
+ * renderer, all of which want whichever one is actually in the slot. */
157
+ const isolationCamera = useMemo(() => {
158
+ const cam = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10_000);
159
+ cam.layers.enable(EDITOR_LAYER);
160
+ return cam;
161
+ }, []);
162
+
163
+ /** The working pane's own ORTHOGRAPHIC rig (numpad-5). Its own object beside
164
+ * the perspective one, for `isolationCamera`'s reason: a projection is a
165
+ * different class, and every consumer wants whichever is actually in the
166
+ * slot rather than one object that changes type underneath them. */
167
+ const workingOrtho = useMemo(() => {
168
+ const cam = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10_000);
169
+ cam.layers.enable(EDITOR_LAYER);
170
+ return cam;
171
+ }, []);
172
+
173
+ useFrame(() => {
174
+ const frames = collectFramesCached(scene, layers, sceneFrame, feed.sceneEpoch);
175
+ feed.frames = frames;
176
+
177
+ // ── ONE FRAME PER ID, THE FIRST ─────────────────────────────────────
178
+ //
179
+ // A CLONER stamps its children N times and every clone carries the SAME
180
+ // `SHOT_LAYER_KEY`, so `new Map(frames.map(...))` kept the LAST one. The
181
+ // outline and the gesture plane then belonged to a different clone than
182
+ // the one the author clicked, while `selectionCentre` averaged all of them
183
+ // and put the move gizmo in empty space between them.
184
+ //
185
+ // FIRST, and that is a CHOICE rather than a match. `collectFrames` walks
186
+ // the scene graph in order, so the first is the one `pickLayers` reports
187
+ // first in the camera pane — where the hits are sorted by paint order and
188
+ // every clone shares one. The WORKING pane sorts by distance
189
+ // (`pickLayersByDistance`), so clicking the nearest clone of a cloner still
190
+ // outlines and resizes the first one.
191
+ //
192
+ // ponytail: threading the picked frame's INDEX through the picker,
193
+ // `hoverQuad`, `grabAt` and the quad loop is the honest fix, and it is
194
+ // plumbing for a case that needs a cloner, the working pane, and a click on
195
+ // a clone that is not the first. Named here; do it when someone reports
196
+ // handles on the wrong clone.
197
+ const byId = new Map<string, LayerFrame>();
198
+ for (const f of frames) if (!byId.has(f.id)) byId.set(f.id, f);
199
+ // ISOLATION SCOPES PICKING, and it does it HERE rather than through
200
+ // `SHOT_LAYER_PICKABLE`. The runtime re-stamps that userData on every
201
+ // commit from a dep-less layout effect, so anything the editor wrote onto
202
+ // it would be gone the next time anything moved. This filter is the
203
+ // editor's own and survives.
204
+ //
205
+ // A press that lands on nothing is what exits: with the surroundings
206
+ // unpickable, clicking outside the subtree already produces an empty hit
207
+ // list, so "click away to leave" needs no second gesture.
208
+ const pickable = frames.filter(
209
+ (f) => f.pickable !== false && (isolated === null || isolated.has(f.id)),
210
+ );
211
+ const cameraRect = panes.camera;
212
+ const workingRect = panes.working;
213
+
214
+ if (workingRect === null) {
215
+ feed.workingCamera = null;
216
+ } else if (isolationView !== null) {
217
+ // ISOLATION TAKES THE WORKING PANE'S SLOT. Not a third pane: a scissored
218
+ // pane per mode is the trap SplitRenderer's header is about, and the two
219
+ // views an author wants while inside a card are "this card, flat" and
220
+ // "the shot" — which is the split that already exists.
221
+ //
222
+ // Orthographic and posed from the subtree's OWN basis, so it can roll and
223
+ // can look straight down — neither of which the orbit rig can do (its up
224
+ // is reset to world +y every frame and its polar angle is clamped off
225
+ // both poles).
226
+ const aspect = workingRect.height === 0 ? 1 : workingRect.width / workingRect.height;
227
+ const extent = isolationExtent(isolationView, aspect);
228
+ const { eye, up, far } = isolationEye(isolationView);
229
+ isolationCamera.left = -extent.halfWidth;
230
+ isolationCamera.right = extent.halfWidth;
231
+ isolationCamera.top = extent.halfHeight;
232
+ isolationCamera.bottom = -extent.halfHeight;
233
+ isolationCamera.near = 0.1;
234
+ isolationCamera.far = far;
235
+ isolationCamera.position.set(eye[0], eye[1], eye[2]);
236
+ isolationCamera.up.set(up[0], up[1], up[2]);
237
+ isolationCamera.lookAt(
238
+ isolationView.target[0],
239
+ isolationView.target[1],
240
+ isolationView.target[2],
241
+ );
242
+ isolationCamera.updateProjectionMatrix();
243
+ isolationCamera.updateMatrixWorld(true);
244
+ feed.workingCamera = isolationCamera;
245
+ } else {
246
+ // ── PERSPECTIVE OR ORTHOGRAPHIC, SAME POSE ────────────────────────
247
+ //
248
+ // Numpad-5. The two rigs are POSED identically — same eye, same target,
249
+ // same up — and differ only in the projection, so the toggle turns the
250
+ // vanishing point on and off without moving the camera an inch.
251
+ //
252
+ // The ortho half-height is `distance * tan(fov/2)`, which is exactly what
253
+ // the perspective lens frames AT THE TARGET. So `workingScreenScale`
254
+ // needs no branch: its `2 * distance * tan(fov/2)` is already both
255
+ // answers.
256
+ //
257
+ // The two agree at the target POINT and not everywhere: an ortho
258
+ // projection has no perspective divide, so a layer's corners — which sit
259
+ // at different depths once the view is orbited — are foreshortened by one
260
+ // rig and not the other. The error grows with distance from the target;
261
+ // on it, it is under one percent of the layer's own span
262
+ // (stageGeometry.test.ts pins that), which reads as the same picture and
263
+ // is what makes the toggle a toggle. Far from the target it is a real
264
+ // difference, and it is the whole reason to have an ortho view at all.
265
+ const ortho = workingView.ortho === true;
266
+ const cam = ortho ? workingOrtho : workingCamera;
267
+ const { position } = workingPose(workingView);
268
+ // ── THE ORTHO EYE STANDS BACK, IT DOES NOT REACH FORWARD ──────────
269
+ //
270
+ // An orthographic projection does not depend on where along the view axis
271
+ // the eye sits, so the two ways to cover the scene look identical: a
272
+ // negative near plane, or a positive near plane with the eye pushed back.
273
+ // They are NOT identical to the pointer. `Raycaster.setFromCamera` starts
274
+ // an ortho ray AT the near plane, and `rayHitsTriangle` rejects `t < 0` —
275
+ // so with a negative near plane every layer behind the eye plane drew
276
+ // perfectly and answered no click, no marquee, no gizmo and no frustum
277
+ // hit (`pickInRect`'s guard, `MoveGizmo`'s `behind` and `FrustumGizmo`'s
278
+ // all read view-space z the same way).
279
+ //
280
+ // Standing back is the construction `isolationEye` already uses, and it
281
+ // fixes all four consumers with no branch in any of them.
282
+ const back = ortho ? Math.max(workingView.distance * 8, 10_000) : 0;
283
+ const dir = ortho
284
+ ? [
285
+ (position[0] - workingView.target[0]) / Math.max(workingView.distance, 1),
286
+ (position[1] - workingView.target[1]) / Math.max(workingView.distance, 1),
287
+ (position[2] - workingView.target[2]) / Math.max(workingView.distance, 1),
288
+ ]
289
+ : [0, 0, 0];
290
+ cam.position.set(
291
+ position[0] + dir[0]! * back,
292
+ position[1] + dir[1]! * back,
293
+ position[2] + dir[2]! * back,
294
+ );
295
+ // Re-set every frame: `lookAt` reads `up`, and leaving it to whatever a
296
+ // previous frame or another consumer left there is how an orbit picks up
297
+ // a roll nobody authored.
298
+ cam.up.set(0, 1, 0);
299
+ cam.lookAt(workingView.target[0], workingView.target[1], workingView.target[2]);
300
+ const aspect = workingRect.height === 0 ? 1 : workingRect.width / workingRect.height;
301
+ if (ortho) {
302
+ const halfHeight = workingView.distance * Math.tan((WORKING_FOV_DEG * Math.PI) / 360);
303
+ workingOrtho.top = halfHeight;
304
+ workingOrtho.bottom = -halfHeight;
305
+ workingOrtho.right = halfHeight * aspect;
306
+ workingOrtho.left = -halfHeight * aspect;
307
+ // A POSITIVE NEAR PLANE, with the eye already pushed back by `back`
308
+ // above. The whole slab is in front of the camera, so every hit test in
309
+ // this lane agrees with what is drawn.
310
+ workingOrtho.near = 0.1;
311
+ workingOrtho.far = back * 2 + workingView.distance;
312
+ } else {
313
+ workingCamera.fov = WORKING_FOV_DEG;
314
+ workingCamera.aspect = aspect;
315
+ // The shot camera's own near/far, for the same reason it can afford
316
+ // them: every material here draws with `depthTest: false`, so depth
317
+ // precision buys nothing and a generous far plane costs nothing.
318
+ workingCamera.near = 0.1;
319
+ workingCamera.far = Math.max(workingView.distance * 8, 10_000);
320
+ }
321
+ cam.updateProjectionMatrix();
322
+ cam.updateMatrixWorld(true);
323
+ feed.workingCamera = cam;
324
+ }
325
+
326
+ /** The pane's rect and camera, or null when that pane is not on screen. */
327
+ const viewOf = (pane: StagePane): { rect: PaneRect; cam: Camera } | null => {
328
+ if (pane === "camera") return cameraRect === null ? null : { rect: cameraRect, cam: camera };
329
+ return workingRect === null || feed.workingCamera === null
330
+ ? null
331
+ : { rect: workingRect, cam: feed.workingCamera };
332
+ };
333
+
334
+ feed.rayAt = (pane, x, y) => {
335
+ const view = viewOf(pane);
336
+ if (view === null) return null;
337
+ // NDC against the PANE, never the canvas: the renderer's viewport is the
338
+ // pane rect, so -1..1 spans the pane and dividing by the canvas would put
339
+ // every ray a fraction of a screen off.
340
+ NDC.set((x / view.rect.width) * 2 - 1, -((y / view.rect.height) * 2 - 1));
341
+ RAYCASTER.setFromCamera(NDC, view.cam);
342
+ // CLONED. `RAYCASTER` is module scope, so the ray it owns is one object:
343
+ // the move gesture holds the ray from the PRESS and compares it against
344
+ // the ray under the cursor, and two aliases of one mutable Ray make that
345
+ // difference identically zero.
346
+ return RAYCASTER.ray.clone();
347
+ };
348
+
349
+ feed.pick = (pane, x, y) => {
350
+ const ray = feed.rayAt?.(pane, x, y) ?? null;
351
+ if (ray === null) return [];
352
+ // The two panes sort their hits differently, and that divergence is the
353
+ // point — see `pickLayersByDistance`. Paint order answers "what is on top"
354
+ // in the camera pane; in a free orbit it answers nothing.
355
+ return pane === "working" ? pickLayersByDistance(ray, pickable) : pickLayers(ray, pickable);
356
+ };
357
+
358
+ // The outline a click would select, projected in the pane it was asked
359
+ // about. `outermost` is `packages/shot`'s, the SAME walk `onPick` runs, so
360
+ // the preview and the click cannot name different layers.
361
+ feed.hoverQuad = (pane, x, y) => {
362
+ const view = viewOf(pane);
363
+ const hit = feed.pick?.(pane, x, y)[0];
364
+ if (view === null || layers === null || hit === undefined) return null;
365
+ const frame = byId.get(outermost(layers, hit, isolationRoot));
366
+ return frame === undefined ? null : screenQuad(frame, view.cam, view.rect.width, view.rect.height);
367
+ };
368
+
369
+ feed.pickRect = (pane, rect) => {
370
+ const view = viewOf(pane);
371
+ if (view === null) return [];
372
+ return pickInRect(pickable, view.cam, view.rect.width, view.rect.height, rect);
373
+ };
374
+
375
+ feed.grabAt = (pane, x, y, selection) => {
376
+ if (selection.length === 0) return null;
377
+ // ── THE UNION BOX OUTRANKS EVERY MEMBER'S OWN HANDLES ─────────────
378
+ //
379
+ // When a multi-select has one honest box (`unionQuad`), that box is the
380
+ // only thing drawn with handles and it is therefore the only thing that
381
+ // may answer a press near one. Testing the members first would answer a
382
+ // union corner that happens to coincide with a member's corner — the
383
+ // commonest case there is, since the union's corners ARE member corners —
384
+ // with a single-layer `resize`, and the author would watch one layer of
385
+ // eight grow.
386
+ //
387
+ // A LOCKED MEMBER IS NOT FILTERED OUT HERE. The box still draws and still
388
+ // grabs; `applyGesture` skips the locked layer and names it, which is the
389
+ // honest report. Dropping it from the union instead would silently shrink
390
+ // the box the author drew a marquee around.
391
+ const union = feed.union;
392
+ const unionQ = union === null ? null : pane === "camera" ? union.camera : union.working;
393
+ if (union !== null && unionQ !== null) {
394
+ const grabbed = nearestHandle(unionQ, x, y);
395
+ // ANY member's matrix serves as the gesture plane: `unionQuad` only
396
+ // answers for a COPLANAR selection, so every member shares the box's
397
+ // plane and `gesturePlane("layer", …)` gives the same one from any of
398
+ // them. The first that still has a frame this playhead, for the same
399
+ // reason `selectionPivot` skips absent ones.
400
+ const any = feed.ids.map((id) => byId.get(id)).find((f) => f !== undefined);
401
+ if (grabbed !== null && any !== undefined) {
402
+ return {
403
+ kind: "scale",
404
+ handle: grabbed.handle,
405
+ matrixWorld: any.matrixWorld,
406
+ handleAngle: grabbed.angle,
407
+ centre: quadCentre(unionQ),
408
+ union: union.world,
409
+ };
410
+ }
411
+ }
412
+ // ── HANDLES FIRST, IN BOTH PANES ──────────────────────────────────
413
+ //
414
+ // Handles sit on top of the layer, so a press inside the box near one is
415
+ // a resize and not a move.
416
+ //
417
+ // IT USED TO BE CAMERA-PANE ONLY, on the grounds that "a resize is a
418
+ // screen drag divided by the view's zoom, and the working pane has no
419
+ // zoom, it has a distance". That reason is gone: `runTransformGesture`
420
+ // now unprojects two rays onto the layer's own plane, which has no zoom
421
+ // term at all and is correct for a perspective or an orthographic camera
422
+ // alike. So the working pane draws handles and grabs them, and the author
423
+ // can finally resize the thing they are looking at in 3D.
424
+ const quads = pane === "camera" ? feed.quadsCamera : feed.quadsWorking;
425
+ for (const [i, id] of feed.ids.entries()) {
426
+ const frame = byId.get(id);
427
+ // LOCKED LAYERS OFFER NO HANDLE. `pickable` above does not filter
428
+ // locked — that filtering lives in `project.ts`'s pickers, which is why
429
+ // the body branch below is already safe and this one was not. A locked
430
+ // layer that resizes from a handle is the lock silently not working.
431
+ if (frame === undefined || frame.locked === true) continue;
432
+ const quad = quads[i];
433
+ const grabbed = nearestHandle(quad, x, y);
434
+ if (grabbed !== null && quad !== undefined) {
435
+ return {
436
+ kind: "resize",
437
+ handle: grabbed.handle,
438
+ matrixWorld: frame.matrixWorld,
439
+ handleAngle: grabbed.angle,
440
+ centre: quadCentre(quad),
441
+ };
442
+ }
443
+ // ── THE ROTATE RING, RANKED AFTER THE RESIZE HANDLES ────────────
444
+ //
445
+ // CAMERA PANE ONLY. `rollDeg` turns a layer about the design z axis,
446
+ // which IS the camera pane's view axis — so a screen sweep there is
447
+ // exactly a roll. Under the working pane's free orbit the view axis is
448
+ // some other direction entirely, and a ring drawn on screen would
449
+ // promise a rotation the document model cannot express. The working
450
+ // pane keeps `R`, whose degrees are unambiguous.
451
+ if (pane !== "camera" || quad === undefined) continue;
452
+ const ring = rotateRingAt(quad, x, y);
453
+ if (ring !== null) {
454
+ return {
455
+ kind: "roll",
456
+ matrixWorld: frame.matrixWorld,
457
+ handleAngle: ring.angle,
458
+ // THE CENTRE THE ANGLE WAS MEASURED ABOUT, carried so the drag
459
+ // sweeps about the quad that was grabbed rather than about
460
+ // whichever selected id happens to sort lowest.
461
+ centre: quadCentre(quad),
462
+ };
463
+ }
464
+ }
465
+ // Then the BODY of anything already selected. Pressing an unselected
466
+ // layer must select it rather than drag it — dragging something the
467
+ // author had not selected is the classic way to move the wrong thing.
468
+ const hits = feed.pick?.(pane, x, y) ?? [];
469
+ const hit = hits.find((id) => selection.includes(id));
470
+ const frame = hit === undefined ? undefined : byId.get(hit);
471
+ return frame === undefined ? null : { kind: "move", matrixWorld: frame.matrixWorld };
472
+ };
473
+
474
+ // The overlay set follows the SELECTION, and a selected layer that is not
475
+ // in the scene this frame (its lifetime ended, or the playhead left its
476
+ // scene) simply has no quad — it must not hold a stale one.
477
+ const ids: string[] = [];
478
+ const locked: boolean[] = [];
479
+ const quadsCamera: ScreenQuad[] = [];
480
+ const quadsWorking: ScreenQuad[] = [];
481
+ const selected: LayerFrame[] = [];
482
+ let changed = selection.length !== feed.ids.length;
483
+ for (const [i, id] of selection.entries()) {
484
+ const frame = byId.get(id);
485
+ if (frame === undefined) {
486
+ changed = true;
487
+ continue;
488
+ }
489
+ selected.push(frame);
490
+ if (feed.ids[i] !== id) changed = true;
491
+ // The lock is a document flag, so it changes only on a commit — but the
492
+ // overlay repaints off `feed.version`, not off React, so it has to be in
493
+ // the change test or a lock toggled from the outliner would leave the
494
+ // handles drawn until something else happened to move a quad.
495
+ if (feed.locked[locked.length] !== (frame.locked === true)) changed = true;
496
+ locked.push(frame.locked === true);
497
+ if (cameraRect !== null) {
498
+ const quad = screenQuad(frame, camera, cameraRect.width, cameraRect.height);
499
+ if (quadMoved(feed.quadsCamera[quadsCamera.length], quad)) changed = true;
500
+ quadsCamera.push(quad);
501
+ }
502
+ if (workingRect !== null && feed.workingCamera !== null) {
503
+ const quad = screenQuad(frame, feed.workingCamera, workingRect.width, workingRect.height);
504
+ if (quadMoved(feed.quadsWorking[quadsWorking.length], quad)) changed = true;
505
+ quadsWorking.push(quad);
506
+ }
507
+ ids.push(id);
508
+ }
509
+ // ── THE MULTI-SELECT'S ONE BOX ────────────────────────────────────────
510
+ //
511
+ // Derived from the SAME frames the outlines came from, in the same pass, so
512
+ // the box and the members it contains can never be a frame apart. Null the
513
+ // moment the selection stops being coplanar and axis-parallel (see
514
+ // `unionQuad`), which is a state the author can reach mid-drag by tilting —
515
+ // hence in the change test rather than assumed stable.
516
+ const world = unionQuad(selected);
517
+ const union =
518
+ world === null
519
+ ? null
520
+ : {
521
+ world,
522
+ camera: cameraRect === null ? null : projectQuad(world, camera, cameraRect.width, cameraRect.height),
523
+ working:
524
+ workingRect === null || feed.workingCamera === null
525
+ ? null
526
+ : projectQuad(world, feed.workingCamera, workingRect.width, workingRect.height),
527
+ };
528
+ if (unionMoved(feed.union, union)) changed = true;
529
+ // A pane appearing or disappearing changes an array's LENGTH without moving
530
+ // any corner, so the emptiness check is what makes a mode switch repaint —
531
+ // otherwise the overlay keeps drawing the pane that just went away.
532
+ if (
533
+ !changed &&
534
+ quadsCamera.length === feed.quadsCamera.length &&
535
+ quadsWorking.length === feed.quadsWorking.length
536
+ ) {
537
+ return;
538
+ }
539
+ feed.ids = ids;
540
+ feed.locked = locked;
541
+ feed.union = union;
542
+ feed.quadsCamera = quadsCamera;
543
+ feed.quadsWorking = quadsWorking;
544
+ feed.version += 1;
545
+ // The overlay leaf's own subscription (see feed.onOverlay) — never a
546
+ // StageApp state setter, which re-committed the whole stage per moved
547
+ // quad and drew a second GL frame for every camera-move frame.
548
+ feed.onOverlay?.();
549
+ }, 0);
550
+
551
+ return null;
552
+ }
553
+
554
+
555
+ /**
556
+ * Every layer group in the scene, as the geometry the editor reasons about.
557
+ *
558
+ * It reads `userData` rather than the mesh, and that is the whole point: a
559
+ * paint layer's plane is GROWN by the rasterizer's shadow padding, so measuring
560
+ * the mesh would draw the outline a blur radius too big and pick the layer from
561
+ * outside its own edge. The group carries the transform and no scale.
562
+ */
563
+ export function collectFrames(root: Object3D): LayerFrame[] {
564
+ const out: LayerFrame[] = [];
565
+ root.traverse((o) => {
566
+ const id: unknown = o.userData[SHOT_LAYER_KEY];
567
+ const box: unknown = o.userData[SHOT_LAYER_BOX];
568
+ if (typeof id !== "string" || !Array.isArray(box)) return;
569
+ out.push({
570
+ id,
571
+ size: [Number(box[0]), Number(box[1])],
572
+ // Live reference, not a clone: `traverse` runs after the transforms are
573
+ // written, and the quad is read in the same tick.
574
+ matrixWorld: o.matrixWorld,
575
+ order: typeof o.userData[SHOT_LAYER_ORDER] === "number" ? (o.userData[SHOT_LAYER_ORDER] as number) : 0,
576
+ locked: o.userData[SHOT_LAYER_LOCKED] === true,
577
+ pickable: o.userData[SHOT_LAYER_PICKABLE] !== false,
578
+ });
579
+ });
580
+ return out;
581
+ }