@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,427 @@
1
+ // Blender-style modal transforms (G / R / S) as PURE data and mapping tables.
2
+ //
3
+ // The shape penState.ts established, for the same reason: this module owns
4
+ // what a modal transform MEANS — which gesture kind each mode+axis emits and
5
+ // with what numbers — and none of the plumbing. StageApp owns the pointer,
6
+ // the rays and the capture-phase key listener; the store's gesture lane owns
7
+ // what a delta DOES (base write, block, refusal). Nothing here imports three,
8
+ // so every row of the mapping table is testable in node.
9
+ //
10
+ // ── THE ONE IDEA: A MODAL IS THE EXISTING GESTURE LANE, RE-DRIVEN ───────
11
+ //
12
+ // A modal transform opens the same `onGestureStart` / N× `onGesture` /
13
+ // `onGestureEnd` scrub a pointer drag opens; only the input differs (mouse
14
+ // MOTION with no button held, terminated by click/Enter/Escape). Every emit
15
+ // is a TOTAL from the anchor, and the store re-derives from the scrub's base
16
+ // per capture — which is what makes two properties safe by construction:
17
+ //
18
+ // AXIS TOGGLES RE-PROJECT. Constraining mid-gesture changes the emitted
19
+ // KIND (move ↔ depth, roll ↔ tilt); the last delta wins wholesale from
20
+ // base, which is exactly Blender's re-projection behaviour.
21
+ //
22
+ // PRECISION CANNOT DRIFT THE DOCUMENT. Shift scales the VIRTUAL CURSOR's
23
+ // steps (`advanceVirtual`), so only the accumulated travel is
24
+ // path-dependent — which is what precision means — while the document
25
+ // still re-derives from base on every emit.
26
+ import type { FramingDelta, GestureDelta, GestureKind } from "../bridgeTypes.js";
27
+
28
+ export type ModalMode = "move" | "rotate" | "scale";
29
+ export type ModalAxis = "x" | "y" | "z" | null;
30
+
31
+ export interface ModalState {
32
+ mode: ModalMode;
33
+ /** Layers when the selection is non-empty, else the CAMERA's framing — the
34
+ * same implicit subject the frustum gizmo already has. The camera is
35
+ * deliberately not selectable (see the keymap's starter lane). */
36
+ target: "layers" | "camera";
37
+ axis: ModalAxis;
38
+ }
39
+
40
+ /** Degrees of tilt/roll per CSS pixel dragged. A whole screen width should be
41
+ * about a half turn, or rotating is either unusable or uncontrollable. ONE
42
+ * definition for the drag lane and the modal lane — StageApp imports it from
43
+ * here. */
44
+ export const TILT_DEG_PER_PX = 0.4;
45
+
46
+ /** Shift's precision factor on the virtual cursor's steps. */
47
+ export const PRECISION_FACTOR = 0.1;
48
+
49
+ /** Travel px for one e-fold of a modal scale. The same 320 the wheel dolly and
50
+ * the frustum apex use, so "one notch" is the same proportion whichever of the
51
+ * three the author reaches for. */
52
+ export const SCALE_FALLOFF_PX = 320;
53
+
54
+ /**
55
+ * What CTRL quantises to, per unit.
56
+ *
57
+ * Blender's own 15 degrees for a rotation. 10 design px is a round number in a
58
+ * 1080-tall canvas and the step the arrow keys already coarse-nudge by
59
+ * (`NUDGE_COARSE_PX`), so the two agree about what "a round move" is. A tenth
60
+ * is what a scale's round numbers are: 1.5x, 2x.
61
+ *
62
+ * There is deliberately NO second snap threshold here. The chrome's smart
63
+ * guides snap a drag to other layers' edges, which is a different question
64
+ * — "line this up with that" rather than "give me a round number" — and a
65
+ * quantise that also tried to be a guide would fight it.
66
+ */
67
+ export const SNAP_PX = 10;
68
+ export const SNAP_DEG = 15;
69
+ export const SNAP_RATIO = 0.1;
70
+
71
+ export function modalTarget(hasSelection: boolean): ModalState["target"] {
72
+ return hasSelection ? "layers" : "camera";
73
+ }
74
+
75
+ /**
76
+ * Toggle an axis constraint: the same axis again clears it, a different one
77
+ * replaces it — Blender's toggle. Three claimed-and-ignored cells: `z` in
78
+ * scale mode (size is a Vec2, there is no z to scale), any axis on the
79
+ * camera's zoom (a dolly has no axis), and `z` on the camera's move (a
80
+ * framing has no z). Ignoring rather than storing them keeps the label
81
+ * honest — a pill saying "Scale · Z" over a gesture that ignores z would be
82
+ * a lie.
83
+ */
84
+ export function modalAxisToggle(state: ModalState, axis: "x" | "y" | "z"): ModalState {
85
+ if (state.mode === "scale" && axis === "z") return state;
86
+ if (state.target === "camera" && (state.mode === "scale" || axis === "z")) return state;
87
+ return { ...state, axis: state.axis === axis ? null : axis };
88
+ }
89
+
90
+ /**
91
+ * Why a claimed axis was ignored, or null when it was honoured.
92
+ *
93
+ * The three ignored cells used to be silent: `onModalKey` had already called
94
+ * `preventDefault` and `stopPropagation`, so pressing Z during a scale
95
+ * swallowed the key and changed nothing, with no way to tell that from a key
96
+ * that had not registered. A modal that eats input without saying so is the
97
+ * one place in this lane an author cannot debug by looking.
98
+ *
99
+ * Data rather than a thrown string, because the pill renders it.
100
+ */
101
+ export function axisRefusal(state: ModalState, axis: "x" | "y" | "z"): string | null {
102
+ if (state.mode === "scale" && axis === "z") {
103
+ return "SIZE HAS NO Z";
104
+ }
105
+ if (state.target === "camera" && state.mode === "scale") return "A ZOOM HAS NO AXIS";
106
+ if (state.target === "camera" && axis === "z") return "A FRAMING HAS NO Z";
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * The live delta, as the pill shows it.
112
+ *
113
+ * NUMBERS, because "SCALE · X" says which transform is running and nothing at
114
+ * all about how far it has gone — and the working pane has no ruler. Blender
115
+ * puts the same figures in its header, and it is what makes a modal usable
116
+ * without numeric entry.
117
+ *
118
+ * Formatted from the DELTA the gesture actually emitted, not re-derived from
119
+ * the travel: a readout computed a second way is a readout that can disagree
120
+ * with the layer under it.
121
+ */
122
+ export function modalReadout(delta: GestureDelta | FramingDelta | null): string {
123
+ if (delta === null) return "";
124
+ if (!("kind" in delta)) {
125
+ // A framing delta: the camera's own move or zoom.
126
+ return delta.dzoom === 1
127
+ ? `${round(delta.dx)}, ${round(delta.dy)}`
128
+ : `×${delta.dzoom.toFixed(2)}`;
129
+ }
130
+ switch (delta.kind) {
131
+ case "scale":
132
+ return `×${(delta.ratio ?? 1).toFixed(2)}`;
133
+ case "roll":
134
+ case "tilt":
135
+ return `${round(delta.dx || delta.dy)}°`;
136
+ case "depth":
137
+ return `${round(delta.dy)} z`;
138
+ default:
139
+ return `${round(delta.dx)}, ${round(delta.dy)}`;
140
+ }
141
+ }
142
+
143
+ /** One decimal at most, and no trailing `.0`: design px are integers in
144
+ * practice and `12.0` reads as more precision than the gesture has. */
145
+ function round(n: number): string {
146
+ if (!Number.isFinite(n)) return "0";
147
+ return (Math.round(n * 10) / 10).toString();
148
+ }
149
+
150
+ /** The gesture kind the scrub OPENS with. The kind may legally change per
151
+ * capture (axis toggles); this is only the first. */
152
+ export function initialGestureKind(state: ModalState): GestureKind {
153
+ switch (state.mode) {
154
+ case "move":
155
+ return "move";
156
+ case "rotate":
157
+ return "roll";
158
+ case "scale":
159
+ // The RATIO kind, not the additive `resize` one. It only decides which
160
+ // channel the scrub announces, and both write `scale`, but the kind the
161
+ // gesture actually emits and the kind it opened with disagreeing is the
162
+ // kind of drift this table exists to prevent.
163
+ return "scale";
164
+ }
165
+ }
166
+
167
+ /** The pill's text: the transform, its axis, and — for the camera — its
168
+ * subject, so the implicit target is never ambiguous. */
169
+ export function modalLabel(state: ModalState): string {
170
+ const axis = state.axis === null ? "" : ` · ${state.axis.toUpperCase()}`;
171
+ if (state.target === "camera") {
172
+ return state.mode === "scale" ? "CAMERA ZOOM" : `CAMERA MOVE${axis}`;
173
+ }
174
+ switch (state.mode) {
175
+ case "move":
176
+ return `MOVE${axis}`;
177
+ case "rotate":
178
+ return state.axis === null || state.axis === "z" ? "ROLL" : `TILT · ${state.axis.toUpperCase()}`;
179
+ case "scale":
180
+ return `SCALE${axis}`;
181
+ }
182
+ }
183
+
184
+ /** One virtual-cursor step: the raw pointer travel, scaled down while shift
185
+ * is held. Pure so the accumulation rule is a testable table row. */
186
+ export function advanceVirtual(
187
+ virtual: readonly [number, number],
188
+ from: readonly [number, number],
189
+ to: readonly [number, number],
190
+ precision: boolean,
191
+ ): [number, number] {
192
+ const k = precision ? PRECISION_FACTOR : 1;
193
+ return [virtual[0] + (to[0] - from[0]) * k, virtual[1] + (to[1] - from[1]) * k];
194
+ }
195
+
196
+ /**
197
+ * The layer mapping table — mode + axis + travel → the existing lane's delta.
198
+ *
199
+ * `travel` is the virtual cursor's total from the anchor, in pane CSS px.
200
+ * `planar` is `designDelta(anchorRay, virtualRay, framePlane)` — the flat
201
+ * "move it on the canvas" plane, matching what the letter G already means in
202
+ * this editor — or null when the mode does not need a plane (or the ray was
203
+ * edge-on, in which case a move emits nothing rather than NaN).
204
+ * `screenScale` is `workingScreenScale(...)`: CSS px per design px, dividing
205
+ * every screen-space kind exactly as the pointer lane divides by the zoom.
206
+ *
207
+ * Scale is driven by HORIZONTAL travel in every axis mode (right grows, left
208
+ * shrinks — additive design px, not Blender's pivot-distance ratio; "basic"
209
+ * scale, documented). `keepAspect` + `aboutCentre` on a corner handle IS
210
+ * uniform-about-the-centre in the resize model; the single-axis modes use the
211
+ * side handles the same model already has.
212
+ */
213
+ export function layerGestureFor(
214
+ state: ModalState,
215
+ travel: { x: number; y: number },
216
+ planar: { dx: number; dy: number; dz?: number } | null,
217
+ screenScale: number,
218
+ /** The world DESIGN point a scale grows away from or a ROLL turns about, or
219
+ * null to transform each layer in place. The stage passes the selection's
220
+ * median for a multi-select and null for one layer, which is Blender's own
221
+ * default pivot. Tilt is deliberately not on that list — see the rotate
222
+ * case. */
223
+ pivot: readonly [number, number, number] | null = null,
224
+ /**
225
+ * A TYPED value, replacing whatever the pointer had travelled.
226
+ *
227
+ * Blender's `G X 200 Enter`. It is applied HERE, in the mapping table,
228
+ * because that is the one place that knows what each mode's number MEANS:
229
+ * design px for a move, degrees for a rotate, a factor for a scale. A caller
230
+ * that substituted it upstream would have to know the same three things.
231
+ *
232
+ * A planar move is the exception it cannot be applied to without an axis —
233
+ * `designDelta` answers two components off two rays and there is no single
234
+ * scalar to replace. That is Blender's own rule too, and the caller refuses
235
+ * by name rather than guessing which component the author meant.
236
+ */
237
+ exact: number | null = null,
238
+ /**
239
+ * CTRL quantises, Blender's own modifier and the partner to shift's
240
+ * precision.
241
+ *
242
+ * Per mode, because the units differ: design px for a move, degrees for a
243
+ * rotate, a proportion for a scale. Applied to the EMITTED value rather than
244
+ * to the travel, so the layer lands on the round number the author is aiming
245
+ * at instead of on a round amount of mouse movement.
246
+ *
247
+ * It does nothing to a TYPED value: an exact number is already exact, and
248
+ * rounding what the author literally spelled would be the tool arguing.
249
+ */
250
+ snap = false,
251
+ ): GestureDelta | null {
252
+ const s = screenScale > 0 && Number.isFinite(screenScale) ? screenScale : 1;
253
+ const quantise = (n: number, step: number): number =>
254
+ snap && exact === null ? Math.round(n / step) * step : n;
255
+ switch (state.mode) {
256
+ case "move": {
257
+ if (state.axis === "z") {
258
+ // The existing depth lane's sign: drag DOWN pushes away (+z).
259
+ //
260
+ // THROUGH `quantise`, like X and Y two rows down. `G Z` was the one
261
+ // move arm that skipped it, so ctrl rounded two axes to whole design px
262
+ // and left the third fractional — a snap that half works reads as a
263
+ // snap that is broken.
264
+ return { kind: "depth", dx: 0, dy: exact ?? quantise(travel.y / s, SNAP_PX) };
265
+ }
266
+ if (exact !== null) {
267
+ // AXIS-CONSTRAINED ONLY. With no axis there is no single component to
268
+ // put the number on, and setting both would move the layer diagonally
269
+ // by a distance the author never asked for. Blender requires an axis
270
+ // here too. Refused in the TABLE as well as in the stage, so the pure
271
+ // half and the plumbing cannot disagree about what is representable.
272
+ if (state.axis === null) return null;
273
+ return {
274
+ kind: "move",
275
+ dx: state.axis === "y" ? 0 : exact,
276
+ dy: state.axis === "x" ? 0 : exact,
277
+ dz: 0,
278
+ };
279
+ }
280
+ if (planar === null) return null;
281
+ return {
282
+ kind: "move",
283
+ dx: state.axis === "y" ? 0 : quantise(planar.dx, SNAP_PX),
284
+ dy: state.axis === "x" ? 0 : quantise(planar.dy, SNAP_PX),
285
+ dz: 0,
286
+ };
287
+ }
288
+ case "rotate": {
289
+ const deg = exact ?? quantise(travel.x * TILT_DEG_PER_PX, SNAP_DEG);
290
+ // transformFor's tilt arms: tilt.x (rx) rides `dy`, tilt.y (ry) rides
291
+ // `dx`. Roll is the default AND the z constraint — the view-axis-most
292
+ // rotation this lane has.
293
+ //
294
+ // ── THE PIVOT RIDES ROLL AND NOT TILT ─────────────────────────────
295
+ //
296
+ // `R` and the rotate ring are two ways to run one operation, and only the
297
+ // ring passed a pivot — so on a multi-selection the ring orbited the
298
+ // group about its median while `R` spun every member in place. One
299
+ // gesture, two documents.
300
+ //
301
+ // TILT STAYS PIVOT-FREE: `applyGesture` only orbits a pivot in the design
302
+ // z plane (`rollDeg`'s own plane), so a tilt about a shared point is not
303
+ // something the document model can express. A single-layer roll still
304
+ // passes `null`, which is byte-identical to before.
305
+ if (state.axis === "x") return { kind: "tilt", dx: 0, dy: deg };
306
+ if (state.axis === "y") return { kind: "tilt", dx: deg, dy: 0 };
307
+ return { kind: "roll", dx: deg, dy: 0, pivot: pivot === null ? null : [...pivot] };
308
+ }
309
+ case "scale": {
310
+ // ── A RATIO, NOT A DISTANCE ───────────────────────────────────────
311
+ //
312
+ // This used to emit `{kind:"resize", dx: travel.x / screenScale}` with a
313
+ // corner handle, and that was broken in two ways at once. `dx` is a WORLD
314
+ // DESIGN vector to everything downstream, so `localizeGesture` rotated a
315
+ // screen-horizontal number into the layer's own frame: at `rollDeg 90`
316
+ // the X row mapped to `[0, -d]`, `resizeTransform` computed `dx = 0` and
317
+ // `dy = 0` because `handle.sy === 0`, `applyToTransform` returned the same
318
+ // reference, and the whole gesture silently did nothing with no refusal.
319
+ // Plain `S` on the same layer inverted instead: rightward shrank it.
320
+ //
321
+ // A ratio has no vector to rotate, so both are gone, and `axis` now names
322
+ // the layer's OWN dimension — which is what `size` is measured in.
323
+ //
324
+ // EXPONENTIAL IN THE TRAVEL, so the factor is strictly positive at every
325
+ // input and one screen inch is the same proportion whatever the layer
326
+ // already is. It is also independent of `screenScale`, which is why a
327
+ // modal scale is correct even while isolation has swapped an orthographic
328
+ // rig into the pane the scale was measured against.
329
+ //
330
+ // ponytail: Blender measures the ratio as the cursor's distance from the
331
+ // pivot over the anchor's, which we cannot do without projecting the
332
+ // pivot every frame — and which is undefined when the anchor lands on it.
333
+ // Upgrade path is a projected pivot on `ModalLive`, if anyone reports the
334
+ // feel differing.
335
+ // A TYPED scale is the factor itself: `S 2` doubles it, which is what
336
+ // the number means everywhere else in this editor and in Blender.
337
+ // Quantised to a TENTH, which is what a scale's round numbers are —
338
+ // 1.5x, 2x — and floored so ctrl can never take a layer to nothing.
339
+ //
340
+ // ── THE FLOOR IS FOR THE POINTER, NOT FOR A TYPED NUMBER ──────────
341
+ //
342
+ // `Math.max` used to wrap `exact` as well, so `S 0 Enter` and `S -1
343
+ // Enter` were silently rewritten to 0.1 and the layer shrank to a tenth
344
+ // of itself instead of being refused. `applyGesture` refuses a
345
+ // non-positive factor BY NAME — and the clamp meant that refusal could
346
+ // never fire from the one lane an author can type a number into. Returning
347
+ // null here refuses in the TABLE too, so the pure half and the plumbing
348
+ // agree about what is representable, exactly as the axis-less numeric
349
+ // move does above.
350
+ if (exact !== null && !(exact > 0)) return null;
351
+ const ratio = exact ?? Math.max(SNAP_RATIO, quantise(Math.exp(travel.x / SCALE_FALLOFF_PX), SNAP_RATIO));
352
+ const axis = state.axis === "x" || state.axis === "y" ? state.axis : "both";
353
+ // Copied out of the readonly tuple the caller holds: `GestureDelta` is
354
+ // the wire shape the store re-derives from on every capture, and handing
355
+ // it an alias of the stage's own snapshot would let one mutate the other.
356
+ return { kind: "scale", dx: 0, dy: 0, ratio, axis, pivot: pivot === null ? null : [...pivot] };
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The camera mapping — the same deltas the frustum gizmo's two grips emit.
363
+ * G is the body drag (`planar` on the z = 0 plane; an axis zeroes a
364
+ * component); S is the apex drag (exponential dzoom at the wheel's own
365
+ * falloff, so a modal zoom and a wheel notch are the same step).
366
+ */
367
+ export function framingDeltaFor(
368
+ state: ModalState,
369
+ travel: { x: number; y: number },
370
+ planar: { dx: number; dy: number } | null,
371
+ dollyFalloffPx: number,
372
+ exact: number | null = null,
373
+ ): FramingDelta | null {
374
+ if (state.mode === "scale") {
375
+ // `dzoom` is a MULTIPLIER, so a typed value maps onto it directly — and
376
+ // must be positive, or `applyFramingDelta` takes the camera's zoom to zero
377
+ // or negative and `planeDistance` answers a non-finite distance. Refused
378
+ // rather than clamped, for the reason every refusal in this lane is.
379
+ if (exact !== null) {
380
+ return exact > 0 ? { dx: 0, dy: 0, dzoom: exact } : null;
381
+ }
382
+ return { dx: 0, dy: 0, dzoom: Math.exp(-travel.y / dollyFalloffPx) };
383
+ }
384
+ if (exact !== null) {
385
+ return {
386
+ dx: state.axis === "y" ? 0 : exact,
387
+ dy: state.axis === "x" ? 0 : exact,
388
+ dzoom: 1,
389
+ };
390
+ }
391
+ if (planar === null) return null;
392
+ return {
393
+ dx: state.axis === "y" ? 0 : planar.dx,
394
+ dy: state.axis === "x" ? 0 : planar.dy,
395
+ dzoom: 1,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * One character into the numeric buffer, or `null` for a character that cannot
401
+ * extend it.
402
+ *
403
+ * `-` TOGGLES rather than appends, which is Blender's own behaviour and the
404
+ * only thing that makes it usable mid-number: it flips the sign of whatever is
405
+ * typed so far, at any point.
406
+ *
407
+ * A second `.` is rejected rather than swallowed, so `1.2.3` cannot exist.
408
+ */
409
+ export function typeNumber(buffer: string, ch: string): string | null {
410
+ if (ch === "-") return buffer.startsWith("-") ? buffer.slice(1) : `-${buffer}`;
411
+ if (ch === ".") return buffer.includes(".") ? null : `${buffer}.`;
412
+ if (ch >= "0" && ch <= "9") return `${buffer}${ch}`;
413
+ return null;
414
+ }
415
+
416
+ /**
417
+ * What the buffer means as a number, or null while it means nothing yet.
418
+ *
419
+ * `""`, `"-"` and `"."` are all mid-typing states, not zeros: treating a bare
420
+ * minus as `-0` would snap the layer to the origin the instant the author
421
+ * pressed it, before they had typed the value they were negating.
422
+ */
423
+ export function typedValue(buffer: string): number | null {
424
+ if (buffer === "" || buffer === "-" || buffer === "." || buffer === "-.") return null;
425
+ const n = Number(buffer);
426
+ return Number.isFinite(n) ? n : null;
427
+ }