@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,49 @@
|
|
|
1
|
+
// Which views the stage shows: working, split, or camera.
|
|
2
|
+
//
|
|
3
|
+
// A FLOATING SIBLING of the stage, never a wrapper. Every panel in this shell
|
|
4
|
+
// is, and the reason is in App.tsx's header: the stage iframe must not remount
|
|
5
|
+
// and no ancestor of it may create a containing block.
|
|
6
|
+
//
|
|
7
|
+
// The three modes are a table for the same reason the tool dock's are — the
|
|
8
|
+
// glyph, the label and the key belong beside the mode they describe, so adding
|
|
9
|
+
// a fourth is one row and forgetting its shortcut is a compile error.
|
|
10
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
11
|
+
import type { StageLayout } from "../../bridgeTypes.js";
|
|
12
|
+
|
|
13
|
+
const MODES: ReadonlyArray<{
|
|
14
|
+
mode: StageLayout["mode"];
|
|
15
|
+
glyph: string;
|
|
16
|
+
label: string;
|
|
17
|
+
key: string;
|
|
18
|
+
}> = [
|
|
19
|
+
// Left to right in the order the digits are, and in the order the panes
|
|
20
|
+
// themselves sit: the working pane is on the left of a split.
|
|
21
|
+
{ mode: "working", glyph: "◱", label: "Working view", key: "1" },
|
|
22
|
+
{ mode: "split", glyph: "▥", label: "Split", key: "2" },
|
|
23
|
+
{ mode: "camera", glyph: "▣", label: "Camera view", key: "3" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function ViewModeBar() {
|
|
27
|
+
const store = useStore();
|
|
28
|
+
const layout = useShotEditor((s) => s.stageLayout);
|
|
29
|
+
const open = useShotEditor((s) => s.name !== null);
|
|
30
|
+
if (!open) return null;
|
|
31
|
+
return (
|
|
32
|
+
<div className="view-mode-bar" role="group" aria-label="View mode" data-testid="view-mode-bar">
|
|
33
|
+
{MODES.map(({ mode, glyph, label, key }) => (
|
|
34
|
+
<button
|
|
35
|
+
key={mode}
|
|
36
|
+
type="button"
|
|
37
|
+
className={`tool-button${layout.mode === mode ? " is-armed" : ""}`}
|
|
38
|
+
aria-pressed={layout.mode === mode}
|
|
39
|
+
title={`${label} (${key})`}
|
|
40
|
+
// The RATIO is carried through, not reset: flipping to the camera and
|
|
41
|
+
// back must return the split the author sized, not a fresh half.
|
|
42
|
+
onClick={() => store.setStageLayout({ ...layout, mode })}
|
|
43
|
+
>
|
|
44
|
+
{glyph}
|
|
45
|
+
</button>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/* The shot lane's palette, and the ONE place it is defined.
|
|
2
|
+
*
|
|
3
|
+
* ── WHY THIS FILE EXISTS ────────────────────────────────────────────────
|
|
4
|
+
*
|
|
5
|
+
* This lane spans TWO documents: the chrome, and the stage in its iframe.
|
|
6
|
+
* They are separate CSS worlds, so a palette declared in the chrome's
|
|
7
|
+
* stylesheet is invisible to the stage — which is how the selection quad, the
|
|
8
|
+
* tilt handles and the snap guides ended up drawing in the house rose while
|
|
9
|
+
* every control around them was blue. The overlays were not wrong; they were
|
|
10
|
+
* reading a token nobody had overridden for them.
|
|
11
|
+
*
|
|
12
|
+
* So the palette lives here, both documents import it, and there is exactly
|
|
13
|
+
* one definition to change. This mirrors the split tokens.css/styles.css
|
|
14
|
+
* already models in @dailies-studio/ui: the stage takes the PALETTE and none
|
|
15
|
+
* of the widget rules.
|
|
16
|
+
*
|
|
17
|
+
* ── WHY IT OVERRIDES THE HOUSE ACCENT ───────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* This lane is the Raylight clone, and Raylight's editor is near-black with a
|
|
20
|
+
* BLUE accent used sparingly: one primary button, the playhead, the armed
|
|
21
|
+
* tool. The house rose reads as an alert colour, and applied at once to every
|
|
22
|
+
* toggle, tab, scene bar and selection outline it made the editor look like a
|
|
23
|
+
* standing error state. The override is scoped to this lane; motion, canvas,
|
|
24
|
+
* scene and gallery keep the house palette untouched.
|
|
25
|
+
*/
|
|
26
|
+
:root {
|
|
27
|
+
/* NOT aliased to --font. That token is a `font:` SHORTHAND
|
|
28
|
+
(`12px/1.45 system-ui, …`), so feeding it to `font-family` is an invalid
|
|
29
|
+
declaration the browser drops, which lands the whole document on the
|
|
30
|
+
default serif. Rules that want a family use this; rules that want the
|
|
31
|
+
whole shorthand use `font: var(--font)`. */
|
|
32
|
+
--font-ui: system-ui, "Segoe UI", sans-serif;
|
|
33
|
+
|
|
34
|
+
/* Surfaces, darkest to lightest. Raylight sits the panels a shade off pure
|
|
35
|
+
black and the controls a shade above the panels, so a field reads as a
|
|
36
|
+
recess without needing a border on every side. */
|
|
37
|
+
--bg: #000000;
|
|
38
|
+
--panel: #0c0c0e;
|
|
39
|
+
--panel-raised: #141417;
|
|
40
|
+
--control: #1b1b1f;
|
|
41
|
+
--control-hover: #26262c;
|
|
42
|
+
--input-bg: #1b1b1f;
|
|
43
|
+
|
|
44
|
+
--border: #232328;
|
|
45
|
+
--border-soft: #1a1a1e;
|
|
46
|
+
--text: #f2f2f5;
|
|
47
|
+
--text-dim: #8e8e97;
|
|
48
|
+
--text-faint: #5d5d66;
|
|
49
|
+
|
|
50
|
+
--accent: #0a84ff;
|
|
51
|
+
--accent-hover: #3d9dff;
|
|
52
|
+
--accent-soft: rgba(10, 132, 255, 0.16);
|
|
53
|
+
--accent-quiet: rgba(10, 132, 255, 0.12);
|
|
54
|
+
--accent-warm: #f5a524;
|
|
55
|
+
--danger: #ff4d4f;
|
|
56
|
+
--danger-text: #ff8a8c;
|
|
57
|
+
--warn: #f5a524;
|
|
58
|
+
|
|
59
|
+
/* The stage's own vocabulary. All three were undefined, so the overlays ran
|
|
60
|
+
on their hardcoded fallbacks: red guides and a grey frame border that
|
|
61
|
+
read as a stray rectangle rather than as the exportable edge.
|
|
62
|
+
|
|
63
|
+
A SNAP GUIDE IS DELIBERATELY NOT THE ACCENT. It appears while a drag is
|
|
64
|
+
in flight, on top of a selection already drawn in accent, and two
|
|
65
|
+
identical colours mean the author cannot tell the thing they are moving
|
|
66
|
+
from the thing they are aligning to. */
|
|
67
|
+
--guide: #f5a524;
|
|
68
|
+
--frame-border: rgba(255, 255, 255, 0.22);
|
|
69
|
+
--marquee: var(--accent);
|
|
70
|
+
|
|
71
|
+
/* The shot camera drawn as an object in the working pane, and the badge that
|
|
72
|
+
says the camera is following that pane. A YELLOW, which is the one hue this
|
|
73
|
+
lane's palette does not already spend: the accent is the selection, the
|
|
74
|
+
guide amber is a live snap, and a camera gizmo drawn in either would read
|
|
75
|
+
as "you have selected this" or "this is snapping". Both documents need it —
|
|
76
|
+
the gizmo is a GL material in the stage, the badge is SVG in the same
|
|
77
|
+
document — so it lives here with the rest of the palette. */
|
|
78
|
+
--frustum: #eab308;
|
|
79
|
+
|
|
80
|
+
/* The working pane's placement aids: the ground grid, and the three DESIGN
|
|
81
|
+
axes shared by the drag gizmo and the corner nav ball.
|
|
82
|
+
|
|
83
|
+
RED / GREEN / BLUE FOR X / Y / Z is the one convention in this whole
|
|
84
|
+
palette that is not ours to pick. Every 3D tool an author has used draws
|
|
85
|
+
the axes this way, and a lane-consistent hue would cost them the one
|
|
86
|
+
thing the gizmo exists to say at a glance. They are needed in both halves —
|
|
87
|
+
GL materials in the stage, SVG in the same document — so they live here.
|
|
88
|
+
|
|
89
|
+
The grid is nearly the panel colour on purpose: it is furniture under a
|
|
90
|
+
picture being judged, and a bright floor competes with the shot. */
|
|
91
|
+
--grid: #2a2a31;
|
|
92
|
+
--axis-x: #ef4444;
|
|
93
|
+
--axis-y: #22c55e;
|
|
94
|
+
--axis-z: #3b82f6;
|
|
95
|
+
|
|
96
|
+
/* The seam between the two stage panes. Deliberately `--border` and not the
|
|
97
|
+
accent: it is furniture, drawn over a picture the author is judging, and
|
|
98
|
+
the moment it is bright it competes with the shot. Defined here rather than
|
|
99
|
+
in the chrome's stylesheet because the SVG that draws it lives in the STAGE
|
|
100
|
+
document, which inherits nothing from the chrome. */
|
|
101
|
+
--stage-divider: var(--border);
|
|
102
|
+
|
|
103
|
+
/* The timeline vocabulary. Raylight draws a block as a DARK FILL WITH A
|
|
104
|
+
BRIGHT BORDER rather than a solid bar, which keeps forty overlapping
|
|
105
|
+
blocks legible instead of a wall of colour and gives every lane its own
|
|
106
|
+
hue. One hue per lane defines both halves. */
|
|
107
|
+
--block-in: #38bdf8;
|
|
108
|
+
--block-during: #a78bfa;
|
|
109
|
+
--block-out: #f472b6;
|
|
110
|
+
--scene-bar: #34d399;
|
|
111
|
+
--playhead: var(--accent);
|
|
112
|
+
|
|
113
|
+
/* The audio lane. A distinct hue from every block slot AND from the scene
|
|
114
|
+
bar, because the whole point of putting the score under the video is that
|
|
115
|
+
the author can find it without reading a label: an audio clip that shared
|
|
116
|
+
the During purple would read as one more animation block. Teal is the one
|
|
117
|
+
band left between the scene green and the In blue. */
|
|
118
|
+
--audio-clip: #2dd4bf;
|
|
119
|
+
/* The gain rubber band and the fade ramps, which are drawn ON the clip and
|
|
120
|
+
must stay readable over the waveform under them. */
|
|
121
|
+
--audio-line: #fef08a;
|
|
122
|
+
|
|
123
|
+
/* The marker strip: an ORANGE band carrying WHITE diamonds, which is the
|
|
124
|
+
reference editor's own pairing and is not arbitrary. A marker is a note
|
|
125
|
+
rather than a thing on screen, so it must not read as one more bar in the
|
|
126
|
+
block vocabulary — orange is the one hue no lane above it uses, and a white
|
|
127
|
+
diamond on it is legible at the 8px the strip is drawn at, where a hue-on-
|
|
128
|
+
hue fill is a smudge. `MarkerRef.color` overrides the diamond only. */
|
|
129
|
+
--marker: #f97316;
|
|
130
|
+
--marker-pin: #ffffff;
|
|
131
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The shot camera, drawn in the working pane as the object it is.
|
|
2
|
+
//
|
|
3
|
+
// ── EDITOR-ONLY, BY CONSTRUCTION AND NOT BY CONVENTION ──────────────────
|
|
4
|
+
//
|
|
5
|
+
// Three separate things keep this out of the picture the author is judging, and
|
|
6
|
+
// each of them fails safe on its own:
|
|
7
|
+
//
|
|
8
|
+
// 1. `layers.set(EDITOR_LAYER)` — bit 1 ONLY. The shot camera's mask is bit 0,
|
|
9
|
+
// so it cannot see this object at all; the working camera enables bit 1.
|
|
10
|
+
// 2. `SplitRenderer` pins its post pass to `new Layers()` (bit 0), so even a
|
|
11
|
+
// camera that somehow enabled bit 1 could not get the gizmo into the
|
|
12
|
+
// graded image.
|
|
13
|
+
// 3. It carries no `SHOT_LAYER_KEY` userData, so `collectFrames` never sees
|
|
14
|
+
// it and layer picking cannot return it.
|
|
15
|
+
//
|
|
16
|
+
// It also touches no document. Everything it draws is a pure function of the
|
|
17
|
+
// resolved rig, which is a pure function of (document, frame).
|
|
18
|
+
//
|
|
19
|
+
// ── WHY THE MATERIAL REFUSES THE OVERRIDE ───────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// The working pane renders with `scene.overrideMaterial = clay`. three applies
|
|
22
|
+
// an override to every material whose `allowOverride` is true — three's own
|
|
23
|
+
// Background sets it false for exactly this reason — so without that flag the
|
|
24
|
+
// gizmo would come out clay grey, which is the one colour it must not be.
|
|
25
|
+
//
|
|
26
|
+
// ── AND WHY THE COLOUR IS READ FROM CSS ─────────────────────────────────
|
|
27
|
+
//
|
|
28
|
+
// It is a MATERIAL colour, not a GL clear colour, and it is resolved once at
|
|
29
|
+
// mount inside the stage document — which imports shotTokens.css. So `--frustum`
|
|
30
|
+
// stays the single definition both documents share, and the overlay could draw
|
|
31
|
+
// the same yellow tomorrow without a second literal. (SplitRenderer's clay is
|
|
32
|
+
// hard-coded for the opposite reason: a clear colour is written before any
|
|
33
|
+
// style has resolved.)
|
|
34
|
+
import { useEffect, useMemo } from "react";
|
|
35
|
+
import { useFrame } from "@react-three/fiber";
|
|
36
|
+
import {
|
|
37
|
+
BufferAttribute,
|
|
38
|
+
BufferGeometry,
|
|
39
|
+
LineSegments,
|
|
40
|
+
Vector3,
|
|
41
|
+
} from "three";
|
|
42
|
+
import { LineBasicNodeMaterial } from "three/webgpu";
|
|
43
|
+
import { EDITOR_LAYER } from "./StageTools.js";
|
|
44
|
+
import { tokenColor } from "./tokenColor.js";
|
|
45
|
+
import {
|
|
46
|
+
FRUSTUM_SEGMENT_FLOATS,
|
|
47
|
+
borderDistance,
|
|
48
|
+
frustumSegments,
|
|
49
|
+
frustumShape,
|
|
50
|
+
segmentsMoved,
|
|
51
|
+
} from "./frustumGeometry.js";
|
|
52
|
+
import type { CameraRig } from "./frustumGeometry.js";
|
|
53
|
+
import { toScreen } from "./project.js";
|
|
54
|
+
import type { StageFeed } from "./feed.js";
|
|
55
|
+
import type { PaneRect } from "./panes.js";
|
|
56
|
+
import type { FrameSize } from "./view.js";
|
|
57
|
+
|
|
58
|
+
/** How close a press has to land to the apex to grab it, in CSS px. Larger
|
|
59
|
+
* than the layer handles' 10: the apex is a single point with no box around
|
|
60
|
+
* it, and it is the only way to reach the zoom. */
|
|
61
|
+
export const APEX_GRAB_PX = 14;
|
|
62
|
+
|
|
63
|
+
/** How close a press has to land to the base rectangle's DRAWN border to grab
|
|
64
|
+
* the body. The interior is deliberately not a grab target — see
|
|
65
|
+
* `borderDistance` for why an interior claim takes the whole pane away — so
|
|
66
|
+
* the border is a line, and it gets the same slop the apex point does. */
|
|
67
|
+
const BODY_GRAB_PX = 14;
|
|
68
|
+
|
|
69
|
+
/** Over everything. Every material in this lane draws with `depthTest: false`,
|
|
70
|
+
* so paint order is `renderOrder` alone (ShotRuntime's header) — a gizmo that
|
|
71
|
+
* shared the layers' range would be hidden by whichever layer sorted after it. */
|
|
72
|
+
const FRUSTUM_RENDER_ORDER = 1_000_000;
|
|
73
|
+
|
|
74
|
+
const FRUSTUM_FALLBACK = "#eab308";
|
|
75
|
+
|
|
76
|
+
/** Scratch for the border test's behind-camera check. */
|
|
77
|
+
const _view = new Vector3();
|
|
78
|
+
|
|
79
|
+
export interface FrustumGizmoProps {
|
|
80
|
+
feed: StageFeed;
|
|
81
|
+
panes: { working: PaneRect | null; camera: PaneRect | null };
|
|
82
|
+
/** The camera as resolved at the playhead. Null when no scene is on screen. */
|
|
83
|
+
rig: CameraRig | null;
|
|
84
|
+
frame: FrameSize;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function FrustumGizmo({ feed, panes, rig, frame }: FrustumGizmoProps) {
|
|
88
|
+
const gizmo = useMemo(() => {
|
|
89
|
+
const geometry = new BufferGeometry();
|
|
90
|
+
geometry.setAttribute(
|
|
91
|
+
"position",
|
|
92
|
+
new BufferAttribute(new Float32Array(FRUSTUM_SEGMENT_FLOATS), 3),
|
|
93
|
+
);
|
|
94
|
+
const material = new LineBasicNodeMaterial();
|
|
95
|
+
material.color.copy(tokenColor("--frustum", FRUSTUM_FALLBACK));
|
|
96
|
+
material.depthTest = false;
|
|
97
|
+
material.depthWrite = false;
|
|
98
|
+
material.toneMapped = false;
|
|
99
|
+
// See the header: the working pane renders under a clay override.
|
|
100
|
+
material.allowOverride = false;
|
|
101
|
+
|
|
102
|
+
const lines = new LineSegments(geometry, material);
|
|
103
|
+
lines.layers.set(EDITOR_LAYER);
|
|
104
|
+
lines.renderOrder = FRUSTUM_RENDER_ORDER;
|
|
105
|
+
// The vertices are WORLD positions rewritten in place, so the object stays
|
|
106
|
+
// at the origin and the bounding sphere the culler would test is never the
|
|
107
|
+
// one the buffer holds. Culling it against a stale sphere makes the gizmo
|
|
108
|
+
// vanish at some orbits and not others.
|
|
109
|
+
lines.frustumCulled = false;
|
|
110
|
+
lines.matrixAutoUpdate = false;
|
|
111
|
+
return { lines, geometry, material, apex: new Vector3(), quad: newQuad() };
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
useEffect(
|
|
115
|
+
() => () => {
|
|
116
|
+
gizmo.geometry.dispose();
|
|
117
|
+
gizmo.material.dispose();
|
|
118
|
+
},
|
|
119
|
+
[gizmo],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
useFrame(() => {
|
|
123
|
+
const working = panes.working;
|
|
124
|
+
if (rig === null) {
|
|
125
|
+
gizmo.lines.visible = false;
|
|
126
|
+
feed.frustumHit = null;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
gizmo.lines.visible = true;
|
|
130
|
+
|
|
131
|
+
const shape = frustumShape(rig, frame);
|
|
132
|
+
const next = frustumSegments(shape);
|
|
133
|
+
const attribute = gizmo.geometry.getAttribute("position") as BufferAttribute;
|
|
134
|
+
const buffer = attribute.array as Float32Array;
|
|
135
|
+
// The 0.5px gate `quadMoved` uses, for the same reason: this runs on every
|
|
136
|
+
// committed frame, and a parked playhead must not re-upload a buffer.
|
|
137
|
+
if (segmentsMoved(buffer, next)) {
|
|
138
|
+
buffer.set(next);
|
|
139
|
+
attribute.needsUpdate = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The hit-test geometry follows every frame regardless of that gate: half a
|
|
143
|
+
// pixel of drift is invisible in the drawing and is still the difference
|
|
144
|
+
// between grabbing the apex and missing it.
|
|
145
|
+
gizmo.apex.set(shape.apex[0], shape.apex[1], shape.apex[2]);
|
|
146
|
+
for (let i = 0; i < 4; i++) {
|
|
147
|
+
gizmo.quad[i]!.set(shape.corners[i]![0], shape.corners[i]![1], shape.corners[i]![2]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
feed.frustumHit = (x, y) => {
|
|
151
|
+
const camera = feed.workingCamera;
|
|
152
|
+
if (camera === null || working === null) return null;
|
|
153
|
+
// THE APEX FIRST, and in SCREEN px: it is a point, so a world-space
|
|
154
|
+
// radius would be a grab target that shrinks as the author dollies out
|
|
155
|
+
// until it cannot be hit at all.
|
|
156
|
+
const [ax, ay] = toScreen(gizmo.apex, camera, working.width, working.height);
|
|
157
|
+
if (Math.hypot(ax - x, ay - y) <= APEX_GRAB_PX) return "apex";
|
|
158
|
+
// THE BODY BY ITS BORDER, in SCREEN px like the apex and for the same
|
|
159
|
+
// reason. A corner behind the working camera projects to garbage, so a
|
|
160
|
+
// quad that is not wholly in front answers null — its border is not on
|
|
161
|
+
// screen to be grabbed, and the press falls through to the orbit.
|
|
162
|
+
const corners: [number, number][] = [];
|
|
163
|
+
for (const corner of gizmo.quad) {
|
|
164
|
+
_view.copy(corner).applyMatrix4(camera.matrixWorldInverse);
|
|
165
|
+
if (_view.z >= 0) return null;
|
|
166
|
+
corners.push(toScreen(corner, camera, working.width, working.height));
|
|
167
|
+
}
|
|
168
|
+
return borderDistance(x, y, corners) <= BODY_GRAB_PX ? "body" : null;
|
|
169
|
+
};
|
|
170
|
+
}, 0);
|
|
171
|
+
|
|
172
|
+
useEffect(
|
|
173
|
+
() => () => {
|
|
174
|
+
feed.frustumHit = null;
|
|
175
|
+
},
|
|
176
|
+
[feed],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return <primitive object={gizmo.lines} />;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function newQuad(): [Vector3, Vector3, Vector3, Vector3] {
|
|
183
|
+
return [new Vector3(), new Vector3(), new Vector3(), new Vector3()];
|
|
184
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The working pane's ground plane.
|
|
2
|
+
//
|
|
3
|
+
// Editor-only by exactly the three constructions FrustumGizmo's header states —
|
|
4
|
+
// `layers.set(EDITOR_LAYER)`, SplitRenderer's post pass pinned to bit 0, and no
|
|
5
|
+
// `SHOT_LAYER_KEY` userData — so it cannot reach the graded image, the camera
|
|
6
|
+
// pane or a pick. It reads no document and writes nothing.
|
|
7
|
+
//
|
|
8
|
+
// ── WHY IT SITS UNDER THE FRAME AND NOT THROUGH THE ORIGIN ──────────────
|
|
9
|
+
//
|
|
10
|
+
// Blender's grid is at the world origin, and here that origin is the CENTRE OF
|
|
11
|
+
// THE CANVAS — the one place the artwork always is. A grid there slices every
|
|
12
|
+
// shot in half with a bright horizontal line and reads as a mistake.
|
|
13
|
+
//
|
|
14
|
+
// So the floor is at the frame's BOTTOM EDGE (design y = +height/2, which the
|
|
15
|
+
// one mapping makes world y = -height/2), and the canvas stands on it. That
|
|
16
|
+
// gives the pane the thing an orbit view is missing — a horizon and a sense of
|
|
17
|
+
// which way is down — without drawing anything across the picture.
|
|
18
|
+
//
|
|
19
|
+
// ── WHY IT IS OPAQUE AND NOT A DIMMED TRANSPARENT ───────────────────────
|
|
20
|
+
//
|
|
21
|
+
// Every material in this lane draws with `depthTest: false`, so paint order is
|
|
22
|
+
// `renderOrder` alone (ShotRuntime's header) — and three sorts the TRANSPARENT
|
|
23
|
+
// list after the whole opaque one, whatever their render orders say. A
|
|
24
|
+
// translucent grid would therefore draw ON TOP of an opaque mesh layer,
|
|
25
|
+
// however negative its order. Opaque and dim is the same look with none of
|
|
26
|
+
// that: it lands first in the opaque list and everything else covers it.
|
|
27
|
+
//
|
|
28
|
+
// It is also why the fade below mixes towards the BACKDROP COLOUR rather than
|
|
29
|
+
// towards alpha, which is the usual way to end an infinite grid.
|
|
30
|
+
//
|
|
31
|
+
// ── AND WHY IT ENDS IN A FADE RATHER THAN AT AN EDGE ────────────────────
|
|
32
|
+
//
|
|
33
|
+
// A ground plane is not bounded by what the camera can see: look along it and
|
|
34
|
+
// it runs to a horizon. So a patch sized against the visible height is a patch
|
|
35
|
+
// the author finds the end of the moment they orbit low, which is what 24
|
|
36
|
+
// cells did. `GRID_CELLS` now outreaches the fade at every legal dolly
|
|
37
|
+
// (`gridExtent` vs `gridFade`, swept in `gizmoMath.test.ts`), and the lines
|
|
38
|
+
// dissolve into the backdrop before the geometry runs out — so what the pane
|
|
39
|
+
// shows is a horizon, and the edge is unreachable rather than merely far.
|
|
40
|
+
//
|
|
41
|
+
// ── AND WHY THE AXES LIE IN IT RATHER THAN AT THE ORIGIN ────────────────
|
|
42
|
+
//
|
|
43
|
+
// Blender's axes lie in its grid because its origin is ON the ground. Ours is
|
|
44
|
+
// not: the world origin is the canvas CENTRE, half a frame height above the
|
|
45
|
+
// floor. Axes drawn there put a red and a blue line in mid-air over the floor
|
|
46
|
+
// with nothing relating the two aids, which reads as a bug rather than as a
|
|
47
|
+
// pair of placement aids.
|
|
48
|
+
//
|
|
49
|
+
// So all three cross at the point the canvas STANDS on — (0, floor, 0) —
|
|
50
|
+
// which is the same reading Blender gives. Each line is still infinite along
|
|
51
|
+
// its own direction and still marks its own zero on the other two components;
|
|
52
|
+
// only the shared crossing moved. They draw one render order ABOVE the floor
|
|
53
|
+
// so they cross the gridlines rather than fight them, and still a million
|
|
54
|
+
// below every layer, so the artwork covers both.
|
|
55
|
+
import { useEffect, useMemo } from "react";
|
|
56
|
+
import { BufferAttribute, BufferGeometry, LineSegments } from "three";
|
|
57
|
+
import { LineBasicNodeMaterial } from "three/webgpu";
|
|
58
|
+
import { cameraPosition, color, length, mix, positionWorld, smoothstep, uniform } from "three/tsl";
|
|
59
|
+
import { EDITOR_LAYER } from "./StageTools.js";
|
|
60
|
+
import { CLAY_BACKDROP } from "./SplitRenderer.js";
|
|
61
|
+
import { WORKING_FOV_DEG } from "./workingView.js";
|
|
62
|
+
import { GRID_CELLS, axisOrigin, axisPositions, gridFade, gridPositions, gridStep } from "./gizmoMath.js";
|
|
63
|
+
import { tokenColor } from "./tokenColor.js";
|
|
64
|
+
import type { FrameSize } from "./view.js";
|
|
65
|
+
|
|
66
|
+
/** Behind everything. The mirror of the frustum's +1_000_000, for the reason
|
|
67
|
+
* its own comment gives: with depth testing off, order is the only answer to
|
|
68
|
+
* "what covers what". */
|
|
69
|
+
const GRID_RENDER_ORDER = -1_000_000;
|
|
70
|
+
|
|
71
|
+
/** One above the floor and still a million below the artwork: the axes cross
|
|
72
|
+
* the grid lines rather than fighting them, and any layer covers both. */
|
|
73
|
+
const AXIS_RENDER_ORDER = GRID_RENDER_ORDER + 1;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Half-length of each world axis.
|
|
77
|
+
*
|
|
78
|
+
* Longer than any legal camera can see (the dolly band tops out around 42k
|
|
79
|
+
* design px of distance for a 1080-tall shot) and the segment RECENTRES along
|
|
80
|
+
* its own direction with the floor, so no pan finds an end either. Translating
|
|
81
|
+
* a line along itself is the identity on the infinite line, which is why this
|
|
82
|
+
* needs no snap quantum the way the grid does.
|
|
83
|
+
*/
|
|
84
|
+
const AXIS_EXTENT = 100_000;
|
|
85
|
+
|
|
86
|
+
const GRID_FALLBACK = "#2a2a31";
|
|
87
|
+
|
|
88
|
+
/** World axis index -> token. The one mapping (`world = (x, -y, -z)`) is a sign
|
|
89
|
+
* flip, and a line through the origin is invariant under it, so the world axes
|
|
90
|
+
* ARE the design axes here and these are the nav gizmo's own colours. */
|
|
91
|
+
const AXES = [
|
|
92
|
+
{ axis: 0 as const, token: "--axis-x", fallback: "#ef4444" },
|
|
93
|
+
{ axis: 1 as const, token: "--axis-y", fallback: "#22c55e" },
|
|
94
|
+
{ axis: 2 as const, token: "--axis-z", fallback: "#3b82f6" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export function GridPlane({
|
|
98
|
+
frame,
|
|
99
|
+
target,
|
|
100
|
+
distance,
|
|
101
|
+
}: {
|
|
102
|
+
frame: FrameSize;
|
|
103
|
+
target: readonly [number, number, number];
|
|
104
|
+
/** The working camera's eye-to-target distance. Drives the spacing through
|
|
105
|
+
* `gridStep`'s ladder, so a dolly changes the geometry in jumps, and the
|
|
106
|
+
* fade band continuously, which is a uniform write and no rebuild. */
|
|
107
|
+
distance: number;
|
|
108
|
+
}) {
|
|
109
|
+
const step = gridStep(frame.height, distance, WORKING_FOV_DEG);
|
|
110
|
+
const height = -frame.height / 2;
|
|
111
|
+
|
|
112
|
+
const grid = useMemo(() => {
|
|
113
|
+
const geometry = new BufferGeometry();
|
|
114
|
+
geometry.setAttribute(
|
|
115
|
+
"position",
|
|
116
|
+
new BufferAttribute(new Float32Array(gridPositions(step, GRID_CELLS, height)), 3),
|
|
117
|
+
);
|
|
118
|
+
const material = new LineBasicNodeMaterial();
|
|
119
|
+
// ── THE FADE, AND WHY IT IS A COLOUR AND NOT AN ALPHA ─────────────────
|
|
120
|
+
//
|
|
121
|
+
// The obvious infinite grid fades its lines to transparent. This lane
|
|
122
|
+
// cannot: three sorts the whole TRANSPARENT list after the whole opaque
|
|
123
|
+
// one whatever their render orders say (see the header), so the moment
|
|
124
|
+
// this material turns transparent the floor draws OVER every opaque mesh
|
|
125
|
+
// layer in its bin instead of a million orders below them.
|
|
126
|
+
//
|
|
127
|
+
// Mixing towards the backdrop COLOUR is the same picture with none of
|
|
128
|
+
// that. At the far end the line is exactly the pane's clear colour, which
|
|
129
|
+
// is invisible without ever leaving the opaque list.
|
|
130
|
+
//
|
|
131
|
+
// The one place it is approximate is `rendered` shading, where the pane
|
|
132
|
+
// clears with the SHOT's background rather than the clay. The grid
|
|
133
|
+
// already draws over that background at full strength today, so a fade
|
|
134
|
+
// towards clay there is a softer version of what is drawn now, not a new
|
|
135
|
+
// disagreement.
|
|
136
|
+
const fadeStart = uniform(0);
|
|
137
|
+
const fadeEnd = uniform(1);
|
|
138
|
+
material.colorNode = mix(
|
|
139
|
+
color(tokenColor("--grid", GRID_FALLBACK)),
|
|
140
|
+
color(CLAY_BACKDROP),
|
|
141
|
+
smoothstep(fadeStart, fadeEnd, length(positionWorld.sub(cameraPosition))),
|
|
142
|
+
);
|
|
143
|
+
material.depthTest = false;
|
|
144
|
+
material.depthWrite = false;
|
|
145
|
+
material.toneMapped = false;
|
|
146
|
+
// The working pane renders under a clay override in solid/wireframe mode;
|
|
147
|
+
// without this the grid comes out the same grey as the layers.
|
|
148
|
+
material.allowOverride = false;
|
|
149
|
+
|
|
150
|
+
const lines = new LineSegments(geometry, material);
|
|
151
|
+
lines.layers.set(EDITOR_LAYER);
|
|
152
|
+
lines.renderOrder = GRID_RENDER_ORDER;
|
|
153
|
+
// The vertices are WORLD positions, so the object stays at the origin and
|
|
154
|
+
// its bounding sphere is not the one the buffer holds — culling against a
|
|
155
|
+
// stale sphere makes the grid vanish at some orbits and not others.
|
|
156
|
+
lines.frustumCulled = false;
|
|
157
|
+
lines.matrixAutoUpdate = false;
|
|
158
|
+
return { lines, geometry, material, fadeStart, fadeEnd };
|
|
159
|
+
// Rebuilt when the shot's size changes (a document switch or a resolution
|
|
160
|
+
// edit) or when the dolly crosses a ladder rung — never within a rung, so a
|
|
161
|
+
// continuous dolly does no geometry work. The buffer stays static and the
|
|
162
|
+
// OBJECT moves; see the follow effect below.
|
|
163
|
+
}, [step, height]);
|
|
164
|
+
|
|
165
|
+
// The three world axes. Built ONCE — their geometry has nothing to do with
|
|
166
|
+
// the grid's spacing, and their extent is already past every legal view.
|
|
167
|
+
//
|
|
168
|
+
// Three objects with three materials rather than one buffer with vertex
|
|
169
|
+
// colours: `MoveGizmo` states the same choice for the same reason, that a
|
|
170
|
+
// node material's colour is a uniform and per-vertex colour on this stack
|
|
171
|
+
// costs a node graph the arms do not need. Three tiny draws is the cheaper
|
|
172
|
+
// half of that trade.
|
|
173
|
+
const axes = useMemo(
|
|
174
|
+
() =>
|
|
175
|
+
AXES.map(({ axis, token, fallback }) => {
|
|
176
|
+
const geometry = new BufferGeometry();
|
|
177
|
+
geometry.setAttribute(
|
|
178
|
+
"position",
|
|
179
|
+
new BufferAttribute(new Float32Array(axisPositions(axis, AXIS_EXTENT)), 3),
|
|
180
|
+
);
|
|
181
|
+
const material = new LineBasicNodeMaterial();
|
|
182
|
+
material.color.copy(tokenColor(token, fallback));
|
|
183
|
+
material.depthTest = false;
|
|
184
|
+
material.depthWrite = false;
|
|
185
|
+
material.toneMapped = false;
|
|
186
|
+
// Escapes the clay/wire override, like the grid and the gizmo arms.
|
|
187
|
+
material.allowOverride = false;
|
|
188
|
+
|
|
189
|
+
const lines = new LineSegments(geometry, material);
|
|
190
|
+
lines.layers.set(EDITOR_LAYER);
|
|
191
|
+
lines.renderOrder = AXIS_RENDER_ORDER;
|
|
192
|
+
lines.frustumCulled = false;
|
|
193
|
+
lines.matrixAutoUpdate = false;
|
|
194
|
+
return { axis, lines, geometry, material };
|
|
195
|
+
}),
|
|
196
|
+
[],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// ── THE GRID FOLLOWS THE AUTHOR ─────────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// It was 24 cells nailed to the world origin, so panning far enough left it
|
|
202
|
+
// behind entirely and the pane lost its floor — the one thing an orbit view
|
|
203
|
+
// has to say which way is down. Blender's grid is infinite; ours moves.
|
|
204
|
+
//
|
|
205
|
+
// SNAPPED TO A WHOLE CELL, which is what makes a moving grid read as a fixed
|
|
206
|
+
// one: the lines land on the same design coordinates whatever the target is,
|
|
207
|
+
// so nothing appears to slide underneath the artwork. Only x and z, because
|
|
208
|
+
// the floor's height is the frame's own bottom edge and must not drift.
|
|
209
|
+
//
|
|
210
|
+
// The MATRIX moves, not the buffer. `matrixAutoUpdate` is off, so this is one
|
|
211
|
+
// matrix write on the frames where the target actually crossed a cell.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const snap = (n: number): number => Math.round(n / step) * step;
|
|
214
|
+
grid.lines.position.set(snap(target[0]), 0, snap(target[2]));
|
|
215
|
+
grid.lines.updateMatrix();
|
|
216
|
+
}, [grid, step, target]);
|
|
217
|
+
|
|
218
|
+
// ── AND THE FADE FOLLOWS THE DOLLY ──────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// Two uniform writes, not a rebuild: the fade band is CONTINUOUS in the
|
|
221
|
+
// distance while the spacing is quantised to a ladder rung, so this runs on
|
|
222
|
+
// every dolly frame and the geometry memo above still runs on almost none.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const [start, end] = gridFade(frame.height, distance, WORKING_FOV_DEG);
|
|
225
|
+
grid.fadeStart.value = start;
|
|
226
|
+
grid.fadeEnd.value = end;
|
|
227
|
+
}, [grid, frame.height, distance]);
|
|
228
|
+
|
|
229
|
+
// ── AND SO DO THE AXES, ALONG THEMSELVES ────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// Each axis follows the target on its OWN component only. That is an exact
|
|
232
|
+
// identity on the infinite line the segment stands for — the drawn set of
|
|
233
|
+
// points does not move at all — so it needs no snapping and can never appear
|
|
234
|
+
// to slide. What it buys is that the finite segment stays centred on the
|
|
235
|
+
// author, so no pan, however far, reaches an end.
|
|
236
|
+
//
|
|
237
|
+
// The OTHER components are the placement above: the floor's height for the
|
|
238
|
+
// two horizontal axes, zero for the vertical one, which crosses the floor at
|
|
239
|
+
// that same point without being told to.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
for (const a of axes) {
|
|
242
|
+
const [x, y, z] = axisOrigin(a.axis, target, height);
|
|
243
|
+
a.lines.position.set(x, y, z);
|
|
244
|
+
a.lines.updateMatrix();
|
|
245
|
+
}
|
|
246
|
+
}, [axes, target, height]);
|
|
247
|
+
|
|
248
|
+
useEffect(
|
|
249
|
+
() => () => {
|
|
250
|
+
grid.geometry.dispose();
|
|
251
|
+
grid.material.dispose();
|
|
252
|
+
},
|
|
253
|
+
[grid],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
useEffect(
|
|
257
|
+
() => () => {
|
|
258
|
+
for (const a of axes) {
|
|
259
|
+
a.geometry.dispose();
|
|
260
|
+
a.material.dispose();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[axes],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<>
|
|
268
|
+
<primitive object={grid.lines} />
|
|
269
|
+
{axes.map((a) => (
|
|
270
|
+
<primitive key={a.axis} object={a.lines} />
|
|
271
|
+
))}
|
|
272
|
+
</>
|
|
273
|
+
);
|
|
274
|
+
}
|