@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,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
|
+
}
|