@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- 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
|
+
}
|