@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,1046 @@
|
|
|
1
|
+
// The stage's overlay layer: one SVG over the canvas.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THE OVERLAY IS IN THE STAGE AND NOT IN THE CHROME ───────────────
|
|
4
|
+
//
|
|
5
|
+
// The chrome is three-free and receives screen-space numbers as data — that
|
|
6
|
+
// invariant is unchanged and `sourceHygiene.test.ts` still enforces it. What
|
|
7
|
+
// changed is WHERE those numbers are drawn.
|
|
8
|
+
//
|
|
9
|
+
// Drawing them in the stage removes a cross-document synchronisation problem
|
|
10
|
+
// outright. The quads are recomputed inside the render loop; the chrome is
|
|
11
|
+
// another document with its own commit schedule, so pushing them across would
|
|
12
|
+
// paint the outline one frame behind the pixels it outlines — visible on every
|
|
13
|
+
// dolly, as handles that lag the layer. The chrome can still READ the quads
|
|
14
|
+
// (`bridge.selectionQuads(pane)`) for anything that belongs in a panel.
|
|
15
|
+
//
|
|
16
|
+
// It is also where the pointer already is: gestures land on the stage's own
|
|
17
|
+
// DOM, so the thing being dragged and the thing being drawn are one document.
|
|
18
|
+
//
|
|
19
|
+
// ── ONE SVG, TWO PANES ──────────────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// Each pane is a `<g>` translated to its rect and clipped to it. Translating
|
|
22
|
+
// is what lets every number that crosses this boundary be PANE-LOCAL — the
|
|
23
|
+
// same coordinates `paneAt` hands the pointer lane and the same ones
|
|
24
|
+
// `StageTools` projects into. A single overlay in canvas coordinates would
|
|
25
|
+
// need every quad offset at the draw site, which is a second place for a pane
|
|
26
|
+
// origin to be added or forgotten.
|
|
27
|
+
//
|
|
28
|
+
// Clipping is not decoration. A layer that sits off the left edge of the
|
|
29
|
+
// camera pane projects to a negative x, and an unclipped outline would draw it
|
|
30
|
+
// across the working pane's picture as a stray rectangle.
|
|
31
|
+
//
|
|
32
|
+
// ── THE OUTLINE IS A POLYGON ────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// Never an axis-aligned rect. A tilted layer's outline is a trapezoid, and a
|
|
35
|
+
// bounding box would lie about the tilt in every frame — and would put the
|
|
36
|
+
// resize handles somewhere the layer is not.
|
|
37
|
+
import { useEffect, useRef } from "react";
|
|
38
|
+
import { pathToD } from "@dailies-studio/shot/doc";
|
|
39
|
+
import type { PathPoint } from "@dailies-studio/shot/doc";
|
|
40
|
+
import type { NavBall } from "../gizmoMath.js";
|
|
41
|
+
import type { ModalAxis, ModalMode } from "../modalState.js";
|
|
42
|
+
import { HANDLE_SITES, handlePoint } from "../handles.js";
|
|
43
|
+
import type { StageInset } from "../../bridgeTypes.js";
|
|
44
|
+
import type { PaneRect, StagePane } from "../panes.js";
|
|
45
|
+
import type { ScreenQuad } from "../project.js";
|
|
46
|
+
|
|
47
|
+
/** A smart guide, already converted to CSS px by the caller — the overlay does
|
|
48
|
+
* no coordinate maths of its own, so there is one conversion in the lane. */
|
|
49
|
+
export interface ScreenGuide {
|
|
50
|
+
axis: "x" | "y";
|
|
51
|
+
at: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A live marquee, in the coordinates of the pane the drag STARTED in. It
|
|
55
|
+
* stays in that pane even when the cursor leaves it, which is what makes a
|
|
56
|
+
* marquee that overshoots the divider still mean what the author meant. */
|
|
57
|
+
export interface ScreenMarquee {
|
|
58
|
+
pane: StagePane;
|
|
59
|
+
x0: number;
|
|
60
|
+
y0: number;
|
|
61
|
+
x1: number;
|
|
62
|
+
y1: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OverlayProps {
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
panes: { working: PaneRect | null; camera: PaneRect | null };
|
|
69
|
+
/** The exportable frame, in CSS px from the CAMERA PANE's top-left. */
|
|
70
|
+
frame: { x: number; y: number; width: number; height: number };
|
|
71
|
+
/** Selection outlines, pane-local. Empty for a pane that is off screen. */
|
|
72
|
+
quadsCamera: readonly ScreenQuad[];
|
|
73
|
+
quadsWorking: readonly ScreenQuad[];
|
|
74
|
+
/** Parallel to both quad arrays: is this selected layer locked? ONE array for
|
|
75
|
+
* both panes, because the selection is one selection and only its projection
|
|
76
|
+
* differs. A locked layer keeps its outline and loses its handles. */
|
|
77
|
+
locked: readonly boolean[];
|
|
78
|
+
/**
|
|
79
|
+
* The multi-select's ONE box, per pane, or null.
|
|
80
|
+
*
|
|
81
|
+
* When it is drawn it OWNS the handles and every member outline gives them up
|
|
82
|
+
* — because `grabAt` gives the box first refusal on any press near a handle,
|
|
83
|
+
* so a member's own would be drawn and never answered. When it is null (one
|
|
84
|
+
* layer, or a selection that is not coplanar and axis-parallel) the members
|
|
85
|
+
* keep theirs and nothing else changes.
|
|
86
|
+
*/
|
|
87
|
+
unionCamera: ScreenQuad | null;
|
|
88
|
+
unionWorking: ScreenQuad | null;
|
|
89
|
+
/**
|
|
90
|
+
* What a click WOULD select, and in which pane, or null.
|
|
91
|
+
*
|
|
92
|
+
* Drawn thinner and dimmer than the selection so the two never read as the
|
|
93
|
+
* same state. It is the affordance Figma and Blender both have and this stage
|
|
94
|
+
* did not: without it the author learns what a click selects by clicking,
|
|
95
|
+
* which in a group tree means undoing a selection they did not want.
|
|
96
|
+
*/
|
|
97
|
+
hover: { pane: StagePane; quad: ScreenQuad } | null;
|
|
98
|
+
/** "Camera follows this view" is armed, so orbiting writes the document. */
|
|
99
|
+
followsView: boolean;
|
|
100
|
+
/** The isolated layer's label, or null when nothing is isolated. Isolation
|
|
101
|
+
* REPLACES the working pane's camera with a derived head-on orthographic rig
|
|
102
|
+
* (isolation.ts), so orbit, pan and dolly all stop answering — the badge is
|
|
103
|
+
* what turns that from "the view froze" into a mode with a way out. */
|
|
104
|
+
isolation: string | null;
|
|
105
|
+
marquee: ScreenMarquee | null;
|
|
106
|
+
guides: readonly ScreenGuide[];
|
|
107
|
+
/** The pen's path so far, in CAMERA-PANE CSS px, or null when no path is
|
|
108
|
+
* being drawn. Converted by the caller, like the guides — the overlay does
|
|
109
|
+
* no coordinate maths of its own.
|
|
110
|
+
*
|
|
111
|
+
* `cursor` is where the rubber band ends; `closeHot` is true when a press
|
|
112
|
+
* there would CLOSE the loop, which the preview says by filling the first
|
|
113
|
+
* anchor rather than by moving anything. */
|
|
114
|
+
/**
|
|
115
|
+
* The same guides, PROJECTED, for the working pane.
|
|
116
|
+
*
|
|
117
|
+
* A guide is a line in the design plane. In the flat camera pane that is one
|
|
118
|
+
* screen coordinate, which is what `guides` above carries. Under a free orbit
|
|
119
|
+
* it is a segment at an arbitrary angle, so it needs two projected endpoints
|
|
120
|
+
* and cannot be expressed the same way.
|
|
121
|
+
*
|
|
122
|
+
* It used to be neither: `snapFor` runs for a move in BOTH panes, so a
|
|
123
|
+
* working-pane drag snapped onto another layer's edge and nothing was drawn
|
|
124
|
+
* to say why — the layer simply jumped. Silent behaviour is the whole class
|
|
125
|
+
* this lane keeps removing.
|
|
126
|
+
*/
|
|
127
|
+
guidesWorking: readonly { a: readonly [number, number]; b: readonly [number, number] }[];
|
|
128
|
+
pen: ScreenPen | null;
|
|
129
|
+
/** A live modal transform's pill, or null. In-pane for the FollowBadge's
|
|
130
|
+
* reason: a mode the author is inside belongs over the picture it is
|
|
131
|
+
* changing, not in a panel they are not looking at. */
|
|
132
|
+
modal: ScreenModal | null;
|
|
133
|
+
/** The working pane's axis indicator: six projected axis ends, and what a
|
|
134
|
+
* click on one should do. Null when there is no working pane. */
|
|
135
|
+
nav: ScreenNav | null;
|
|
136
|
+
/** The selected toon mesh's key-direction handle, or null when there is no
|
|
137
|
+
* single toon mesh selected. Working pane only: it is a 3D direction, and
|
|
138
|
+
* the camera pane has no way to show one. */
|
|
139
|
+
keyLight: ScreenKeyLight | null;
|
|
140
|
+
/**
|
|
141
|
+
* How far the chrome's floating panels reach into the stage, per side.
|
|
142
|
+
*
|
|
143
|
+
* EVERY piece of overlay furniture lays out inside it — the mode bars, the
|
|
144
|
+
* modal pill, the nav gizmo — because the panes are the whole window and the
|
|
145
|
+
* chrome sits on top of them. Before this, two mode bars drew entirely behind
|
|
146
|
+
* the opaque shell header and the modal pill drew behind the left rail, so
|
|
147
|
+
* the only thing that said a G / R / S transform was live had never been
|
|
148
|
+
* visible in the default layout.
|
|
149
|
+
*
|
|
150
|
+
* The pane RECTS are untouched: insetting those would move the scissor and
|
|
151
|
+
* shrink the rendered picture. This is paint only.
|
|
152
|
+
*/
|
|
153
|
+
inset: StageInset;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The key-light handle: where the selected mesh is on screen, and which way its
|
|
158
|
+
* fake key points.
|
|
159
|
+
*
|
|
160
|
+
* ── IT IS NOT A LIGHT, AND THE DRAWING SHOULD NOT PRETEND ─────────────────
|
|
161
|
+
*
|
|
162
|
+
* This lane has no lights and is not getting any (`MeshShading`: "no lights,
|
|
163
|
+
* ever"). What a toon mesh has is `keyDir`, a direction evaluated in the
|
|
164
|
+
* shader — and until now the only way to set it was three number fields in the
|
|
165
|
+
* inspector, which is a poor way to answer a question the author is looking
|
|
166
|
+
* straight at. So the sun disc is an affordance for a uniform, and it wears the
|
|
167
|
+
* frustum's yellow: the colour this stage already uses for "scene rig drawn as
|
|
168
|
+
* furniture", not the accent that means "your selection".
|
|
169
|
+
*/
|
|
170
|
+
export interface ScreenKeyLight {
|
|
171
|
+
/** The selected layer's quad centre, pane-local CSS px. */
|
|
172
|
+
at: readonly [number, number];
|
|
173
|
+
/** The key direction projected into the pane, normalised, or null when it
|
|
174
|
+
* points nearly along the view axis and has no readable 2D direction. */
|
|
175
|
+
dir: readonly [number, number] | null;
|
|
176
|
+
/** A press on the disc or its arm. The overlay is paint: it hands the whole
|
|
177
|
+
* event to the stage, exactly as `ScreenNav.onGrab` does. */
|
|
178
|
+
onGrab: (event: React.PointerEvent) => void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ScreenNav {
|
|
182
|
+
balls: readonly NavBall[];
|
|
183
|
+
/**
|
|
184
|
+
* A press ON the widget: a ball, or `null` for its backing disc.
|
|
185
|
+
*
|
|
186
|
+
* ── WHY THE OVERLAY HANDS THE WHOLE EVENT OVER ──────────────────────────
|
|
187
|
+
*
|
|
188
|
+
* It used to be `onSnap(ball)`, fired straight from `pointerdown` — so the
|
|
189
|
+
* gizmo could only be CLICKED, and dragging it did nothing. Blender's turns
|
|
190
|
+
* the view. Deciding between the two needs a travel threshold and a drag
|
|
191
|
+
* lane, and this file is paint: it owns no gesture vocabulary and registers
|
|
192
|
+
* no move listeners. The stage runs `dragGesture` exactly as it does for an
|
|
193
|
+
* orbit on empty space, and snaps only when the travel never crossed.
|
|
194
|
+
*/
|
|
195
|
+
onGrab: (event: React.PointerEvent, ball: NavBall | null) => void;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What the modal pill says, and whose colour it wears: the accent for layer
|
|
199
|
+
* transforms, the frustum's yellow for the camera — the same subject-colour
|
|
200
|
+
* rule the FollowBadge states. */
|
|
201
|
+
export interface ScreenModal {
|
|
202
|
+
label: string;
|
|
203
|
+
camera: boolean;
|
|
204
|
+
/**
|
|
205
|
+
* Which transform is live, drawn as a glyph before the label.
|
|
206
|
+
*
|
|
207
|
+
* A modal takes POINTER LOCK, so there is no cursor on screen at all while
|
|
208
|
+
* one runs — the usual place a tool says what it is doing. The pill is the
|
|
209
|
+
* only surface left, and a word alone is something the author has to read;
|
|
210
|
+
* a shape is something they see. Same three modes the keymap arms.
|
|
211
|
+
*/
|
|
212
|
+
mode: ModalMode;
|
|
213
|
+
/** The axis lock, or null. Tints the glyph with that axis's own colour, so
|
|
214
|
+
* "S then X" says which direction is about to move without a second look at
|
|
215
|
+
* the label. */
|
|
216
|
+
axis: ModalAxis;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Where the pill's live numbers are written, OUTSIDE React.
|
|
221
|
+
*
|
|
222
|
+
* The readout changes on every pointermove, and `modalBadge` is memoised on the
|
|
223
|
+
* modal STATE alone precisely so motion costs no render. Routing the numbers
|
|
224
|
+
* through props would undo that — a full stage commit per mouse move to change
|
|
225
|
+
* a string. So the pill registers a setter here and `flushModal` calls it.
|
|
226
|
+
*
|
|
227
|
+
* A module-level box rather than a ref chain, because exactly one modal can be
|
|
228
|
+
* live at a time (the pointerdown lane's first branch guarantees it) and the
|
|
229
|
+
* two ends are in different files.
|
|
230
|
+
*/
|
|
231
|
+
export const modalReadoutSink: { write: ((text: string) => void) | null } = { write: null };
|
|
232
|
+
|
|
233
|
+
export interface ScreenPen {
|
|
234
|
+
points: readonly PathPoint[];
|
|
235
|
+
closed: boolean;
|
|
236
|
+
cursor: readonly [number, number] | null;
|
|
237
|
+
closeHot: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function Overlays({
|
|
241
|
+
width,
|
|
242
|
+
height,
|
|
243
|
+
panes,
|
|
244
|
+
frame,
|
|
245
|
+
quadsCamera,
|
|
246
|
+
quadsWorking,
|
|
247
|
+
locked,
|
|
248
|
+
unionCamera,
|
|
249
|
+
unionWorking,
|
|
250
|
+
hover,
|
|
251
|
+
followsView,
|
|
252
|
+
isolation,
|
|
253
|
+
marquee,
|
|
254
|
+
guides,
|
|
255
|
+
guidesWorking,
|
|
256
|
+
pen,
|
|
257
|
+
modal,
|
|
258
|
+
nav,
|
|
259
|
+
keyLight,
|
|
260
|
+
inset,
|
|
261
|
+
}: OverlayProps) {
|
|
262
|
+
const { working, camera } = panes;
|
|
263
|
+
return (
|
|
264
|
+
<svg
|
|
265
|
+
className="stage-overlay"
|
|
266
|
+
width={width}
|
|
267
|
+
height={height}
|
|
268
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
269
|
+
// The pointer lane owns the container; the overlay is paint only. Without
|
|
270
|
+
// this every drag would end the moment the cursor crossed a handle.
|
|
271
|
+
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
|
|
272
|
+
>
|
|
273
|
+
<defs>
|
|
274
|
+
{working === null ? null : <PaneClip id="shot-pane-working" rect={working} />}
|
|
275
|
+
{camera === null ? null : <PaneClip id="shot-pane-camera" rect={camera} />}
|
|
276
|
+
</defs>
|
|
277
|
+
|
|
278
|
+
{working === null ? null : (
|
|
279
|
+
<g clipPath="url(#shot-pane-working)" transform={`translate(${working.x} ${working.y})`}>
|
|
280
|
+
{/* NO FRAME BORDER. The exportable frame is a property of the shot
|
|
281
|
+
camera's projection; drawn in an orbit view it would be a
|
|
282
|
+
rectangle floating in front of a scene it does not bound. */}
|
|
283
|
+
{/* UNDER the selection: a layer that is both hovered and selected
|
|
284
|
+
must read as selected, not as a heavier version of hovered. */}
|
|
285
|
+
{hover?.pane === "working" ? <HoverQuad quad={hover.quad} /> : null}
|
|
286
|
+
{quadsWorking.map((q, i) => (
|
|
287
|
+
// HANDLES HERE TOO, since `runTransformGesture` learned to measure
|
|
288
|
+
// a resize by unprojecting two rays onto the layer's own plane. The
|
|
289
|
+
// old note here said this pane had "no zoom, only a distance" and
|
|
290
|
+
// therefore no resize; the ray form has no zoom term at all, so the
|
|
291
|
+
// reason is gone and the author can resize what they are looking at.
|
|
292
|
+
<SelectionQuad
|
|
293
|
+
key={i}
|
|
294
|
+
quad={q}
|
|
295
|
+
handles={locked[i] !== true && unionWorking === null}
|
|
296
|
+
/>
|
|
297
|
+
))}
|
|
298
|
+
{unionWorking === null ? null : <UnionBox quad={unionWorking} />}
|
|
299
|
+
{/* The snap guides, as the SEGMENTS they are once the plane is seen
|
|
300
|
+
at an angle. Same colour and weight as the camera pane's, because
|
|
301
|
+
they are the same guides. */}
|
|
302
|
+
{guidesWorking.map((g, i) => (
|
|
303
|
+
<line
|
|
304
|
+
key={i}
|
|
305
|
+
x1={g.a[0]}
|
|
306
|
+
y1={g.a[1]}
|
|
307
|
+
x2={g.b[0]}
|
|
308
|
+
y2={g.b[1]}
|
|
309
|
+
stroke="var(--guide, #f43f5e)"
|
|
310
|
+
strokeWidth={1}
|
|
311
|
+
/>
|
|
312
|
+
))}
|
|
313
|
+
{marquee?.pane === "working" ? <Marquee rect={marquee} /> : null}
|
|
314
|
+
{/* The mode bars STACK DOWN FROM the chrome's bottom edge, so they
|
|
315
|
+
are the first thing under the header rather than behind it. */}
|
|
316
|
+
{isolation === null ? null : (
|
|
317
|
+
<IsolationBadge
|
|
318
|
+
width={working.width}
|
|
319
|
+
y={inset.top}
|
|
320
|
+
left={inset.left}
|
|
321
|
+
label={isolation}
|
|
322
|
+
/>
|
|
323
|
+
)}
|
|
324
|
+
{/* Under the isolation bar when both are on. Two bars at the same y
|
|
325
|
+
would hide one mode behind the other, and isolation is the one
|
|
326
|
+
that changed the camera. */}
|
|
327
|
+
{followsView ? (
|
|
328
|
+
<FollowBadge
|
|
329
|
+
width={working.width}
|
|
330
|
+
y={inset.top + (isolation === null ? 0 : BAR_H)}
|
|
331
|
+
left={inset.left}
|
|
332
|
+
/>
|
|
333
|
+
) : null}
|
|
334
|
+
{keyLight === null ? null : <KeyLightGizmo light={keyLight} />}
|
|
335
|
+
{modal === null ? null : <ModalBadge modal={modal} rect={working} inset={inset} />}
|
|
336
|
+
{nav === null ? null : (
|
|
337
|
+
<NavGizmo nav={nav} width={working.width} top={inset.top} right={inset.right} />
|
|
338
|
+
)}
|
|
339
|
+
</g>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{camera === null ? null : (
|
|
343
|
+
<g clipPath="url(#shot-pane-camera)" transform={`translate(${camera.x} ${camera.y})`}>
|
|
344
|
+
<FrameBorder rect={frame} width={camera.width} height={camera.height} />
|
|
345
|
+
{hover?.pane === "camera" ? <HoverQuad quad={hover.quad} /> : null}
|
|
346
|
+
{quadsCamera.map((q, i) => (
|
|
347
|
+
// A LOCKED LAYER DRAWS NO HANDLES, matching `grabAt`, which refuses
|
|
348
|
+
// to answer one. A handle that cannot be grabbed is a handle the
|
|
349
|
+
// author will try to grab, and then wonder what is broken.
|
|
350
|
+
//
|
|
351
|
+
// NOR DOES A MEMBER OF A UNION. The box has them, and `grabAt` gives
|
|
352
|
+
// the box first refusal on every press near one — so a member's own
|
|
353
|
+
// handle would be drawn and never answered.
|
|
354
|
+
<SelectionQuad key={i} quad={q} handles={locked[i] !== true && unionCamera === null} />
|
|
355
|
+
))}
|
|
356
|
+
{unionCamera === null ? null : <UnionBox quad={unionCamera} />}
|
|
357
|
+
{guides.map((g, i) =>
|
|
358
|
+
g.axis === "x" ? (
|
|
359
|
+
<line
|
|
360
|
+
key={i}
|
|
361
|
+
x1={g.at}
|
|
362
|
+
y1={0}
|
|
363
|
+
x2={g.at}
|
|
364
|
+
y2={camera.height}
|
|
365
|
+
stroke="var(--guide, #f43f5e)"
|
|
366
|
+
strokeWidth={1}
|
|
367
|
+
/>
|
|
368
|
+
) : (
|
|
369
|
+
<line
|
|
370
|
+
key={i}
|
|
371
|
+
x1={0}
|
|
372
|
+
y1={g.at}
|
|
373
|
+
x2={camera.width}
|
|
374
|
+
y2={g.at}
|
|
375
|
+
stroke="var(--guide, #f43f5e)"
|
|
376
|
+
strokeWidth={1}
|
|
377
|
+
/>
|
|
378
|
+
),
|
|
379
|
+
)}
|
|
380
|
+
{marquee?.pane === "camera" ? <Marquee rect={marquee} /> : null}
|
|
381
|
+
{pen === null || pen.points.length === 0 ? null : <PenPreview pen={pen} />}
|
|
382
|
+
{/* THE BADGE FOLLOWS THE MODE, NOT THE PANE. Camera-only plus
|
|
383
|
+
isolated is a legitimate state — the author can switch layout
|
|
384
|
+
while inside a subtree — and it had zero indicators while the dim
|
|
385
|
+
and the pick scope were both fully in force, so every click
|
|
386
|
+
outside the card did nothing with nothing to say why. Drawn here
|
|
387
|
+
only when the working pane is off screen, or the two would both
|
|
388
|
+
render it. */}
|
|
389
|
+
{isolation === null || working !== null ? null : (
|
|
390
|
+
<IsolationBadge
|
|
391
|
+
width={camera.width}
|
|
392
|
+
y={inset.top}
|
|
393
|
+
left={inset.left}
|
|
394
|
+
label={isolation}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
</g>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{working === null || camera === null ? null : <Divider x={camera.x} height={height} />}
|
|
401
|
+
</svg>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function PaneClip({ id, rect }: { id: string; rect: PaneRect }) {
|
|
406
|
+
// Authored at the ORIGIN because the group that references it carries the
|
|
407
|
+
// translate, and a clip path resolves in the referencing element's own user
|
|
408
|
+
// space. Repeating the pane's x here would offset it twice.
|
|
409
|
+
return (
|
|
410
|
+
<clipPath id={id}>
|
|
411
|
+
<rect x={0} y={0} width={rect.width} height={rect.height} />
|
|
412
|
+
</clipPath>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* "Camera follows this view", said INSIDE the pane it is true of.
|
|
418
|
+
*
|
|
419
|
+
* Blender's decade-old complaint about the equivalent toggle is that it is easy
|
|
420
|
+
* to forget it is on — and it is easy because the only thing that says so is a
|
|
421
|
+
* checkbox in a panel nobody is looking at while they orbit. So this is drawn
|
|
422
|
+
* over the picture, in the gizmo's own yellow so it reads as the same subject,
|
|
423
|
+
* and it is a full-width bar rather than a dot because the state it announces
|
|
424
|
+
* is that ORBITING EDITS THE DOCUMENT.
|
|
425
|
+
*
|
|
426
|
+
* A bar and not a border: a border here would be one more rectangle in a pane
|
|
427
|
+
* that already draws a frustum, a selection and a divider.
|
|
428
|
+
*/
|
|
429
|
+
/** Height of a mode bar. Shared so isolation can stack follow under itself
|
|
430
|
+
* without either one restating the other's geometry. */
|
|
431
|
+
const BAR_H = 22;
|
|
432
|
+
|
|
433
|
+
function FollowBadge({ width, y, left }: { width: number; y: number; left: number }) {
|
|
434
|
+
return (
|
|
435
|
+
<ModeBar
|
|
436
|
+
width={width}
|
|
437
|
+
y={y}
|
|
438
|
+
left={left}
|
|
439
|
+
color="var(--frustum, #eab308)"
|
|
440
|
+
text="CAMERA FOLLOWS THIS VIEW"
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* "You are inside this layer", said INSIDE the pane it is true of.
|
|
447
|
+
*
|
|
448
|
+
* Isolation is the largest mode this pane has: `StageTools` swaps a derived
|
|
449
|
+
* orthographic head-on rig into the working camera's slot, so orbit, pan, dolly
|
|
450
|
+
* and the nav gizmo all stop answering, and picking is scoped to the subtree.
|
|
451
|
+
* With no in-pane sign, that reads as the view having frozen on whatever was
|
|
452
|
+
* double-clicked — and the only thing that said otherwise was a breadcrumb in
|
|
453
|
+
* the left panel, which `[` and `.` can hide entirely.
|
|
454
|
+
*
|
|
455
|
+
* THE WAY OUT IS IN THE BADGE, for the reason the badge exists at all: a mode
|
|
456
|
+
* the author entered by accident has to be leavable without finding the panel
|
|
457
|
+
* that documents it.
|
|
458
|
+
*
|
|
459
|
+
* The accent, not the frustum's yellow: the subject is a LAYER, which is the
|
|
460
|
+
* same subject-colour rule ModalBadge follows.
|
|
461
|
+
*/
|
|
462
|
+
function IsolationBadge({
|
|
463
|
+
width,
|
|
464
|
+
y,
|
|
465
|
+
label,
|
|
466
|
+
left,
|
|
467
|
+
}: {
|
|
468
|
+
width: number;
|
|
469
|
+
y: number;
|
|
470
|
+
label: string;
|
|
471
|
+
left: number;
|
|
472
|
+
}) {
|
|
473
|
+
return (
|
|
474
|
+
<ModeBar
|
|
475
|
+
width={width}
|
|
476
|
+
y={y}
|
|
477
|
+
left={left}
|
|
478
|
+
color="var(--accent, #3b82f6)"
|
|
479
|
+
text={`INSIDE ${label.toUpperCase()} — ESC TO LEAVE`}
|
|
480
|
+
/>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ModeBar({
|
|
485
|
+
width,
|
|
486
|
+
y,
|
|
487
|
+
color,
|
|
488
|
+
text,
|
|
489
|
+
/** How far the chrome's left rail reaches into this pane. THE TEXT IS THE
|
|
490
|
+
* ONLY PART THAT MOVES: the tinted band is a full-width mode indicator and
|
|
491
|
+
* reads correctly under a panel, but the words did not — they drew at x = 10,
|
|
492
|
+
* behind an opaque rail, so the only thing naming isolation or follow mode
|
|
493
|
+
* was invisible at every default panel width. */
|
|
494
|
+
left,
|
|
495
|
+
}: {
|
|
496
|
+
width: number;
|
|
497
|
+
y: number;
|
|
498
|
+
color: string;
|
|
499
|
+
text: string;
|
|
500
|
+
left: number;
|
|
501
|
+
}) {
|
|
502
|
+
return (
|
|
503
|
+
<g pointerEvents="none">
|
|
504
|
+
<rect x={0} y={y} width={width} height={BAR_H} fill={color} fillOpacity={0.16} />
|
|
505
|
+
<rect x={0} y={y} width={width} height={2} fill={color} />
|
|
506
|
+
<text
|
|
507
|
+
x={left + 10}
|
|
508
|
+
y={y + 16}
|
|
509
|
+
fill={color}
|
|
510
|
+
fontFamily="var(--font-ui, system-ui)"
|
|
511
|
+
fontSize={11}
|
|
512
|
+
fontWeight={600}
|
|
513
|
+
letterSpacing={0.4}
|
|
514
|
+
>
|
|
515
|
+
{text}
|
|
516
|
+
</text>
|
|
517
|
+
</g>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The live modal transform's pill: which transform, which axis, whose
|
|
523
|
+
* subject. Bottom-left of the working pane — over the picture being changed
|
|
524
|
+
* (the FollowBadge's argument), and at the bottom so it never collides with
|
|
525
|
+
* the follow bar at the top. Width from the label's length at the pill's own
|
|
526
|
+
* monospace-ish tracking; an SVG has no text measurement without layout, and
|
|
527
|
+
* a few px of slack is invisible on a translucent plate.
|
|
528
|
+
*/
|
|
529
|
+
/**
|
|
530
|
+
* The three mode glyphs, as SVG path data on a 16x16 box centred at (8, 8).
|
|
531
|
+
*
|
|
532
|
+
* Stroked, not filled, so one `stroke` attribute colours the whole thing and
|
|
533
|
+
* the shapes read at 14px against a translucent plate. They are the shapes the
|
|
534
|
+
* tools already imply: a four-way arrow for move, an arc with a head for
|
|
535
|
+
* rotate, and a diagonal with a box at each end for scale.
|
|
536
|
+
*/
|
|
537
|
+
const MODAL_GLYPHS: Record<ModalMode, string> = {
|
|
538
|
+
move: "M8 2v12M2 8h12M8 2 6 4.5M8 2l2 2.5M8 14l-2-2.5M8 14l2-2.5M2 8l2.5-2M2 8l2.5 2M14 8l-2.5-2M14 8l-2.5 2",
|
|
539
|
+
rotate: "M3.2 9.6a5 5 0 1 0 1.5-4.4M4.7 2.2v3.2h3.2",
|
|
540
|
+
scale: "M4 12 12 4M2.5 10.5h3v3h-3zM10.5 2.5h3v3h-3z",
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
/** Glyph size and the gap to the label, in CSS px. The pill's own width grows
|
|
544
|
+
* by exactly this much so nothing that fitted before starts clipping. */
|
|
545
|
+
const GLYPH_PX = 14;
|
|
546
|
+
const GLYPH_GAP_PX = 6;
|
|
547
|
+
|
|
548
|
+
/** The axis palette, the same tokens the nav gizmo and the move arms wear. */
|
|
549
|
+
const AXIS_COLORS: Record<"x" | "y" | "z", string> = {
|
|
550
|
+
x: "var(--axis-x, #ef4444)",
|
|
551
|
+
y: "var(--axis-y, #22c55e)",
|
|
552
|
+
z: "var(--axis-z, #3b82f6)",
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect; inset: StageInset }) {
|
|
556
|
+
const color = modal.camera ? "var(--frustum, #eab308)" : "var(--accent, #3b82f6)";
|
|
557
|
+
// The glyph wears the AXIS's colour when one is locked, and the pill's own
|
|
558
|
+
// otherwise. The lock is the thing most worth seeing at a glance, and it is
|
|
559
|
+
// the one piece of modal state that changes mid-gesture.
|
|
560
|
+
const glyphColor = modal.axis === null ? color : AXIS_COLORS[modal.axis];
|
|
561
|
+
const shift = GLYPH_PX + GLYPH_GAP_PX;
|
|
562
|
+
// INSIDE the chrome on both axes. It sat at x = 10 and y = height - 34, which
|
|
563
|
+
// is behind the 232px left rail and behind the timeline dock — so the only
|
|
564
|
+
// thing that says a transform is live has never been visible in the default
|
|
565
|
+
// layout.
|
|
566
|
+
const x = inset.left + 10;
|
|
567
|
+
const y = rect.height - inset.bottom - 34;
|
|
568
|
+
// The live numbers, written straight into the node. See `modalReadoutSink`:
|
|
569
|
+
// the readout changes per pointermove and the pill is memoised on state, so
|
|
570
|
+
// the two have to meet outside React or motion costs a stage commit.
|
|
571
|
+
const readout = useRef<SVGTextElement | null>(null);
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
modalReadoutSink.write = (text: string): void => {
|
|
574
|
+
if (readout.current !== null) readout.current.textContent = text;
|
|
575
|
+
};
|
|
576
|
+
return () => {
|
|
577
|
+
modalReadoutSink.write = null;
|
|
578
|
+
};
|
|
579
|
+
}, []);
|
|
580
|
+
return (
|
|
581
|
+
<g pointerEvents="none">
|
|
582
|
+
<rect
|
|
583
|
+
x={x}
|
|
584
|
+
y={y}
|
|
585
|
+
rx={4}
|
|
586
|
+
// Room for the label AND the numbers. An SVG has no text measurement
|
|
587
|
+
// without layout, and a few px of slack is invisible on a translucent
|
|
588
|
+
// plate; a pill too NARROW would clip the figure the author is reading.
|
|
589
|
+
width={modal.label.length * 7.5 + 96 + shift}
|
|
590
|
+
height={24}
|
|
591
|
+
fill="var(--panel, #1c1c20)"
|
|
592
|
+
fillOpacity={0.88}
|
|
593
|
+
stroke={color}
|
|
594
|
+
strokeWidth={1}
|
|
595
|
+
/>
|
|
596
|
+
{/* Scaled onto the pill from the 16x16 box the paths are authored in, so
|
|
597
|
+
the shapes stay editable as plain coordinates. */}
|
|
598
|
+
<g
|
|
599
|
+
transform={`translate(${x + 10} ${y + 5}) scale(${GLYPH_PX / 16})`}
|
|
600
|
+
fill="none"
|
|
601
|
+
stroke={glyphColor}
|
|
602
|
+
strokeWidth={1.6}
|
|
603
|
+
strokeLinecap="round"
|
|
604
|
+
strokeLinejoin="round"
|
|
605
|
+
>
|
|
606
|
+
<path d={MODAL_GLYPHS[modal.mode]} />
|
|
607
|
+
</g>
|
|
608
|
+
<text
|
|
609
|
+
x={x + 10 + shift}
|
|
610
|
+
y={y + 16}
|
|
611
|
+
fill={color}
|
|
612
|
+
fontFamily="var(--font-ui, system-ui)"
|
|
613
|
+
fontSize={11}
|
|
614
|
+
fontWeight={600}
|
|
615
|
+
letterSpacing={0.6}
|
|
616
|
+
>
|
|
617
|
+
{modal.label}
|
|
618
|
+
</text>
|
|
619
|
+
<text
|
|
620
|
+
ref={readout}
|
|
621
|
+
x={x + modal.label.length * 7.5 + 22 + shift}
|
|
622
|
+
y={y + 16}
|
|
623
|
+
fill="var(--text, #e7e7ea)"
|
|
624
|
+
fontFamily="var(--font-mono, ui-monospace, monospace)"
|
|
625
|
+
fontSize={11}
|
|
626
|
+
fontWeight={500}
|
|
627
|
+
/>
|
|
628
|
+
</g>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Arm length and disc radius, CSS px. The arm is long enough to aim with and
|
|
633
|
+
* short enough not to cross the layer it belongs to. */
|
|
634
|
+
const KEY_ARM_PX = 48;
|
|
635
|
+
const KEY_DISC_PX = 9;
|
|
636
|
+
|
|
637
|
+
function KeyLightGizmo({ light }: { light: ScreenKeyLight }) {
|
|
638
|
+
const color = "var(--frustum, #eab308)";
|
|
639
|
+
const [cx, cy] = light.at;
|
|
640
|
+
// The arm points where the KEY COMES FROM, which is the direction the author
|
|
641
|
+
// is aiming: a sun in the sky, not a ray leaving it. `dir` is already the
|
|
642
|
+
// design direction projected into the pane, so this is a sign, not a maths.
|
|
643
|
+
const tip =
|
|
644
|
+
light.dir === null ? null : ([cx - light.dir[0] * KEY_ARM_PX, cy - light.dir[1] * KEY_ARM_PX] as const);
|
|
645
|
+
return (
|
|
646
|
+
<g>
|
|
647
|
+
{tip === null ? null : (
|
|
648
|
+
<line x1={cx} y1={cy} x2={tip[0]} y2={tip[1]} stroke={color} strokeWidth={1.5} strokeDasharray="3 3" />
|
|
649
|
+
)}
|
|
650
|
+
{/* The grabbable end. When the key points nearly along the view axis
|
|
651
|
+
there is no readable 2D direction, so the disc sits ON the layer
|
|
652
|
+
centre — still draggable, which is what pulls it back out of the
|
|
653
|
+
degenerate pose. */}
|
|
654
|
+
<g
|
|
655
|
+
transform={`translate(${tip === null ? cx : tip[0]} ${tip === null ? cy : tip[1]})`}
|
|
656
|
+
// The one other piece of overlay furniture that takes pointer events.
|
|
657
|
+
// Paint hands the event over; the stage owns the drag.
|
|
658
|
+
pointerEvents="auto"
|
|
659
|
+
style={{ cursor: "grab" }}
|
|
660
|
+
onPointerDown={light.onGrab}
|
|
661
|
+
>
|
|
662
|
+
<circle r={KEY_DISC_PX} fill="var(--panel, #1c1c20)" fillOpacity={0.9} stroke={color} strokeWidth={1.5} />
|
|
663
|
+
{/* Four short rays, so the disc reads as a key rather than as another
|
|
664
|
+
handle. Drawn at the diagonals to stay clear of the arm. */}
|
|
665
|
+
{[45, 135, 225, 315].map((deg) => {
|
|
666
|
+
const r = (deg * Math.PI) / 180;
|
|
667
|
+
return (
|
|
668
|
+
<line
|
|
669
|
+
key={deg}
|
|
670
|
+
x1={Math.cos(r) * (KEY_DISC_PX + 2)}
|
|
671
|
+
y1={Math.sin(r) * (KEY_DISC_PX + 2)}
|
|
672
|
+
x2={Math.cos(r) * (KEY_DISC_PX + 5)}
|
|
673
|
+
y2={Math.sin(r) * (KEY_DISC_PX + 5)}
|
|
674
|
+
stroke={color}
|
|
675
|
+
strokeWidth={1.5}
|
|
676
|
+
strokeLinecap="round"
|
|
677
|
+
/>
|
|
678
|
+
);
|
|
679
|
+
})}
|
|
680
|
+
</g>
|
|
681
|
+
</g>
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The nav gizmo's radius and its ball, in CSS px. Small enough to be furniture
|
|
686
|
+
* over the corner of a picture, large enough that a ball is a real click
|
|
687
|
+
* target at the size a cursor actually is. */
|
|
688
|
+
const NAV_RADIUS = 30;
|
|
689
|
+
const NAV_BALL = 8;
|
|
690
|
+
const NAV_MARGIN = 16;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Which way is which, in the corner of the working pane.
|
|
694
|
+
*
|
|
695
|
+
* ── WHY THIS IS SVG AND NOT A SECOND VIEWPORT ───────────────────────────
|
|
696
|
+
*
|
|
697
|
+
* The usual build of this widget is a small scene rendered into its own corner
|
|
698
|
+
* viewport. That is exactly the construction SplitRenderer's header forbids:
|
|
699
|
+
* a second render into a scissored canvas, whose failure on the WebGPU backend
|
|
700
|
+
* is a whole-attachment MSAA resolve that wipes the neighbouring pane — silent,
|
|
701
|
+
* and invisible to CI because the pinned Chromium cannot make a WebGPU device.
|
|
702
|
+
*
|
|
703
|
+
* A nav gizmo shows DIRECTIONS, and the directions are three dot products
|
|
704
|
+
* against the camera basis (`navBalls`). So it costs no GL at all.
|
|
705
|
+
*
|
|
706
|
+
* ── THE AXES ARE THE DESIGN AXES ────────────────────────────────────────
|
|
707
|
+
*
|
|
708
|
+
* X right, Y DOWN, Z away — the same three the inspector's fields and the
|
|
709
|
+
* modal's `G` `Y` mean. Labelling them with world axes instead would put the
|
|
710
|
+
* letter Y on the arm that moves a layer the opposite way from the number the
|
|
711
|
+
* author is watching.
|
|
712
|
+
*
|
|
713
|
+
* Top-right, which is where every 3D tool puts it — but pushed down past the
|
|
714
|
+
* chrome that floats over this pane's own top edge. `top` is the chrome's own
|
|
715
|
+
* measurement, sent across the bridge, and it replaced a hardcoded 116 that a
|
|
716
|
+
* wrapping control strip on a narrow window grew straight past.
|
|
717
|
+
*/
|
|
718
|
+
function NavGizmo({
|
|
719
|
+
nav,
|
|
720
|
+
width,
|
|
721
|
+
top,
|
|
722
|
+
/** How far the inspector reaches into this pane from the right. Unlike the
|
|
723
|
+
* mode bars', this inset is not cosmetic: the gizmo is the only piece of
|
|
724
|
+
* overlay furniture that TAKES POINTER EVENTS, so drawing it under an opaque
|
|
725
|
+
* panel makes it unclickable rather than merely hidden. */
|
|
726
|
+
right,
|
|
727
|
+
}: {
|
|
728
|
+
nav: ScreenNav;
|
|
729
|
+
width: number;
|
|
730
|
+
top: number;
|
|
731
|
+
right: number;
|
|
732
|
+
}) {
|
|
733
|
+
const gap = NAV_RADIUS + NAV_BALL + NAV_MARGIN;
|
|
734
|
+
const cx = width - right - gap;
|
|
735
|
+
const cy = top + gap;
|
|
736
|
+
// FAR FIRST. A flat drawing has no depth buffer, so paint order is the only
|
|
737
|
+
// cue for which end of an axis is pointing at the author — and without it a
|
|
738
|
+
// gizmo seen from behind reads exactly like one seen from the front.
|
|
739
|
+
const balls = [...nav.balls].sort((a, b) => a.toward - b.toward);
|
|
740
|
+
return (
|
|
741
|
+
<g data-testid="nav-gizmo">
|
|
742
|
+
{/* THE DISC TURNS THE VIEW. Blender's gizmo drags to orbit, and the body
|
|
743
|
+
is the part with nothing else to do — a press here can only ever have
|
|
744
|
+
meant "turn me". `grab` is the cursor because that is what it is. */}
|
|
745
|
+
<circle
|
|
746
|
+
cx={cx}
|
|
747
|
+
cy={cy}
|
|
748
|
+
r={NAV_RADIUS + NAV_BALL}
|
|
749
|
+
fill="var(--panel, #0c0c0e)"
|
|
750
|
+
fillOpacity={0.6}
|
|
751
|
+
pointerEvents="auto"
|
|
752
|
+
style={{ cursor: "grab" }}
|
|
753
|
+
onPointerDown={(e) => nav.onGrab(e, null)}
|
|
754
|
+
/>
|
|
755
|
+
{balls.map((ball) => {
|
|
756
|
+
const x = cx + ball.x * NAV_RADIUS;
|
|
757
|
+
const y = cy + ball.y * NAV_RADIUS;
|
|
758
|
+
const color = `var(--axis-${ball.axis}, ${NAV_FALLBACK[ball.axis]})`;
|
|
759
|
+
return (
|
|
760
|
+
<g
|
|
761
|
+
key={`${ball.axis}${ball.positive ? "+" : "-"}`}
|
|
762
|
+
// The SVG root is `pointerEvents: none` so drags pass through it;
|
|
763
|
+
// these groups opt back in. The press is stopped by the STAGE (see
|
|
764
|
+
// `onGrab`), which needs the event to open its own drag lane —
|
|
765
|
+
// stopping it here would leave a ball that could be clicked and
|
|
766
|
+
// never dragged, which is the behaviour this replaced.
|
|
767
|
+
pointerEvents="auto"
|
|
768
|
+
style={{ cursor: "pointer" }}
|
|
769
|
+
onPointerDown={(e) => nav.onGrab(e, ball)}
|
|
770
|
+
>
|
|
771
|
+
{/* The spoke, positive ends only: drawing both makes six lines
|
|
772
|
+
through one point and reads as a star rather than as an axis. */}
|
|
773
|
+
{ball.positive ? (
|
|
774
|
+
<line x1={cx} y1={cy} x2={x} y2={y} stroke={color} strokeWidth={2} />
|
|
775
|
+
) : null}
|
|
776
|
+
<circle
|
|
777
|
+
cx={x}
|
|
778
|
+
cy={y}
|
|
779
|
+
r={NAV_BALL}
|
|
780
|
+
// FILLED positive, HOLLOW negative — the one thing that tells the
|
|
781
|
+
// author whether they are looking at +Z or at -Z when the two
|
|
782
|
+
// project to the same place.
|
|
783
|
+
fill={ball.positive ? color : "var(--panel, #0c0c0e)"}
|
|
784
|
+
stroke={color}
|
|
785
|
+
strokeWidth={1.5}
|
|
786
|
+
/>
|
|
787
|
+
{ball.positive ? (
|
|
788
|
+
<text
|
|
789
|
+
x={x}
|
|
790
|
+
y={y + 3.5}
|
|
791
|
+
textAnchor="middle"
|
|
792
|
+
fill="var(--bg, #000)"
|
|
793
|
+
fontFamily="var(--font-ui, system-ui)"
|
|
794
|
+
fontSize={10}
|
|
795
|
+
fontWeight={700}
|
|
796
|
+
>
|
|
797
|
+
{ball.axis.toUpperCase()}
|
|
798
|
+
</text>
|
|
799
|
+
) : null}
|
|
800
|
+
</g>
|
|
801
|
+
);
|
|
802
|
+
})}
|
|
803
|
+
</g>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const NAV_FALLBACK: Readonly<Record<"x" | "y" | "z", string>> = {
|
|
808
|
+
x: "#ef4444",
|
|
809
|
+
y: "#22c55e",
|
|
810
|
+
z: "#3b82f6",
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
/** The seam between the panes. It belongs to the CAMERA pane's first column,
|
|
814
|
+
* which is what `paneAt` decides, so it is drawn ON that column rather than
|
|
815
|
+
* straddling it — a half-pixel straddle would put the drawn edge and the
|
|
816
|
+
* hit-tested edge in different places. */
|
|
817
|
+
function Divider({ x, height }: { x: number; height: number }) {
|
|
818
|
+
return (
|
|
819
|
+
<rect
|
|
820
|
+
x={x}
|
|
821
|
+
y={0}
|
|
822
|
+
width={1}
|
|
823
|
+
height={height}
|
|
824
|
+
fill="var(--stage-divider, #232328)"
|
|
825
|
+
shapeRendering="crispEdges"
|
|
826
|
+
/>
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* The exportable frame.
|
|
832
|
+
*
|
|
833
|
+
* Its rect comes from `frameRect`, which is derived from the SAME numbers as
|
|
834
|
+
* the camera's view offset — so the border sits exactly on the pixels that will
|
|
835
|
+
* be exported, at every zoom and every playhead, rather than approximately on
|
|
836
|
+
* them. The dimmed surround is what makes "you are working off-canvas" legible
|
|
837
|
+
* without a second camera.
|
|
838
|
+
*/
|
|
839
|
+
function FrameBorder({
|
|
840
|
+
rect,
|
|
841
|
+
width,
|
|
842
|
+
height,
|
|
843
|
+
}: {
|
|
844
|
+
rect: OverlayProps["frame"];
|
|
845
|
+
width: number;
|
|
846
|
+
height: number;
|
|
847
|
+
}) {
|
|
848
|
+
return (
|
|
849
|
+
<>
|
|
850
|
+
{/* Even-odd fill: one path, outer rect minus the frame, so the scrim can
|
|
851
|
+
never disagree with the border about where the frame is. */}
|
|
852
|
+
<path
|
|
853
|
+
d={`M0 0H${width}V${height}H0Z M${rect.x} ${rect.y}H${rect.x + rect.width}V${
|
|
854
|
+
rect.y + rect.height
|
|
855
|
+
}H${rect.x}Z`}
|
|
856
|
+
fillRule="evenodd"
|
|
857
|
+
fill="rgba(0,0,0,0.45)"
|
|
858
|
+
/>
|
|
859
|
+
<rect
|
|
860
|
+
x={rect.x}
|
|
861
|
+
y={rect.y}
|
|
862
|
+
width={rect.width}
|
|
863
|
+
height={rect.height}
|
|
864
|
+
fill="none"
|
|
865
|
+
stroke="var(--frame-border, #6b7280)"
|
|
866
|
+
strokeWidth={1}
|
|
867
|
+
shapeRendering="crispEdges"
|
|
868
|
+
/>
|
|
869
|
+
</>
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** What a click would select. One px and half opacity against the selection's
|
|
874
|
+
* 1.5 px solid, so the two states are told apart at a glance and never
|
|
875
|
+
* mistaken for each other on a layer that is both. */
|
|
876
|
+
function HoverQuad({ quad }: { quad: ScreenQuad }) {
|
|
877
|
+
return (
|
|
878
|
+
<polygon
|
|
879
|
+
points={pointsOf(quad)}
|
|
880
|
+
fill="none"
|
|
881
|
+
stroke="var(--accent, #3b82f6)"
|
|
882
|
+
strokeOpacity={0.5}
|
|
883
|
+
strokeWidth={1}
|
|
884
|
+
/>
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function SelectionQuad({ quad, handles }: { quad: ScreenQuad; handles?: boolean }) {
|
|
889
|
+
const points = pointsOf(quad);
|
|
890
|
+
return (
|
|
891
|
+
<>
|
|
892
|
+
<polygon points={points} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
|
|
893
|
+
{/* THE SAME TABLE `grabAt` HIT-TESTS, imported rather than restated: a
|
|
894
|
+
drawn handle the hit test does not answer, or an answered one nobody
|
|
895
|
+
drew, is the exact bug the hover cursor exists to make impossible. */}
|
|
896
|
+
{handles !== true
|
|
897
|
+
? null
|
|
898
|
+
: HANDLE_SITES.map((site, i) => {
|
|
899
|
+
const [x, y] = handlePoint(quad, site);
|
|
900
|
+
return (
|
|
901
|
+
<rect
|
|
902
|
+
key={i}
|
|
903
|
+
x={x - 3.5}
|
|
904
|
+
y={y - 3.5}
|
|
905
|
+
width={7}
|
|
906
|
+
height={7}
|
|
907
|
+
fill="var(--panel, #1c1c20)"
|
|
908
|
+
stroke="var(--accent, #3b82f6)"
|
|
909
|
+
strokeWidth={1.5}
|
|
910
|
+
/>
|
|
911
|
+
);
|
|
912
|
+
})}
|
|
913
|
+
</>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The one box around a multi-select.
|
|
919
|
+
*
|
|
920
|
+
* ── IT LOOKS DIFFERENT FROM A SELECTION OUTLINE ON PURPOSE ──────────────
|
|
921
|
+
*
|
|
922
|
+
* A dashed edge and solid handles, against the members' solid edges and (now)
|
|
923
|
+
* no handles. The two are different objects and offer different gestures: a
|
|
924
|
+
* member's outline says "this layer is selected", the box says "these grow
|
|
925
|
+
* together, about the corner opposite the one you grab". Drawing them alike
|
|
926
|
+
* would leave the author guessing which of nine outlines the handles belong to.
|
|
927
|
+
*
|
|
928
|
+
* The handles come off `HANDLE_SITES` like every other handle in the lane, so
|
|
929
|
+
* the box the hit test answers and the box the author sees are one table.
|
|
930
|
+
*/
|
|
931
|
+
function UnionBox({ quad }: { quad: ScreenQuad }) {
|
|
932
|
+
return (
|
|
933
|
+
<>
|
|
934
|
+
<polygon
|
|
935
|
+
points={pointsOf(quad)}
|
|
936
|
+
fill="none"
|
|
937
|
+
stroke="var(--accent, #3b82f6)"
|
|
938
|
+
strokeWidth={1}
|
|
939
|
+
strokeDasharray="4 3"
|
|
940
|
+
/>
|
|
941
|
+
{HANDLE_SITES.map((site, i) => {
|
|
942
|
+
const [x, y] = handlePoint(quad, site);
|
|
943
|
+
return (
|
|
944
|
+
<rect
|
|
945
|
+
key={i}
|
|
946
|
+
x={x - 3.5}
|
|
947
|
+
y={y - 3.5}
|
|
948
|
+
width={7}
|
|
949
|
+
height={7}
|
|
950
|
+
fill="var(--panel, #1c1c20)"
|
|
951
|
+
stroke="var(--accent, #3b82f6)"
|
|
952
|
+
strokeWidth={1.5}
|
|
953
|
+
/>
|
|
954
|
+
);
|
|
955
|
+
})}
|
|
956
|
+
</>
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* The path as it is being drawn, through THE SAME PRINTER the committed layer
|
|
962
|
+
* uses.
|
|
963
|
+
*
|
|
964
|
+
* `pathToD` and not a hand-rolled polyline: the preview has to show the curve
|
|
965
|
+
* the anchors actually describe, and a second printer here would be a second
|
|
966
|
+
* answer to "what does this path look like" — visibly wrong the moment a
|
|
967
|
+
* handle is pulled, which is exactly when the author is looking at it.
|
|
968
|
+
*
|
|
969
|
+
* The DOTS are an affordance rather than decoration. One anchor draws no
|
|
970
|
+
* segment, so without them the author cannot tell a pen that registered their
|
|
971
|
+
* click from one that did not — and the FIRST dot fills when a press there
|
|
972
|
+
* would close the loop, which is the only signal that the path can be ended
|
|
973
|
+
* without reaching for Enter. The tether to the cursor is paid for by a hover
|
|
974
|
+
* listener in StageApp; the earlier note here said it was not worth a
|
|
975
|
+
* per-move re-render, and drawing curves rather than a polyline changed that.
|
|
976
|
+
*/
|
|
977
|
+
function PenPreview({ pen }: { pen: ScreenPen }) {
|
|
978
|
+
const { points, cursor } = pen;
|
|
979
|
+
const last = points[points.length - 1];
|
|
980
|
+
return (
|
|
981
|
+
<g pointerEvents="none">
|
|
982
|
+
{points.length >= 2 ? (
|
|
983
|
+
<path d={pathToD({ points: [...points], closed: pen.closed })} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
|
|
984
|
+
) : null}
|
|
985
|
+
{/* The rubber band to the cursor, drawn as the segment it WOULD become:
|
|
986
|
+
curved when the anchor behind it carries an outgoing handle. */}
|
|
987
|
+
{last !== undefined && cursor !== null && !pen.closed ? (
|
|
988
|
+
<path
|
|
989
|
+
d={pathToD({ points: [last, { p: [cursor[0], cursor[1]] }], closed: false })}
|
|
990
|
+
fill="none"
|
|
991
|
+
stroke="var(--accent, #3b82f6)"
|
|
992
|
+
strokeWidth={1}
|
|
993
|
+
strokeDasharray="4 3"
|
|
994
|
+
opacity={0.7}
|
|
995
|
+
/>
|
|
996
|
+
) : null}
|
|
997
|
+
{/* Handles, so a pulled curve is legible as two arms and not just a bend. */}
|
|
998
|
+
{points.map((p, i) => (
|
|
999
|
+
<g key={`h${i}`}>
|
|
1000
|
+
{p.out === undefined ? null : (
|
|
1001
|
+
<line x1={p.p[0]} y1={p.p[1]} x2={p.out[0]} y2={p.out[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
|
|
1002
|
+
)}
|
|
1003
|
+
{p.in === undefined ? null : (
|
|
1004
|
+
<line x1={p.p[0]} y1={p.p[1]} x2={p.in[0]} y2={p.in[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
|
|
1005
|
+
)}
|
|
1006
|
+
</g>
|
|
1007
|
+
))}
|
|
1008
|
+
{points.map((p, i) => (
|
|
1009
|
+
<circle
|
|
1010
|
+
key={i}
|
|
1011
|
+
cx={p.p[0]}
|
|
1012
|
+
cy={p.p[1]}
|
|
1013
|
+
r={i === 0 && pen.closeHot ? 5 : 3}
|
|
1014
|
+
// A FILLED first anchor is the whole close affordance: the author
|
|
1015
|
+
// needs to know the next click ends the path before they make it.
|
|
1016
|
+
fill={i === 0 && pen.closeHot ? "var(--accent, #3b82f6)" : "var(--panel, #1c1c20)"}
|
|
1017
|
+
stroke="var(--accent, #3b82f6)"
|
|
1018
|
+
strokeWidth={1.5}
|
|
1019
|
+
/>
|
|
1020
|
+
))}
|
|
1021
|
+
</g>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function Marquee({ rect }: { rect: ScreenMarquee }) {
|
|
1026
|
+
const x = Math.min(rect.x0, rect.x1);
|
|
1027
|
+
const y = Math.min(rect.y0, rect.y1);
|
|
1028
|
+
return (
|
|
1029
|
+
<rect
|
|
1030
|
+
x={x}
|
|
1031
|
+
y={y}
|
|
1032
|
+
width={Math.abs(rect.x1 - rect.x0)}
|
|
1033
|
+
height={Math.abs(rect.y1 - rect.y0)}
|
|
1034
|
+
fill="var(--accent, #3b82f6)"
|
|
1035
|
+
fillOpacity={0.12}
|
|
1036
|
+
stroke="var(--accent, #3b82f6)"
|
|
1037
|
+
strokeWidth={1}
|
|
1038
|
+
/>
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function pointsOf(quad: ScreenQuad): string {
|
|
1043
|
+
const out: string[] = [];
|
|
1044
|
+
for (let i = 0; i < 8; i += 2) out.push(`${quad[i]},${quad[i + 1]}`);
|
|
1045
|
+
return out.join(" ");
|
|
1046
|
+
}
|