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