@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,636 @@
1
+ // The working pane's free-orbit camera, as pure spherical arithmetic.
2
+ //
3
+ // ── COORDINATE CONVENTION, STATED ONCE ──────────────────────────────────
4
+ //
5
+ // The DOCUMENT is design px with +y DOWN and +z AWAY. three is +y UP and -z
6
+ // away. The one mapping is `world = (x, -y, -z)`, and each lane performs it in
7
+ // exactly one place: ShotRuntime for the camera and the layers, move.ts for a
8
+ // gesture delta (see packages/shot docs).
9
+ //
10
+ // EVERYTHING HERE IS ALREADY WORLD (three) COORDINATES. `target` is a world
11
+ // point, `workingPose().position` is a world point, and nothing in this file
12
+ // negates anything. A second negation is the exact failure this note exists to
13
+ // prevent: it looks right on a centred canvas and inverts the orbit everywhere
14
+ // else, which reads as "orbit is inverted sometimes".
15
+ //
16
+ // One unit is one design px, because the mapping has no scale in it.
17
+ //
18
+ // ── WHY THIS IS NOT A SECOND DOCUMENT CAMERA ────────────────────────────
19
+ //
20
+ // view.ts's header says the shot has ONE camera, and that still holds: this one
21
+ // is EDITOR STATE. It is never written to the document, never sampled, and
22
+ // never reaches an export. It decides where the author is standing while they
23
+ // look at the shot; the shot itself is unchanged by where they stand.
24
+ //
25
+ // ── ANGLES ARE DEGREES ──────────────────────────────────────────────────
26
+ //
27
+ // Hence every constant being named `*_DEG`. `phi` is the polar angle FROM WORLD
28
+ // +Y, which is three's own `Spherical` convention, so `phi = 90, theta = 0`
29
+ // puts the eye at +z looking back down the shot camera's own axis. Keeping the
30
+ // convention identical to three's is what lets `workingPose` be checked against
31
+ // `Vector3.setFromSphericalCoords` rather than against a diagram.
32
+ //
33
+ // THREE-FREE: plain arrays and numbers, so the chrome can render a readout of
34
+ // the view and these functions are testable in node.
35
+ import { DEFAULT_FOV_DEG, planeDistance } from "@dailies-studio/shot/doc";
36
+ import { FIT_MARGIN } from "./view.js";
37
+ import type { FrameSize } from "./view.js";
38
+
39
+ /**
40
+ * The working camera's lens, FIXED.
41
+ *
42
+ * Deliberately not `scene.fovDeg`: a zoom shot animates the shot camera's lens
43
+ * every frame, and a working pane that followed it would breathe under the
44
+ * author's hands while they orbit — the one thing an authoring view must not
45
+ * do. It borrows the schema's own default only so this lane has no second
46
+ * magic number, not because the two are coupled.
47
+ *
48
+ * Everything that needs the working camera's lens reads THIS: the camera the
49
+ * stage writes, and `defaultWorkingView`'s derived distance. A second opinion
50
+ * would make "fit" frame something other than what is drawn.
51
+ */
52
+ export const WORKING_FOV_DEG = DEFAULT_FOV_DEG;
53
+
54
+ export interface WorkingView {
55
+ /** Azimuth in degrees, wrapped to [-180, 180). 0 is the shot camera's axis. */
56
+ theta: number;
57
+ /** Polar angle from world +y, in degrees. Clamped off both poles. */
58
+ phi: number;
59
+ /** Eye-to-target distance, in world units (= design px). */
60
+ distance: number;
61
+ /** The world point the eye orbits and looks at. */
62
+ target: [number, number, number];
63
+ /**
64
+ * ORTHOGRAPHIC instead of perspective. Numpad-5, and the reason ours used to
65
+ * answer null: the pane had only a perspective lens.
66
+ *
67
+ * It is what makes an elevation readable — parallel edges stay parallel, so
68
+ * two layers the same size measure the same on screen wherever they sit in
69
+ * depth, which is exactly what you want while lining a card up against
70
+ * another one behind it.
71
+ *
72
+ * ── AND WHY NOTHING ELSE IN THIS FILE BRANCHES ON IT ──────────────────
73
+ *
74
+ * The ortho half-height the renderer uses is `distance * tan(fov/2)`, which
75
+ * is exactly the half-height the perspective lens frames AT THE TARGET. So
76
+ * the picture is identical at the moment of the toggle, and
77
+ * `workingScreenScale`'s `2 * distance * tan(fov/2)` is already right for
78
+ * both. Orbit, pan and dolly are unchanged too: they move the same five
79
+ * numbers.
80
+ *
81
+ * `framingFromWorkingView` is the ONE exception and it is load-bearing — see
82
+ * its own note.
83
+ *
84
+ * Optional so every existing literal in this repo, and every persisted
85
+ * workspace pref, keeps meaning perspective with no migration.
86
+ */
87
+ ortho?: boolean;
88
+ }
89
+
90
+ export const ORBIT_DEG_PER_PX = 0.3;
91
+
92
+ /**
93
+ * How far one numpad orbit step turns the view, in DEGREES.
94
+ *
95
+ * Blender's own 15. It lives HERE beside `ORBIT_DEG_PER_PX` rather than in the
96
+ * keymap, because the keymap deciding how far a turn is would be a second
97
+ * opinion on the same question — and a degrees-based step means it does not
98
+ * change when the drag sensitivity does.
99
+ */
100
+ export const ORBIT_STEP_DEG = 15;
101
+
102
+
103
+ /** Off the poles, both ends. At phi 0 or 180 the eye is straight above or below
104
+ * the target, `lookAt`'s up vector is parallel to the view direction, and the
105
+ * camera's roll is undefined — which shows up as the view snapping to an
106
+ * arbitrary rotation exactly at the top of a drag. */
107
+ export const MIN_PHI_DEG = 5;
108
+ export const MAX_PHI_DEG = 175;
109
+
110
+ /** A degenerate distance is not a far-away camera, it is a camera AT the target:
111
+ * `lookAt` gets a zero-length direction and writes a NaN quaternion, and every
112
+ * projected quad after that is NaN. The floor is one design px because a
113
+ * sub-pixel camera distance means nothing in a design-px world. */
114
+ const MIN_DISTANCE = 1;
115
+ const MAX_DISTANCE = 1e7;
116
+
117
+ /** Wheel px for one e-fold of distance. THE SAME 320 `wheelToView` uses for
118
+ * zoom, so one notch is the same ratio whichever pane it lands in. Exported so
119
+ * the chrome's zoom BUTTONS can express their step as a ratio through
120
+ * `dollyBy` rather than reaching past the clamps to write `distance`. */
121
+ export const DOLLY_FALLOFF_PX = 320;
122
+
123
+ /**
124
+ * How far a NAVIGATION gesture may dolly, as a ratio of the fit distance.
125
+ *
126
+ * `MIN_DISTANCE`/`MAX_DISTANCE` above are SANITISATION — they exist so `lookAt`
127
+ * never sees a zero-length direction and so a distance stays a finite float.
128
+ * They are deliberately vacuous as travel limits: 1e7 is ~2400x the fit
129
+ * distance of a 1080-tall document, which is a wheel spin into empty space with
130
+ * the canvas long since a dot, and 1 puts the eye a single design px from the
131
+ * target. Neither scales with the document, so a 4K board and a square sticker
132
+ * get the same nonsense band.
133
+ *
134
+ * These two do scale, because they are expressed against `defaultWorkingView`'s
135
+ * derived fit. For a 1920x1080 document (fit ~4159) the band is ~[42, 41590]:
136
+ * at the near wall the smallest legal layer, 1 design px, still spans a
137
+ * readable fraction of the pane, and at the far wall the whole canvas reads
138
+ * about 42 px tall. Past either end there is nothing to look at.
139
+ */
140
+ export const DOLLY_MIN_FIT_RATIO = 1 / 100;
141
+ export const DOLLY_MAX_FIT_RATIO = 10;
142
+
143
+ /** The travel limits for one document, from `dollyBounds`. */
144
+ export interface DollyBounds {
145
+ min: number;
146
+ max: number;
147
+ }
148
+
149
+ /**
150
+ * The navigation band for a document, derived from the same fit
151
+ * `defaultWorkingView` computes so the two cannot disagree.
152
+ *
153
+ * Only NAVIGATION is bounded. `defaultWorkingView`, `workingViewForFraming` and
154
+ * `workingViewForIsolation` answer "show me exactly this" and stay unbounded on
155
+ * purpose: fitting a selection the author deliberately made tiny should frame
156
+ * it, not refuse. The next dolly then walks back into band from wherever the
157
+ * fit landed, which is the behaviour a wall should have.
158
+ *
159
+ * PURE in (frame, fov), and three-free, so the chrome's zoom buttons can ask
160
+ * for the same numbers the stage's wheel uses.
161
+ */
162
+ export function dollyBounds(frame: FrameSize, fovDeg: number = WORKING_FOV_DEG): DollyBounds {
163
+ const fit = defaultWorkingView(frame, fovDeg).distance;
164
+ return { min: fit * DOLLY_MIN_FIT_RATIO, max: fit * DOLLY_MAX_FIT_RATIO };
165
+ }
166
+
167
+ /** The default three-quarter look. Off-axis on both angles so the canvas reads
168
+ * as a plane in space at a glance; a straight-on default would be
169
+ * indistinguishable from the camera pane and the split would look broken. */
170
+ const DEFAULT_THETA_DEG = 35;
171
+ const DEFAULT_PHI_DEG = 65;
172
+
173
+ const DEG = Math.PI / 180;
174
+
175
+ function clampPhi(phi: number): number {
176
+ if (!Number.isFinite(phi)) return 90;
177
+ return Math.max(MIN_PHI_DEG, Math.min(MAX_PHI_DEG, phi));
178
+ }
179
+
180
+ function clampDistance(distance: number): number {
181
+ if (!Number.isFinite(distance)) return MIN_DISTANCE;
182
+ return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
183
+ }
184
+
185
+ /** Azimuth into [-180, 180). Wrapped rather than clamped: a full turn is a real
186
+ * gesture, and an unwrapped theta grows without bound over a session until it
187
+ * loses float precision in `Math.sin`. */
188
+ function wrapDeg(deg: number): number {
189
+ if (!Number.isFinite(deg)) return 0;
190
+ return ((((deg + 180) % 360) + 360) % 360) - 180;
191
+ }
192
+
193
+ /**
194
+ * The eye position for a view.
195
+ *
196
+ * This IS `Vector3.setFromSphericalCoords(r, phi, theta)` offset by the target,
197
+ * written out so the file stays three-free — and so it can be diffed against
198
+ * three when someone doubts the convention. The camera's orientation is not
199
+ * returned because it is not free: the working camera always looks at `target`.
200
+ */
201
+ export function workingPose(view: WorkingView): { position: [number, number, number] } {
202
+ const phi = clampPhi(view.phi) * DEG;
203
+ const theta = wrapDeg(view.theta) * DEG;
204
+ const r = clampDistance(view.distance);
205
+ const sinPhi = Math.sin(phi);
206
+ return {
207
+ position: [
208
+ view.target[0] + r * sinPhi * Math.sin(theta),
209
+ view.target[1] + r * Math.cos(phi),
210
+ view.target[2] + r * sinPhi * Math.cos(theta),
211
+ ],
212
+ };
213
+ }
214
+
215
+ /**
216
+ * A drag orbits the eye around the target.
217
+ *
218
+ * BOTH SIGNS ARE NEGATIVE, which is `OrbitControls`' convention and therefore
219
+ * the one already in the author's hands: dragging right walks the eye left, so
220
+ * the thing under the cursor appears to follow the cursor. Dragging down walks
221
+ * the eye up and over, so the canvas tips towards the author.
222
+ *
223
+ * Measured from the PRESS by the caller, never accumulated — the same rule
224
+ * `panBy` states, for the same reason.
225
+ */
226
+ export function orbitBy(view: WorkingView, dxPx: number, dyPx: number): WorkingView {
227
+ return {
228
+ ...view,
229
+ theta: wrapDeg(view.theta - dxPx * ORBIT_DEG_PER_PX),
230
+ phi: clampPhi(clampPhi(view.phi) - dyPx * ORBIT_DEG_PER_PX),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The same orbit, asked for in DEGREES.
236
+ *
237
+ * The numpad's step is an angle, not a distance, so routing it through
238
+ * `orbitBy` would make it change whenever the drag sensitivity did. Both share
239
+ * one implementation, so the two can never differ about which way is which.
240
+ *
241
+ * The SIGNS ARE FLIPPED against `orbitBy` because the two are asked opposite
242
+ * questions: a drag says "the eye moves this way against the cursor" and a
243
+ * numpad says "turn the view this way". Numpad4 turning the view LEFT is what
244
+ * the key means and what Blender does.
245
+ */
246
+ export function orbitByDegrees(view: WorkingView, dxDeg: number, dyDeg: number): WorkingView {
247
+ return orbitBy(view, -dxDeg / ORBIT_DEG_PER_PX, -dyDeg / ORBIT_DEG_PER_PX);
248
+ }
249
+
250
+ /**
251
+ * SCREEN PX PER DESIGN PX at the target's depth — the working pane's answer to
252
+ * `ShotView.zoom`, which is the same quantity for the flat camera pane.
253
+ *
254
+ * Exported because two lanes need the identical number and a second derivation
255
+ * would put them a lens apart: the pan below turns a drag into world units with
256
+ * its reciprocal, and the chrome's snapping divides a SCREEN threshold by it —
257
+ * a working-pane drag measured against the camera pane's zoom snaps at a
258
+ * distance that has nothing to do with what the author can see.
259
+ *
260
+ * `viewportHeight` is the PANE's height, not the canvas's, because the pane is
261
+ * what the fov spans.
262
+ *
263
+ * Infinity for a degenerate lens, which is the answer that stays useful in both
264
+ * directions: the pan's `1 / scale` is then zero (a lens with no extent moves
265
+ * nothing) and the snap threshold divided by it is zero (nothing to snap to).
266
+ */
267
+ export function workingScreenScale(
268
+ view: WorkingView,
269
+ viewportHeight: number,
270
+ fovDeg: number = WORKING_FOV_DEG,
271
+ ): number {
272
+ const height = Number.isFinite(viewportHeight) && viewportHeight > 0 ? viewportHeight : 1;
273
+ const fov = Number.isFinite(fovDeg) ? fovDeg : 0;
274
+ const visible = 2 * clampDistance(view.distance) * Math.tan((fov * DEG) / 2);
275
+ return visible > 0 ? height / visible : Number.POSITIVE_INFINITY;
276
+ }
277
+
278
+ /**
279
+ * The camera basis straight out of the two angles, all three UNIT LENGTH.
280
+ *
281
+ * three's `lookAt` builds z = normalize(eye - target), x = normalize(up × z),
282
+ * y = z × x with world +y as up; substitute the spherical eye and those
283
+ * collapse to exactly these closed forms. No matrix and no cross product means
284
+ * nothing here can drift out of step with the renderer's own basis.
285
+ *
286
+ * `forward` points FROM the eye AT the target — the direction the author is
287
+ * looking, which is `-z` of that basis. Stated as its own name because every
288
+ * consumer wants the looking direction and none of them wants to remember the
289
+ * sign.
290
+ *
291
+ * Three lanes read it: the pan below turns a screen drag into a world one, the
292
+ * nav gizmo projects the design axes onto (right, up), and the same gizmo sorts
293
+ * its balls by `forward`. A second derivation in any of them would put the
294
+ * drawn axes a rotation away from the one the pan moves along.
295
+ */
296
+ export function viewBasis(view: WorkingView): {
297
+ right: [number, number, number];
298
+ up: [number, number, number];
299
+ forward: [number, number, number];
300
+ } {
301
+ const theta = wrapDeg(view.theta) * DEG;
302
+ const phi = clampPhi(view.phi) * DEG;
303
+ const sinPhi = Math.sin(phi);
304
+ return {
305
+ right: [Math.cos(theta), 0, -Math.sin(theta)],
306
+ up: [-Math.cos(phi) * Math.sin(theta), sinPhi, -Math.cos(phi) * Math.cos(theta)],
307
+ // `workingPose` puts the eye at +r along (sinφ sinθ, cosφ, sinφ cosθ) from
308
+ // the target, so looking AT the target is that same direction negated.
309
+ forward: [-sinPhi * Math.sin(theta), -Math.cos(phi), -sinPhi * Math.cos(theta)],
310
+ };
311
+ }
312
+
313
+ /**
314
+ * A drag slides the target across the screen, keeping the eye's angles.
315
+ *
316
+ * The scale is the visible height AT THE TARGET'S DEPTH divided by the
317
+ * viewport's — `planeDistance` run backwards. Panning at the target's depth is
318
+ * what makes the point under the cursor keep up with the cursor; using any
319
+ * other depth makes the canvas either lag or outrun the pointer, at a rate that
320
+ * changes with the dolly.
321
+ */
322
+ export function panWorkingBy(
323
+ view: WorkingView,
324
+ dxPx: number,
325
+ dyPx: number,
326
+ viewportHeight: number,
327
+ fovDeg: number,
328
+ ): WorkingView {
329
+ const perPx = 1 / workingScreenScale(view, viewportHeight, fovDeg);
330
+ const { right, up } = viewBasis(view);
331
+ return {
332
+ ...view,
333
+ target: [
334
+ view.target[0] + (dyPx * up[0] - dxPx * right[0]) * perPx,
335
+ view.target[1] + dyPx * up[1] * perPx,
336
+ view.target[2] + (dyPx * up[2] - dxPx * right[2]) * perPx,
337
+ ],
338
+ };
339
+ }
340
+
341
+ /**
342
+ * The wheel dollies the eye in and out.
343
+ *
344
+ * EXPONENTIAL, mirroring `wheelToView`: distance is the reciprocal of zoom, so
345
+ * where zoom multiplies by `exp(-dy / 320)` this multiplies by `exp(+dy / 320)`
346
+ * and a notch is the same visual step in either pane. Linear steps would crawl
347
+ * when far out and jump when close in.
348
+ *
349
+ * `wheelDeltaY` is already `deltaMode`-scaled by the caller, exactly as
350
+ * `wheelToView` scales it — Firefox reports lines where Chrome reports px.
351
+ *
352
+ * `bounds` is the document's navigation band (`dollyBounds`). It is optional
353
+ * because the sanitisation clamp has to hold with or without a document, and
354
+ * because an absent argument leaves this function byte-identical to the version
355
+ * every existing caller was written against.
356
+ *
357
+ * THE WHEEL COALESCER SUMS DELTAS, so the clamp has to compose. It does: for a
358
+ * monotone map into a saturating interval, clamping each step and clamping the
359
+ * composed step land on the same value for a same-sign burst, which is every
360
+ * real wheel spin. A burst that reverses inside one frame loses only the travel
361
+ * a wall would have eaten anyway.
362
+ */
363
+ export function dollyBy(view: WorkingView, wheelDeltaY: number, bounds?: DollyBounds): WorkingView {
364
+ const dy = Number.isFinite(wheelDeltaY) ? wheelDeltaY : 0;
365
+ const dollied = clampDistance(clampDistance(view.distance) * Math.exp(dy / DOLLY_FALLOFF_PX));
366
+ const distance = bounds === undefined ? dollied : Math.max(bounds.min, Math.min(bounds.max, dollied));
367
+ return { ...view, distance };
368
+ }
369
+
370
+ /**
371
+ * The view an author gets before they touch anything: an off-axis three-quarter
372
+ * look with the WHOLE canvas in shot.
373
+ *
374
+ * THE DISTANCE IS DERIVED, NOT AUTHORED, for the same reason `planeDistance` is
375
+ * (resolve.ts): there is then no number for anyone to get wrong. The shot
376
+ * camera's own distance frames the canvas HEIGHT exactly. Seen from an angle
377
+ * the canvas is turned, so the extent that has to fit is its DIAGONAL — which
378
+ * is the identical relationship with `hypot(w, h)` substituted for the height,
379
+ * and it is rotation-invariant, so no orbit angle can push a corner off-pane.
380
+ * Then the same `FIT_MARGIN` the 2D fit uses, so "fit" means one thing here.
381
+ *
382
+ * PURE in (frame, fov): a document opens at the same view on every machine, and
383
+ * a fresh load and a reset button cannot disagree.
384
+ */
385
+ export function defaultWorkingView(frame: FrameSize, fovDeg: number): WorkingView {
386
+ const diagonal = Math.hypot(frame.width, frame.height);
387
+ return {
388
+ theta: DEFAULT_THETA_DEG,
389
+ phi: DEFAULT_PHI_DEG,
390
+ distance: clampDistance(planeDistance(diagonal, fovDeg, 1) / (1 - FIT_MARGIN * 2)),
391
+ target: [0, 0, 0],
392
+ };
393
+ }
394
+
395
+ /**
396
+ * The orbit pose that stands where isolation's DERIVED head-on rig stands.
397
+ *
398
+ * ── WHY THE HAND-OVER NEEDS A CONVERSION AT ALL ─────────────────────────
399
+ *
400
+ * Isolation draws with an orthographic camera posed from the subtree's own
401
+ * basis (isolation.ts), and the author's first navigation gesture hands the
402
+ * pane back to this file's spherical rig. Without seeding, that gesture would
403
+ * continue from wherever the orbit camera happened to be BEFORE the author
404
+ * entered the subtree, and the picture would jump the width of the scene.
405
+ *
406
+ * This is `workingPose` run backwards, so the two cannot disagree: substitute
407
+ * the returned angles and the eye comes back where it started.
408
+ *
409
+ * ── THE NAMED CEILING ───────────────────────────────────────────────────
410
+ *
411
+ * ponytail: the orbit rig resets `up` to world +y every frame and clamps its
412
+ * polar angle off both poles, so a subtree that is ROLLED or lying FACE DOWN
413
+ * has no exact representation here and seeds to the nearest pose that does. The
414
+ * picture turns once, at the moment the author takes the camera, by the amount
415
+ * the rig cannot express. Head-on stays exact because the reset goes back to
416
+ * the derived rig rather than trying to represent it. Upgrade path is a
417
+ * free-roll working camera, which is a bigger change than the jump is worth.
418
+ *
419
+ * `halfHeight` is the ortho rig's own half-extent, and the distance is derived
420
+ * so the perspective lens frames the same height — the ortho camera's distance
421
+ * is arbitrary (it is chosen for the clip planes) and carrying it across would
422
+ * frame something else entirely.
423
+ */
424
+ export function workingViewForIsolation(
425
+ eye: readonly [number, number, number],
426
+ target: readonly [number, number, number],
427
+ halfHeight: number,
428
+ fovDeg: number = WORKING_FOV_DEG,
429
+ ): WorkingView {
430
+ const dx = eye[0] - target[0];
431
+ const dy = eye[1] - target[1];
432
+ const dz = eye[2] - target[2];
433
+ const r = Math.hypot(dx, dy, dz);
434
+ // A degenerate separation means the derived rig is not looking anywhere yet,
435
+ // which the clamps below turn into a usable pose rather than a NaN one.
436
+ const unitY = r > 0 ? dy / r : 0;
437
+ const tan = Math.tan((Number.isFinite(fovDeg) ? fovDeg : WORKING_FOV_DEG) * DEG * 0.5);
438
+ return {
439
+ theta: wrapDeg((Math.atan2(dx, dz) * 180) / Math.PI),
440
+ phi: clampPhi((Math.acos(Math.max(-1, Math.min(1, unitY))) * 180) / Math.PI),
441
+ distance: clampDistance(tan > 0 ? halfHeight / tan : MIN_DISTANCE),
442
+ target: [target[0], target[1], target[2]],
443
+ // ORTHOGRAPHIC, because the rig this inverts IS one (isolation.ts's
444
+ // `isolationEye` builds an `OrthographicCamera`). Rebuilding the view
445
+ // without the flag silently dropped the pane back to perspective at the
446
+ // exact moment the author took the camera over, so the picture changed on
447
+ // the first orbit with nothing having asked for it.
448
+ ortho: true,
449
+ };
450
+ }
451
+
452
+ /** The Blender-numpad axis views. Plain strings so the keymap can carry one
453
+ * without importing this module. */
454
+ export type WorkingViewPreset = "front" | "back" | "right" | "left" | "top" | "bottom";
455
+
456
+ /**
457
+ * Snap the view to an axis, KEEPING the distance and the target.
458
+ *
459
+ * Angles only: the numpad answers "which side am I looking from", never "how
460
+ * far away am I" — Blender's convention, and the one that makes the keys safe
461
+ * to lean on mid-layout. Front is theta 0 (the shot camera's own axis, per
462
+ * this file's convention) and back wraps to -180 via `wrapDeg`.
463
+ *
464
+ * TOP AND BOTTOM LAND ON THE POLE CLAMPS, not on 0/180: straight down the
465
+ * pole the camera's roll is undefined (see MIN_PHI_DEG), so "top" is the
466
+ * closest pose that still has a stable up — 5 degrees off vertical, keeping
467
+ * the author's theta so the horizon stays where they left it.
468
+ */
469
+ export function presetWorkingView(view: WorkingView, preset: WorkingViewPreset): WorkingView {
470
+ switch (preset) {
471
+ case "front":
472
+ return { ...view, theta: 0, phi: 90 };
473
+ case "back":
474
+ return { ...view, theta: wrapDeg(180), phi: 90 };
475
+ case "right":
476
+ return { ...view, theta: 90, phi: 90 };
477
+ case "left":
478
+ return { ...view, theta: -90, phi: 90 };
479
+ case "top":
480
+ return { ...view, phi: MIN_PHI_DEG };
481
+ case "bottom":
482
+ return { ...view, phi: MAX_PHI_DEG };
483
+ }
484
+ }
485
+
486
+ /**
487
+ * The view held before any document has arrived, in BOTH documents.
488
+ *
489
+ * 1080p is a stand-in and the fitter replaces it the moment a document lands.
490
+ * It exists so the initial state is a real, finite pose rather than a camera
491
+ * sitting AT its own target — which is what `{distance: 0, target: [0,0,0]}`
492
+ * would be, and which makes `lookAt` write a NaN quaternion and blank the pane
493
+ * with no error. The chrome's `INITIAL_STATE` and the stage's own initial state
494
+ * take it from here so a reconnect cannot start them on different views.
495
+ */
496
+ export const INITIAL_WORKING_VIEW: WorkingView = defaultWorkingView(
497
+ { width: 1920, height: 1080 },
498
+ WORKING_FOV_DEG,
499
+ );
500
+
501
+ /**
502
+ * The working view, projected onto the FRAMING model — "align the camera to
503
+ * this view", and the half of the round trip that can fail.
504
+ *
505
+ * ── WHY THIS CAN REFUSE, AND WHY REFUSING IS THE FEATURE ────────────────
506
+ *
507
+ * A `Framing` is four numbers (x, y, zoom, roll) describing a camera that looks
508
+ * straight down -z from the +z side. A free orbit is five, and most of them
509
+ * describe a pose no framing can name. Writing "the closest framing" for a pose
510
+ * the author is looking at from behind the canvas would hand them a camera
511
+ * pointing at something else and call it aligned; naming the reason costs one
512
+ * message and is the only answer they can act on.
513
+ *
514
+ * ── WHAT IS PRESERVED, AND WHAT IS NOT ──────────────────────────────────
515
+ *
516
+ * The CENTRE is where the author is looking: the view axis meets z = 0 there,
517
+ * and that point is the framing's pan by definition of the model.
518
+ *
519
+ * The ZOOM matches the FRAMED AREA, not the camera's distance. The shot camera
520
+ * frames `height / zoom` design px; the working camera frames
521
+ * `2 * t * tan(fov/2)` at the point it is looking at. Equating those is what
522
+ * makes the picture the same size; equating the two DISTANCES instead would be
523
+ * off by the ratio of the lenses, and the working lens is deliberately not the
524
+ * scene's (see WORKING_FOV_DEG).
525
+ *
526
+ * ROLL is not read. The working camera's up is world +y at every pose — it
527
+ * cannot be rolled — so the framing that matches what the author sees has no
528
+ * roll, and inventing one from the foreshortening of world-up would rotate the
529
+ * shot by an amount the author never asked for. The caller keeps whatever roll
530
+ * the framing already had.
531
+ */
532
+ export type ViewFramingResult =
533
+ | { ok: true; x: number; y: number; zoom: number }
534
+ | { ok: false; refusal: string };
535
+
536
+ /**
537
+ * Below this the view axis is parallel enough to the canvas that the hit point
538
+ * runs away to infinity — a grazing pose has no framing, not a huge one.
539
+ *
540
+ * It is used for the AXIS test alone now. It used to also guard the
541
+ * eye-to-target separation, and that arm was unreachable: `clampDistance`
542
+ * floors every distance at `MIN_DISTANCE = 1` for every input including NaN,
543
+ * so the separation can never be smaller than 1. A refusal nobody can trigger
544
+ * is a refusal nobody can trust, so it is gone rather than left to imply a
545
+ * case that does not exist.
546
+ */
547
+ const MIN_AXIS_Z = 1e-4;
548
+
549
+ export function framingFromWorkingView(
550
+ view: WorkingView,
551
+ frame: FrameSize,
552
+ fovDeg: number = WORKING_FOV_DEG,
553
+ ): ViewFramingResult {
554
+ const eye = workingPose(view).position;
555
+ const target = view.target;
556
+ const dir: [number, number, number] = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
557
+ const length = Math.hypot(dir[0], dir[1], dir[2]);
558
+ // A FINITE BACKSTOP, not a separation test. `clampDistance` floors the
559
+ // distance at `MIN_DISTANCE = 1` for every input including NaN, so a camera
560
+ // sitting on its own target is unreachable — the refusal that claimed to
561
+ // catch it was dead code. What CAN still arrive here is a non-finite target,
562
+ // which no clamp on the distance touches.
563
+ if (!Number.isFinite(length)) {
564
+ return {
565
+ ok: false,
566
+ refusal:
567
+ "The working camera is not looking anywhere a framing could describe — press Home to " +
568
+ "put it back, then align.",
569
+ };
570
+ }
571
+
572
+ if (eye[2] <= 0) {
573
+ return {
574
+ ok: false,
575
+ refusal:
576
+ "The working camera is behind the canvas, and the shot camera always looks at it from the " +
577
+ "front — orbit back round to the front of the layers, then align.",
578
+ };
579
+ }
580
+ // Towards the plane means -z: the eye is in front of it, so the axis has to
581
+ // point back at it. Away from it, or along it, has no crossing to frame.
582
+ const axisZ = dir[2] / length;
583
+ if (axisZ > -MIN_AXIS_Z) {
584
+ return {
585
+ ok: false,
586
+ refusal:
587
+ "The working camera is looking along the canvas rather than at it, so there is no " +
588
+ "rectangle for the shot camera to frame — orbit until the canvas faces you, then align.",
589
+ };
590
+ }
591
+
592
+ // Where the view axis meets design z = 0, and how far away that is.
593
+ const t = -eye[2] / axisZ;
594
+ const hit: [number, number] = [eye[0] + (dir[0] / length) * t, eye[1] + (dir[1] / length) * t];
595
+ // ── ORTHOGRAPHIC FRAMES A CONSTANT HEIGHT ────────────────────────────────
596
+ //
597
+ // A perspective camera's framed height grows with the distance to the plane,
598
+ // which is why `t` appears below. An ortho camera's does not: it is the
599
+ // rig's own half-extent, `distance * tan(fov/2)`, wherever the plane happens
600
+ // to be. Substituting `distance` for `t` is not an approximation — it IS the
601
+ // ortho answer, and the two agree exactly when the plane is at the target.
602
+ //
603
+ // LOAD-BEARING, and not optional: this function is a DOCUMENT WRITER (align
604
+ // camera to view, and every gesture under follow mode). Leaving `t` here
605
+ // would write a framing that gets more wrong the further the target sits
606
+ // from z = 0, silently, and autosave it.
607
+ const span = view.ortho === true ? clampDistance(view.distance) : t;
608
+ const zoom = frame.height / (2 * span * Math.tan((fovDeg * DEG) / 2));
609
+ if (!Number.isFinite(zoom) || zoom <= 0) {
610
+ return { ok: false, refusal: "The working camera is too close to the canvas to express as a framing — dolly back, then align." };
611
+ }
612
+ // The one mapping, undone: world y is design -y.
613
+ return { ok: true, x: hit[0], y: -hit[1], zoom };
614
+ }
615
+
616
+ /**
617
+ * The other half of the round trip: the working view that sees exactly what a
618
+ * framing sees.
619
+ *
620
+ * Head on, at the distance whose framed height matches. It exists so "align,
621
+ * then look at what you aligned to" is a checkable identity rather than a
622
+ * claim, and so the fitter has one place to derive a distance from a lens.
623
+ */
624
+ export function workingViewForFraming(
625
+ framing: { x: number; y: number; zoom: number },
626
+ frame: FrameSize,
627
+ fovDeg: number = WORKING_FOV_DEG,
628
+ ): WorkingView {
629
+ const zoom = framing.zoom > 0 ? framing.zoom : 1;
630
+ return {
631
+ theta: 0,
632
+ phi: 90,
633
+ distance: clampDistance(frame.height / (2 * zoom * Math.tan((fovDeg * DEG) / 2))),
634
+ target: [framing.x, -framing.y, 0],
635
+ };
636
+ }