@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,411 @@
|
|
|
1
|
+
// Three drag arms in the working pane, on whatever the author is transforming.
|
|
2
|
+
//
|
|
3
|
+
// ── ONE GIZMO, TWO SUBJECTS, AND THE FORK IS `modalTarget` ──────────────
|
|
4
|
+
//
|
|
5
|
+
// With a selection it hangs on the selection's centre and moves layers. With
|
|
6
|
+
// NOTHING selected it hangs on the shot camera's apex and moves the camera —
|
|
7
|
+
// which is not a second feature bolted on, it is the SAME rule `G` and `S`
|
|
8
|
+
// already follow (modalState.ts: "layers when the selection is non-empty, else
|
|
9
|
+
// the CAMERA's framing"). Importing `modalTarget` rather than restating it is
|
|
10
|
+
// what keeps the drawn gizmo and the keyboard from ever disagreeing about whose
|
|
11
|
+
// transform the author is about to change.
|
|
12
|
+
//
|
|
13
|
+
// The camera's arms mean something slightly different, and `framingArmDelta`
|
|
14
|
+
// owns that: it has no free z, so its Z arm is the DOLLY. See that function.
|
|
15
|
+
//
|
|
16
|
+
// ── WHAT THIS ADDS THAT THE PANE DID NOT HAVE ───────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// Dragging a layer's BODY in this pane unprojects onto a plane, so it can move
|
|
19
|
+
// the layer in two axes at once but never along z. Everything else was on the
|
|
20
|
+
// keyboard: G-X, G-Y, G-Z. This is the pointer's way to say the same three
|
|
21
|
+
// things, which is the one an author reaching for "place it over there" tries
|
|
22
|
+
// first. The camera had even less: a body drag that pans and an apex drag that
|
|
23
|
+
// zooms, neither of which can be constrained to an axis.
|
|
24
|
+
//
|
|
25
|
+
// ROTATE AND SCALE STAY ON R AND S. They already exist, with axis constraints
|
|
26
|
+
// and a live pill (modalState.ts); a second set of rings and boxes would be a
|
|
27
|
+
// duplicate of a lane that works, and every one of them would need its own
|
|
28
|
+
// screen-space arc maths. This gizmo is TRANSLATE ONLY, on purpose.
|
|
29
|
+
//
|
|
30
|
+
// ── WHY THE DRAG SOLVES A LINE AND NOT A PLANE ──────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// An arm the author has grabbed must stay under the cursor, and only the
|
|
33
|
+
// closest-approach solve between the axis line and the pointer ray does that
|
|
34
|
+
// (`axisParam`). Unprojecting onto a plane and zeroing two components — what
|
|
35
|
+
// the modal lane does, because it has no drawn arm to stay under — drifts away
|
|
36
|
+
// from the pointer as the view turns and runs away entirely at a grazing angle.
|
|
37
|
+
//
|
|
38
|
+
// ── EDITOR-ONLY, THE SAME THREE WAYS THE FRUSTUM IS ─────────────────────
|
|
39
|
+
//
|
|
40
|
+
// `layers.set(EDITOR_LAYER)`, SplitRenderer's post pass pinned to bit 0, and no
|
|
41
|
+
// `SHOT_LAYER_KEY` userData. It also writes no document: the DRAG does, through
|
|
42
|
+
// the same `onGestureStart`/`onGesture`/`onGestureEnd` scrub every other
|
|
43
|
+
// transform in this file opens.
|
|
44
|
+
import { useEffect, useMemo } from "react";
|
|
45
|
+
import { useFrame } from "@react-three/fiber";
|
|
46
|
+
import { BufferAttribute, BufferGeometry, Color, LineSegments, Vector3 } from "three";
|
|
47
|
+
import type { Camera, OrthographicCamera, PerspectiveCamera } from "three";
|
|
48
|
+
import { LineBasicNodeMaterial } from "three/webgpu";
|
|
49
|
+
import { EDITOR_LAYER } from "./StageTools.js";
|
|
50
|
+
import { DESIGN_AXES, armGrabbable } from "./gizmoMath.js";
|
|
51
|
+
import type { DesignAxis, Vec3 } from "./gizmoMath.js";
|
|
52
|
+
// `GizmoGrab` lives in gizmoMath.ts with the rest of the axis vocabulary — the
|
|
53
|
+
// pointer lane carries one and must not import a three-side module for a type.
|
|
54
|
+
import { APEX_GRAB_PX } from "./FrustumGizmo.js";
|
|
55
|
+
import { borderDistance, segmentsMoved } from "./frustumGeometry.js";
|
|
56
|
+
import { modalTarget } from "./modalState.js";
|
|
57
|
+
import { toScreen } from "./project.js";
|
|
58
|
+
import { tokenColor } from "./tokenColor.js";
|
|
59
|
+
import type { StageFeed } from "./feed.js";
|
|
60
|
+
import type { PaneRect } from "./panes.js";
|
|
61
|
+
|
|
62
|
+
/** Over everything, and over the frustum too: an arm that a camera gizmo could
|
|
63
|
+
* hide is an arm the author cannot grab. */
|
|
64
|
+
const GIZMO_RENDER_ORDER = 2_000_000;
|
|
65
|
+
|
|
66
|
+
/** Arm tip distance in CSS px, held CONSTANT as the author dollies. A
|
|
67
|
+
* world-space length would be a gizmo that fills the pane up close and
|
|
68
|
+
* disappears far out — and, because the hit slop below is in screen px, one
|
|
69
|
+
* whose grab target changed size with the zoom. */
|
|
70
|
+
const ARM_PX = 90;
|
|
71
|
+
|
|
72
|
+
/** How close a press has to land to an arm, in CSS px. The frustum's own 14 —
|
|
73
|
+
* a line is a line, and the author's finger is the same size in both. */
|
|
74
|
+
const GRAB_PX = 14;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where an arm STARTS, in CSS px from the anchor.
|
|
78
|
+
*
|
|
79
|
+
* It has to clear the anchor by more than BOTH slops added together, and that
|
|
80
|
+
* is not cosmetic. On the camera the anchor IS the frustum's apex, whose own
|
|
81
|
+
* grab is a 14px screen target (FrustumGizmo's APEX_GRAB_PX) tested AFTER this
|
|
82
|
+
* one in the pointer lane. At any start below `APEX_GRAB_PX + GRAB_PX` a press
|
|
83
|
+
* aimed at the apex lands within `GRAB_PX` of an arm as well, the arms win, and
|
|
84
|
+
* the apex dolly becomes unreachable — silently, because an arm drag looks like
|
|
85
|
+
* a working gesture.
|
|
86
|
+
*
|
|
87
|
+
* 30 > 14 + 14. It also keeps the three roots from piling into one ambiguous
|
|
88
|
+
* blob, which is why the layer gizmo wants it too.
|
|
89
|
+
*/
|
|
90
|
+
const ARM_START_PX = 30;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How far an arm's ROOT must land from the anchor ON SCREEN to be grabbable.
|
|
94
|
+
*
|
|
95
|
+
* ── WHY `ARM_START_PX` IS NOT ENOUGH ────────────────────────────────────
|
|
96
|
+
*
|
|
97
|
+
* The note above asserts an inequality in SCREEN px ("30 > 14 + 14"), but the
|
|
98
|
+
* value is converted to WORLD units at the anchor's depth and then projected.
|
|
99
|
+
* So the distance the author actually sees is `ARM_START_PX * sin(angle to the
|
|
100
|
+
* view direction)`, and an axis pointing near the camera collapses:
|
|
101
|
+
*
|
|
102
|
+
* At Numpad7 the polar angle clamps to 5 degrees, so the design y arm
|
|
103
|
+
* projects to about 2.6 px and every press near the selection centre grabs
|
|
104
|
+
* it. A 5 px twitch then throws the layer.
|
|
105
|
+
*
|
|
106
|
+
* Under ISOLATION'S ORTHOGRAPHIC RIG there is no vanishing point at all: the
|
|
107
|
+
* design z axis points exactly at the camera, so the z arm projects to a
|
|
108
|
+
* mathematical POINT on the anchor. `borderDistance` to a degenerate segment
|
|
109
|
+
* is the distance to that point, so every press within `GRAB_PX` of the
|
|
110
|
+
* selection centre grabs it — and then `axisParam` divides by a zero
|
|
111
|
+
* denominator, answers null, and `runGizmoGesture` returns before opening a
|
|
112
|
+
* scrub. The press is completely dead, under a blue line that looks grabbable.
|
|
113
|
+
*
|
|
114
|
+
* So the test is done where it is true: after projection, in screen px. An arm
|
|
115
|
+
* that fails it is still DRAWN — it is a real axis and hiding it would be
|
|
116
|
+
* lying about the geometry — but it does not answer presses, so they fall
|
|
117
|
+
* through to the pick lane underneath.
|
|
118
|
+
*
|
|
119
|
+
* ── AND IT MEASURES THE DRAWN LENGTH, NOT THE ROOT OFFSET ───────────────
|
|
120
|
+
*
|
|
121
|
+
* The first version of this gate compared the ROOT's distance from the anchor,
|
|
122
|
+
* on the reasoning that the tip sits three times further out so the root test
|
|
123
|
+
* implies the length test. That is backwards: the root offset is at most
|
|
124
|
+
* `ARM_START_PX = 30` px and the threshold is 28, so at the SHIPPED DEFAULT
|
|
125
|
+
* view (theta 35, phi 65) all three arms measured 20 to 27 px, every one of
|
|
126
|
+
* them failed, `ends` came back empty and the gizmo was inert everywhere. The
|
|
127
|
+
* quantity that decides whether a segment can be grabbed is the segment's own
|
|
128
|
+
* length, so that is what is measured.
|
|
129
|
+
*
|
|
130
|
+
* The APEX CLEARANCE is a separate question and only applies to the camera
|
|
131
|
+
* subject, where the anchor IS the frustum's apex — see `ARM_START_PX`.
|
|
132
|
+
*/
|
|
133
|
+
const MIN_ARM_SCREEN_PX = GRAB_PX * 2;
|
|
134
|
+
|
|
135
|
+
const AXIS_FALLBACK: Readonly<Record<DesignAxis, string>> = {
|
|
136
|
+
x: "#ef4444",
|
|
137
|
+
y: "#22c55e",
|
|
138
|
+
z: "#3b82f6",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** How far a hovered arm blends towards white. Enough to read as hot at a
|
|
142
|
+
* glance, little enough that red / green / blue still name the axis. */
|
|
143
|
+
const HOVER_TINT = 0.45;
|
|
144
|
+
const WHITE = new Color(1, 1, 1);
|
|
145
|
+
|
|
146
|
+
const DEG = Math.PI / 180;
|
|
147
|
+
const _origin = new Vector3();
|
|
148
|
+
const _root = new Vector3();
|
|
149
|
+
const _tip = new Vector3();
|
|
150
|
+
const _view = new Vector3();
|
|
151
|
+
/** One arm's six position floats, hoisted: the hot path allocates nothing. */
|
|
152
|
+
const _next = new Float32Array(6);
|
|
153
|
+
|
|
154
|
+
/** Is a world point behind the camera's own plane? View-space z runs NEGATIVE
|
|
155
|
+
* in front, so anything at or past zero has no honest projection. */
|
|
156
|
+
function behind(point: Vector3, camera: Camera): boolean {
|
|
157
|
+
return _view.copy(point).applyMatrix4(camera.matrixWorldInverse).z >= 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface MoveGizmoProps {
|
|
161
|
+
feed: StageFeed;
|
|
162
|
+
panes: { working: PaneRect | null; camera: PaneRect | null };
|
|
163
|
+
selection: readonly string[];
|
|
164
|
+
/** The shot camera's own position at this frame, or null when no scene is on
|
|
165
|
+
* screen. Passed in rather than read off the feed: `FrustumGizmo` writes its
|
|
166
|
+
* own copy at priority 0 too, and two priority-0 subscribers have no defined
|
|
167
|
+
* order — so this comes from `rig`, which `StageApp` already memoizes. */
|
|
168
|
+
cameraApex: Vec3 | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function MoveGizmo({ feed, panes, selection, cameraApex }: MoveGizmoProps) {
|
|
172
|
+
// One `LineSegments` per axis rather than one buffer with vertex colours:
|
|
173
|
+
// three objects and three materials is the whole cost, and vertex colours on
|
|
174
|
+
// a node material would be a second thing to keep working across the WebGPU
|
|
175
|
+
// and WebGL2 backends for no gain at three vertices apiece.
|
|
176
|
+
const arms = useMemo(
|
|
177
|
+
() =>
|
|
178
|
+
DESIGN_AXES.map(({ axis }) => {
|
|
179
|
+
const geometry = new BufferGeometry();
|
|
180
|
+
geometry.setAttribute("position", new BufferAttribute(new Float32Array(6), 3));
|
|
181
|
+
const material = new LineBasicNodeMaterial();
|
|
182
|
+
material.color.copy(tokenColor(`--axis-${axis}`, AXIS_FALLBACK[axis]));
|
|
183
|
+
material.depthTest = false;
|
|
184
|
+
material.depthWrite = false;
|
|
185
|
+
material.toneMapped = false;
|
|
186
|
+
// The working pane renders under a clay override in solid/wireframe
|
|
187
|
+
// mode, and a grey gizmo is the one thing this must not be.
|
|
188
|
+
material.allowOverride = false;
|
|
189
|
+
const lines = new LineSegments(geometry, material);
|
|
190
|
+
lines.layers.set(EDITOR_LAYER);
|
|
191
|
+
lines.renderOrder = GIZMO_RENDER_ORDER;
|
|
192
|
+
// World positions rewritten in place — same reason the frustum turns
|
|
193
|
+
// culling off: the bounding sphere is never the one the buffer holds.
|
|
194
|
+
lines.frustumCulled = false;
|
|
195
|
+
lines.matrixAutoUpdate = false;
|
|
196
|
+
// How far towards white the hover has taken this arm, so the frame loop
|
|
197
|
+
// can skip the colour write on every frame that changed nothing.
|
|
198
|
+
return { axis, lines, geometry, material, tint: 0 };
|
|
199
|
+
}),
|
|
200
|
+
[],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
useEffect(
|
|
204
|
+
() => () => {
|
|
205
|
+
for (const arm of arms) {
|
|
206
|
+
arm.geometry.dispose();
|
|
207
|
+
arm.material.dispose();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
[arms],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
useFrame(() => {
|
|
214
|
+
const working = panes.working;
|
|
215
|
+
const camera = feed.workingCamera;
|
|
216
|
+
// `modalTarget` and not `selection.length` written out again: this is the
|
|
217
|
+
// same fork `G` takes, and one of them drifting is a gizmo that moves
|
|
218
|
+
// something other than what the pill says it will.
|
|
219
|
+
//
|
|
220
|
+
// A selection whose layers are all off-frame at this playhead anchors
|
|
221
|
+
// NOWHERE rather than falling through to the camera — silently retargeting
|
|
222
|
+
// a live selection's gizmo at the camera is the worst answer available.
|
|
223
|
+
const subject = modalTarget(selection.length > 0);
|
|
224
|
+
const centre = subject === "camera" ? cameraApex : selectionCentre(feed, selection);
|
|
225
|
+
if (working === null || camera === null || centre === null) {
|
|
226
|
+
for (const arm of arms) arm.lines.visible = false;
|
|
227
|
+
feed.gizmoHit = null;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
_origin.set(centre[0], centre[1], centre[2]);
|
|
232
|
+
const perPixel = worldPerPixel(camera, _origin, working.height);
|
|
233
|
+
const start = ARM_START_PX * perPixel;
|
|
234
|
+
const length = ARM_PX * perPixel;
|
|
235
|
+
// The anchor ON SCREEN, which is what every arm's own screen length is
|
|
236
|
+
// measured against below. An anchor behind the camera projects to garbage,
|
|
237
|
+
// so nothing is grabbable at all — the same refusal each arm makes.
|
|
238
|
+
const anchorVisible = !behind(_origin, camera);
|
|
239
|
+
const anchor = toScreen(_origin, camera, working.width, working.height);
|
|
240
|
+
// Screen ends, kept beside the world ones so the hit test measures the
|
|
241
|
+
// segment that was actually drawn.
|
|
242
|
+
const ends: {
|
|
243
|
+
axis: DesignAxis;
|
|
244
|
+
world: Vec3;
|
|
245
|
+
a: [number, number];
|
|
246
|
+
b: [number, number];
|
|
247
|
+
}[] = [];
|
|
248
|
+
|
|
249
|
+
for (const [i, { world }] of DESIGN_AXES.entries()) {
|
|
250
|
+
const arm = arms[i]!;
|
|
251
|
+
arm.lines.visible = true;
|
|
252
|
+
const from: Vec3 = [
|
|
253
|
+
centre[0] + world[0] * start,
|
|
254
|
+
centre[1] + world[1] * start,
|
|
255
|
+
centre[2] + world[2] * start,
|
|
256
|
+
];
|
|
257
|
+
const to: Vec3 = [
|
|
258
|
+
centre[0] + world[0] * length,
|
|
259
|
+
centre[1] + world[1] * length,
|
|
260
|
+
centre[2] + world[2] * length,
|
|
261
|
+
];
|
|
262
|
+
const attribute = arm.geometry.getAttribute("position") as BufferAttribute;
|
|
263
|
+
const buffer = attribute.array as Float32Array;
|
|
264
|
+
_next[0] = from[0];
|
|
265
|
+
_next[1] = from[1];
|
|
266
|
+
_next[2] = from[2];
|
|
267
|
+
_next[3] = to[0];
|
|
268
|
+
_next[4] = to[1];
|
|
269
|
+
_next[5] = to[2];
|
|
270
|
+
// The frustum's own half-pixel gate, for its reason: this runs on every
|
|
271
|
+
// committed frame, and a parked playhead with a selection must not
|
|
272
|
+
// re-upload three buffers per seek.
|
|
273
|
+
if (segmentsMoved(buffer, _next)) {
|
|
274
|
+
buffer.set(_next);
|
|
275
|
+
attribute.needsUpdate = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// An arm with EITHER end behind the working camera projects to garbage,
|
|
279
|
+
// so it is drawn but not grabbable — the same refusal the frustum's body
|
|
280
|
+
// makes, and for the same reason: there is no screen segment to measure
|
|
281
|
+
// against.
|
|
282
|
+
_root.set(from[0], from[1], from[2]);
|
|
283
|
+
_tip.set(to[0], to[1], to[2]);
|
|
284
|
+
if (!anchorVisible || behind(_root, camera) || behind(_tip, camera)) continue;
|
|
285
|
+
const a = toScreen(_root, camera, working.width, working.height);
|
|
286
|
+
const b = toScreen(_tip, camera, working.width, working.height);
|
|
287
|
+
// AN ARM THAT COLLAPSED ON SCREEN IS DRAWN BUT NOT GRABBABLE, and on the
|
|
288
|
+
// camera it must also clear the apex. Both live in `armGrabbable`, which
|
|
289
|
+
// is pure so a gate that decides whether the whole widget works can be
|
|
290
|
+
// checked without a GL context.
|
|
291
|
+
if (!armGrabbable(a, b, anchor, subject, MIN_ARM_SCREEN_PX, APEX_GRAB_PX + GRAB_PX)) continue;
|
|
292
|
+
ends.push({ axis: arm.axis, world, a, b });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
feed.gizmoHit = (x, y) => {
|
|
296
|
+
let best: (typeof ends)[number] | null = null;
|
|
297
|
+
let bestDistance = GRAB_PX;
|
|
298
|
+
for (const end of ends) {
|
|
299
|
+
// `borderDistance` walks a CLOSED polygon, so a two-point list is that
|
|
300
|
+
// one segment measured twice — the point-to-segment distance, out of
|
|
301
|
+
// the module that already owns it.
|
|
302
|
+
const distance = borderDistance(x, y, [end.a, end.b]);
|
|
303
|
+
if (distance <= bestDistance) {
|
|
304
|
+
best = end;
|
|
305
|
+
bestDistance = distance;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return best === null
|
|
309
|
+
? null
|
|
310
|
+
: { subject, axis: best.axis, origin: centre, direction: best.world };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// ── THE HOVERED ARM LIGHTS UP ─────────────────────────────────────────
|
|
314
|
+
//
|
|
315
|
+
// Blender highlights the gizmo element under the cursor, and this pane did
|
|
316
|
+
// not — so "is this grabbable" was something the author learned by trying.
|
|
317
|
+
// Blended TOWARDS WHITE rather than swapped for it, so the axis is still
|
|
318
|
+
// identifiable by colour while it is hot.
|
|
319
|
+
//
|
|
320
|
+
// Here, in the frame loop, off a plain field the pointer lane wrote. A
|
|
321
|
+
// hover therefore costs one uniform write per frame and zero React renders,
|
|
322
|
+
// which is the whole reason `feed.hoverArm` is a field and not state.
|
|
323
|
+
for (const arm of arms) {
|
|
324
|
+
const hot = feed.hoverArm === arm.axis && ends.some((e) => e.axis === arm.axis);
|
|
325
|
+
const want = hot ? HOVER_TINT : 0;
|
|
326
|
+
if (arm.tint === want) continue;
|
|
327
|
+
arm.tint = want;
|
|
328
|
+
arm.material.color.copy(tokenColor(`--axis-${arm.axis}`, AXIS_FALLBACK[arm.axis])).lerp(WHITE, want);
|
|
329
|
+
}
|
|
330
|
+
}, 0);
|
|
331
|
+
|
|
332
|
+
useEffect(
|
|
333
|
+
() => () => {
|
|
334
|
+
feed.gizmoHit = null;
|
|
335
|
+
},
|
|
336
|
+
[feed],
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<>
|
|
341
|
+
{arms.map((arm) => (
|
|
342
|
+
<primitive key={arm.axis} object={arm.lines} />
|
|
343
|
+
))}
|
|
344
|
+
</>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Where the arms sit: the mean of the selected layers' own origins.
|
|
350
|
+
*
|
|
351
|
+
* The GROUP origin out of `feed.frames`, never the mesh's centre — a paint
|
|
352
|
+
* layer's plane is grown by the rasterizer's shadow padding, so the mesh would
|
|
353
|
+
* put the gizmo a blur radius away from the thing it moves (the same reason
|
|
354
|
+
* `collectFrames` reads userData).
|
|
355
|
+
*
|
|
356
|
+
* Null when nothing selected is on screen this frame, which includes a
|
|
357
|
+
* selection whose lifetime has ended: an arm drawn for a layer that is not
|
|
358
|
+
* there would drag something the author cannot see.
|
|
359
|
+
*/
|
|
360
|
+
function selectionCentre(feed: StageFeed, selection: readonly string[]): Vec3 | null {
|
|
361
|
+
const frames = feed.frames;
|
|
362
|
+
if (frames === null || selection.length === 0) return null;
|
|
363
|
+
let x = 0;
|
|
364
|
+
let y = 0;
|
|
365
|
+
let z = 0;
|
|
366
|
+
let count = 0;
|
|
367
|
+
// ONE FRAME PER ID, the first — the same rule `StageTools`' `byId` follows.
|
|
368
|
+
// A cloner stamps its children N times under one id, and averaging all the
|
|
369
|
+
// clones put the gizmo in the empty space between them rather than on the
|
|
370
|
+
// layer the author selected.
|
|
371
|
+
const seen = new Set<string>();
|
|
372
|
+
for (const frame of frames) {
|
|
373
|
+
if (!selection.includes(frame.id) || seen.has(frame.id)) continue;
|
|
374
|
+
seen.add(frame.id);
|
|
375
|
+
const e = frame.matrixWorld.elements;
|
|
376
|
+
x += e[12]!;
|
|
377
|
+
y += e[13]!;
|
|
378
|
+
z += e[14]!;
|
|
379
|
+
count += 1;
|
|
380
|
+
}
|
|
381
|
+
return count === 0 ? null : [x / count, y / count, z / count];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* World units per CSS pixel AT A POINT, which is what turns the arm's fixed
|
|
386
|
+
* screen length into a world one.
|
|
387
|
+
*
|
|
388
|
+
* Both camera classes, because isolation swaps an ORTHOGRAPHIC camera into this
|
|
389
|
+
* pane's slot (StageTools) and a perspective-only formula would size the gizmo
|
|
390
|
+
* off a `fov` that camera does not have — reading `undefined` and drawing arms
|
|
391
|
+
* of length NaN, which is a gizmo that silently disappears inside isolation.
|
|
392
|
+
*
|
|
393
|
+
* Perspective: the visible height at the point's own view depth, over the pane.
|
|
394
|
+
* Orthographic: its frustum height over the pane, which has no depth term at
|
|
395
|
+
* all — that is what orthographic means.
|
|
396
|
+
*/
|
|
397
|
+
function worldPerPixel(camera: Camera, at: Vector3, paneHeight: number): number {
|
|
398
|
+
const height = Math.max(1, paneHeight);
|
|
399
|
+
const ortho = camera as OrthographicCamera;
|
|
400
|
+
if (ortho.isOrthographicCamera === true) {
|
|
401
|
+
const zoom = ortho.zoom > 0 ? ortho.zoom : 1;
|
|
402
|
+
return Math.abs(ortho.top - ortho.bottom) / zoom / height;
|
|
403
|
+
}
|
|
404
|
+
const perspective = camera as PerspectiveCamera;
|
|
405
|
+
const fov = Number.isFinite(perspective.fov) ? perspective.fov : 50;
|
|
406
|
+
// View-space z runs NEGATIVE in front of the camera, so the depth is its
|
|
407
|
+
// negation — and it is floored off zero, because a selection sitting exactly
|
|
408
|
+
// at the eye would otherwise give every arm zero length.
|
|
409
|
+
const depth = Math.max(-_view.copy(at).applyMatrix4(camera.matrixWorldInverse).z, 1e-3);
|
|
410
|
+
return (2 * Math.tan((fov * DEG) / 2) * depth) / height;
|
|
411
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The stage's perf readout, behind `?perf=1` on the stage URL.
|
|
2
|
+
//
|
|
3
|
+
// Exists because every performance judgement about this lane used to be made
|
|
4
|
+
// by reading code: there was no way to SEE where a frame went. The HUD prints
|
|
5
|
+
// the five numbers that decide whether the stage is healthy, sampled on a
|
|
6
|
+
// half-second interval so the HUD itself costs nothing worth measuring:
|
|
7
|
+
//
|
|
8
|
+
// gl/s GL frames drawn per second. Under `frameloop="never"` a draw IS
|
|
9
|
+
// a React commit, so this doubling the seek rate is the symptom of
|
|
10
|
+
// a double-commit bug (the overlay bump was one).
|
|
11
|
+
// ui ms EMA of the gap between rAF ticks — main-thread health. 16.7 is
|
|
12
|
+
// a clean 60Hz; spikes mean something synchronous is eating the
|
|
13
|
+
// thread (a raster storm, a full-dock reconcile).
|
|
14
|
+
// draws draw calls in the last GL frame, off `gl.info` (see PerfProbe
|
|
15
|
+
// for the drawCalls-vs-calls renderer-class split).
|
|
16
|
+
// raster the module LRU: live MB (refcounted entries included — the
|
|
17
|
+
// honest live set), entry count, and hits/misses over the last
|
|
18
|
+
// interval. Misses during steady playback are a regression; see
|
|
19
|
+
// rasterCache.test.ts.
|
|
20
|
+
// layers how many layers the open document's current scene holds.
|
|
21
|
+
//
|
|
22
|
+
// Two halves, same file: `PerfProbe` runs INSIDE the canvas (priority 2, after
|
|
23
|
+
// the presenters, so its info read sees the finished frame) and writes into a
|
|
24
|
+
// mutable box; `PerfHud` is DOM, polling the box and `rasterCacheStats()` on
|
|
25
|
+
// an interval. The box is the same feed pattern the picking lane uses — the
|
|
26
|
+
// probe must never cause a render, or the HUD would bend the number it shows.
|
|
27
|
+
import { useEffect, useRef, useState } from "react";
|
|
28
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
29
|
+
import { rasterCacheStats } from "@dailies-studio/shot";
|
|
30
|
+
|
|
31
|
+
/** Read once: the stage URL is set at iframe mount and never rewritten. */
|
|
32
|
+
export const PERF_HUD_ENABLED =
|
|
33
|
+
typeof window !== "undefined" && new URLSearchParams(window.location.search).get("perf") === "1";
|
|
34
|
+
|
|
35
|
+
export interface PerfBox {
|
|
36
|
+
glFrames: number;
|
|
37
|
+
drawCalls: number;
|
|
38
|
+
/** Active GL programs, off `info.memory.programs`. A number that RISES on a
|
|
39
|
+
* layout switch is the compile-storm symptom the warm-up exists to kill;
|
|
40
|
+
* after warm-up it should be flat across every mode flip. */
|
|
41
|
+
programs: number;
|
|
42
|
+
/**
|
|
43
|
+
* Triangles the renderer submitted, off `info.render.triangles`.
|
|
44
|
+
*
|
|
45
|
+
* LAST BIN ONLY when a scene has more than one. `renderBins` calls
|
|
46
|
+
* `gl.render` once per bin and the info resets per render, so this reports
|
|
47
|
+
* the final pass rather than the frame — exactly the caveat `drawCalls`
|
|
48
|
+
* beside it already carries, and for exactly the same reason. The outliner's
|
|
49
|
+
* per-layer badges are the number to trust for authoring decisions; this one
|
|
50
|
+
* is for watching a pass get heavier while you drag.
|
|
51
|
+
*/
|
|
52
|
+
triangles: number;
|
|
53
|
+
/** Wall-time spans from a `markPerf()` call to the NEXT presented frame,
|
|
54
|
+
* keyed by the mark's name. The probe settles them; the HUD prints them. */
|
|
55
|
+
spans: Record<string, number>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createPerfBox(): PerfBox {
|
|
59
|
+
return { glFrames: 0, drawCalls: 0, programs: 0, triangles: 0, spans: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Marks pending until the next presented GL frame.
|
|
64
|
+
*
|
|
65
|
+
* Module scope, not the box: the callers (StageApp's doc/layout effects) run
|
|
66
|
+
* in React's commit and the settle runs inside `advance()`, and threading the
|
|
67
|
+
* box through both for two timestamps would put a perf-only field on the
|
|
68
|
+
* component contract. Cleared by the probe the moment they are measured.
|
|
69
|
+
*/
|
|
70
|
+
const pendingMarks = new Map<string, number>();
|
|
71
|
+
|
|
72
|
+
/** Record "X happened; measure to the next presented frame". No-op unless the
|
|
73
|
+
* HUD is on, so callers do not need their own gate. */
|
|
74
|
+
export function markPerf(name: string): void {
|
|
75
|
+
if (!PERF_HUD_ENABLED) return;
|
|
76
|
+
pendingMarks.set(name, performance.now());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** In-canvas half: counts GL frames and copies the renderer's draw-call count.
|
|
80
|
+
*
|
|
81
|
+
* THE PRIORITY IS CONDITIONAL AND THAT IS LOAD-BEARING. Any useFrame with
|
|
82
|
+
* priority > 0 makes fiber skip its own render (`!state.internal.priority`
|
|
83
|
+
* gates the default draw), on the theory that a positive-priority subscriber
|
|
84
|
+
* IS the renderer. In the camera-only layout nothing else takes over the
|
|
85
|
+
* present, so a probe at priority 2 there would be the only "renderer" on
|
|
86
|
+
* the canvas — and it draws nothing: a blank stage, only under ?perf=1, only
|
|
87
|
+
* in the default layout. So: priority 2 only when a presenter is mounted
|
|
88
|
+
* (SplitRenderer or ScenePresenter, both priority 1) so the probe reads the
|
|
89
|
+
* FINISHED frame; priority 0 otherwise, where `drawCalls` is one frame stale
|
|
90
|
+
* — fine for a 500ms-sampled HUD, and gl/s stays exact. */
|
|
91
|
+
export function PerfProbe({ box, presenterMounted }: { box: PerfBox; presenterMounted: boolean }): null {
|
|
92
|
+
const gl = useThree((s) => s.gl);
|
|
93
|
+
useFrame(() => {
|
|
94
|
+
box.glFrames += 1;
|
|
95
|
+
const info = (gl as {
|
|
96
|
+
info?: {
|
|
97
|
+
render?: { calls?: number; drawCalls?: number; triangles?: number };
|
|
98
|
+
memory?: { programs?: number };
|
|
99
|
+
};
|
|
100
|
+
}).info;
|
|
101
|
+
// The common WebGPURenderer Info spells the per-frame count `drawCalls`
|
|
102
|
+
// (its `calls` is a LIFETIME total); a classic WebGLRenderer has no
|
|
103
|
+
// `drawCalls` and its `calls` is per-frame. Hence this fallback order —
|
|
104
|
+
// the split is renderer class, not backend.
|
|
105
|
+
box.drawCalls = info?.render?.drawCalls ?? info?.render?.calls ?? 0;
|
|
106
|
+
box.programs = info?.memory?.programs ?? 0;
|
|
107
|
+
// Spelled the same on both renderer classes, so no fallback is needed here.
|
|
108
|
+
box.triangles = info?.render?.triangles ?? 0;
|
|
109
|
+
// Settle pending marks: this frame is the first one PRESENTED after the
|
|
110
|
+
// mark, which is the moment the author saw the result.
|
|
111
|
+
if (pendingMarks.size > 0) {
|
|
112
|
+
const now = performance.now();
|
|
113
|
+
for (const [name, at] of pendingMarks) box.spans[name] = now - at;
|
|
114
|
+
pendingMarks.clear();
|
|
115
|
+
}
|
|
116
|
+
}, presenterMounted ? 2 : 0);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SAMPLE_MS = 500;
|
|
121
|
+
|
|
122
|
+
/** DOM half: the readout. `layers` is passed in because only the caller knows
|
|
123
|
+
* which scene is current; everything else is sampled here. */
|
|
124
|
+
export function PerfHud({ box, layers }: { box: PerfBox; layers: number }) {
|
|
125
|
+
const [text, setText] = useState("perf…");
|
|
126
|
+
// rAF gap EMA, sampled continuously but folded into state only per interval.
|
|
127
|
+
const uiMs = useRef(16.7);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
let raf = 0;
|
|
130
|
+
let last = performance.now();
|
|
131
|
+
const tick = (): void => {
|
|
132
|
+
const now = performance.now();
|
|
133
|
+
uiMs.current = uiMs.current * 0.9 + (now - last) * 0.1;
|
|
134
|
+
last = now;
|
|
135
|
+
raf = requestAnimationFrame(tick);
|
|
136
|
+
};
|
|
137
|
+
raf = requestAnimationFrame(tick);
|
|
138
|
+
return () => cancelAnimationFrame(raf);
|
|
139
|
+
}, []);
|
|
140
|
+
// Long tasks since the last sample: count and the worst one. This is the
|
|
141
|
+
// number that catches a synchronous compile or raster storm even when the
|
|
142
|
+
// rAF-gap EMA has already recovered by sample time.
|
|
143
|
+
const longTasks = useRef({ count: 0, maxMs: 0 });
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
146
|
+
const observer = new PerformanceObserver((list) => {
|
|
147
|
+
for (const entry of list.getEntries()) {
|
|
148
|
+
longTasks.current.count += 1;
|
|
149
|
+
longTasks.current.maxMs = Math.max(longTasks.current.maxMs, entry.duration);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
observer.observe({ entryTypes: ["longtask"] });
|
|
154
|
+
} catch {
|
|
155
|
+
return; // the entry type is Chromium-only; the HUD just drops the field
|
|
156
|
+
}
|
|
157
|
+
return () => observer.disconnect();
|
|
158
|
+
}, []);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
let lastFrames = box.glFrames;
|
|
161
|
+
let lastHits = rasterCacheStats().hits;
|
|
162
|
+
let lastMisses = rasterCacheStats().misses;
|
|
163
|
+
const timer = setInterval(() => {
|
|
164
|
+
const stats = rasterCacheStats();
|
|
165
|
+
const gps = ((box.glFrames - lastFrames) * 1000) / SAMPLE_MS;
|
|
166
|
+
const hits = stats.hits - lastHits;
|
|
167
|
+
const misses = stats.misses - lastMisses;
|
|
168
|
+
lastFrames = box.glFrames;
|
|
169
|
+
lastHits = stats.hits;
|
|
170
|
+
lastMisses = stats.misses;
|
|
171
|
+
const lt = longTasks.current;
|
|
172
|
+
const ltText = lt.count > 0 ? ` · lt ${lt.count}/${lt.maxMs.toFixed(0)}ms` : "";
|
|
173
|
+
longTasks.current = { count: 0, maxMs: 0 };
|
|
174
|
+
const spans = Object.entries(box.spans)
|
|
175
|
+
.map(([name, ms]) => ` · ${name} ${ms.toFixed(0)}ms`)
|
|
176
|
+
.join("");
|
|
177
|
+
setText(
|
|
178
|
+
`gl/s ${gps.toFixed(0)} · ui ${uiMs.current.toFixed(1)}ms · draws ${box.drawCalls} · ` +
|
|
179
|
+
`tris ${box.triangles} · progs ${box.programs} · ` +
|
|
180
|
+
`raster ${(stats.bytes / 1e6).toFixed(0)}MB/${stats.entries} (+${hits}h ${misses}m) · ` +
|
|
181
|
+
`layers ${layers}${ltText}${spans}`,
|
|
182
|
+
);
|
|
183
|
+
}, SAMPLE_MS);
|
|
184
|
+
return () => clearInterval(timer);
|
|
185
|
+
}, [box, layers]);
|
|
186
|
+
return (
|
|
187
|
+
<div
|
|
188
|
+
style={{
|
|
189
|
+
position: "fixed",
|
|
190
|
+
left: 8,
|
|
191
|
+
bottom: 8,
|
|
192
|
+
zIndex: 40,
|
|
193
|
+
padding: "4px 8px",
|
|
194
|
+
borderRadius: 4,
|
|
195
|
+
background: "rgba(10, 10, 12, 0.82)",
|
|
196
|
+
color: "#9be29b",
|
|
197
|
+
font: "11px/1.5 ui-monospace, monospace",
|
|
198
|
+
pointerEvents: "none",
|
|
199
|
+
whiteSpace: "nowrap",
|
|
200
|
+
}}
|
|
201
|
+
data-testid="perf-hud"
|
|
202
|
+
>
|
|
203
|
+
{text}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|