@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,121 @@
|
|
|
1
|
+
// The stage's two panes, as pure rectangles.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THE PANES ARE ARITHMETIC AND NOT TWO ELEMENTS ───────────────────
|
|
4
|
+
//
|
|
5
|
+
// The split view is ONE canvas drawn twice under a scissor. Not two canvases:
|
|
6
|
+
// a second GL context doubles a budget this lane already owns outright (the
|
|
7
|
+
// shot runtime owns the present, see the lane note in CLAUDE.md). Not two
|
|
8
|
+
// iframes: the stage iframe may never remount. So the pane boundary exists only
|
|
9
|
+
// as numbers, and it has exactly two readers — the renderer's scissor rect and
|
|
10
|
+
// the pointer lane's hit test.
|
|
11
|
+
//
|
|
12
|
+
// They must read the SAME numbers. If the divider the renderer draws to and the
|
|
13
|
+
// divider the pointer tests against differ by even one column, a press in that
|
|
14
|
+
// column is answered by the other pane's camera, and the symptom is "picking is
|
|
15
|
+
// off by half a screen" rather than anything that looks like a layout bug.
|
|
16
|
+
//
|
|
17
|
+
// THREE-FREE ON PURPOSE. The chrome lays out the divider handle from these same
|
|
18
|
+
// rects, and the chrome may not import three (test/sourceHygiene.test.ts).
|
|
19
|
+
import type { Viewport } from "./view.js";
|
|
20
|
+
|
|
21
|
+
/** Which of the two views a coordinate, a camera or a query belongs to. */
|
|
22
|
+
export type StagePane = "camera" | "working";
|
|
23
|
+
|
|
24
|
+
/** What the stage is showing. Editor state: it is never written to the document
|
|
25
|
+
* and never reaches an export. */
|
|
26
|
+
export interface StageLayout {
|
|
27
|
+
mode: "camera" | "split" | "working";
|
|
28
|
+
/** The WORKING pane's fraction of the viewport WIDTH. Read only in "split". */
|
|
29
|
+
splitRatio: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A pane's rectangle in CSS px from the CANVAS TOP-LEFT.
|
|
34
|
+
*
|
|
35
|
+
* Top-left because that is the unit every other screen coordinate in this lane
|
|
36
|
+
* is already in (view.ts, project.ts). GL's bottom-left origin is a conversion
|
|
37
|
+
* the renderer does at the scissor call, and doing it here would leave two
|
|
38
|
+
* different y conventions in one file for the pointer lane to pick between.
|
|
39
|
+
*/
|
|
40
|
+
export interface PaneRect {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MIN_SPLIT_RATIO = 0.2;
|
|
48
|
+
export const MAX_SPLIT_RATIO = 0.8;
|
|
49
|
+
|
|
50
|
+
/** 0.5 for a nonsense ratio, for the same reason `clampZoom` answers 1: a NaN
|
|
51
|
+
* here becomes a NaN scissor rect, and a NaN scissor rect draws nothing at all
|
|
52
|
+
* with no error anywhere. */
|
|
53
|
+
export function clampSplitRatio(ratio: number): number {
|
|
54
|
+
if (!Number.isFinite(ratio)) return 0.5;
|
|
55
|
+
return Math.max(MIN_SPLIT_RATIO, Math.min(MAX_SPLIT_RATIO, ratio));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The two panes' rectangles.
|
|
60
|
+
*
|
|
61
|
+
* THE WORKING PANE IS ON THE LEFT and the camera pane on the right, matching
|
|
62
|
+
* the reference: the camera pane is the one being judged, and a judged image
|
|
63
|
+
* belongs where a reader's eye finishes rather than where it starts.
|
|
64
|
+
*
|
|
65
|
+
* A pane that is not on screen is `null`, not a zero-sized rect. Null cannot be
|
|
66
|
+
* scissored by accident and cannot be hit-tested by accident; a 0x0 rect can be
|
|
67
|
+
* both, silently.
|
|
68
|
+
*/
|
|
69
|
+
export function paneRects(
|
|
70
|
+
layout: StageLayout,
|
|
71
|
+
viewport: Viewport,
|
|
72
|
+
): { working: PaneRect | null; camera: PaneRect | null } {
|
|
73
|
+
const { width, height } = viewport;
|
|
74
|
+
// The same guard view.ts's `fitView` applies, for the same reason: the
|
|
75
|
+
// container is measured by a ResizeObserver and is 0x0 for the first frame.
|
|
76
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
77
|
+
return { working: null, camera: null };
|
|
78
|
+
}
|
|
79
|
+
const full: PaneRect = { x: 0, y: 0, width, height };
|
|
80
|
+
if (layout.mode === "camera") return { working: null, camera: full };
|
|
81
|
+
if (layout.mode === "working") return { working: full, camera: null };
|
|
82
|
+
// A viewport too narrow to hold two panes shows the CAMERA one. It is the
|
|
83
|
+
// pane that is the export, so it is the one worth keeping when only one fits.
|
|
84
|
+
if (width < 2) return { working: null, camera: full };
|
|
85
|
+
// ROUNDED, and the camera pane takes the remainder rather than its own
|
|
86
|
+
// rounding: two independently rounded widths can leave a one-column seam that
|
|
87
|
+
// neither pane owns, and a column nobody draws is a visible tear.
|
|
88
|
+
const split = Math.min(Math.max(1, Math.round(width * clampSplitRatio(layout.splitRatio))), width - 1);
|
|
89
|
+
return {
|
|
90
|
+
working: { x: 0, y: 0, width: split, height },
|
|
91
|
+
camera: { x: split, y: 0, width: width - split, height },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which pane a canvas-space point is in, and where it lands inside it.
|
|
97
|
+
*
|
|
98
|
+
* THE DIVIDER COLUMN BELONGS TO THE CAMERA PANE. Both rects are half-open —
|
|
99
|
+
* `[x, x + width)` on each axis — so `x === split` is the camera pane's FIRST
|
|
100
|
+
* column and not the working pane's last. Half-open is what makes the two panes
|
|
101
|
+
* a partition: no point is in both, and no point between them is in neither.
|
|
102
|
+
* Which side wins matters less than the fact that it is stated; a `<=` on both
|
|
103
|
+
* sides would make the answer depend on the order the rects are tested in.
|
|
104
|
+
*
|
|
105
|
+
* Null for a point outside every pane, which is what the chrome's own gutters
|
|
106
|
+
* and the not-yet-laid-out first frame produce.
|
|
107
|
+
*/
|
|
108
|
+
export function paneAt(
|
|
109
|
+
rects: { working: PaneRect | null; camera: PaneRect | null },
|
|
110
|
+
x: number,
|
|
111
|
+
y: number,
|
|
112
|
+
): { pane: StagePane; local: [number, number] } | null {
|
|
113
|
+
for (const pane of ["working", "camera"] as const) {
|
|
114
|
+
const rect = rects[pane];
|
|
115
|
+
if (rect === null) continue;
|
|
116
|
+
if (x < rect.x || x >= rect.x + rect.width) continue;
|
|
117
|
+
if (y < rect.y || y >= rect.y + rect.height) continue;
|
|
118
|
+
return { pane, local: [x - rect.x, y - rect.y] };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The pen's model, as a PURE REDUCER.
|
|
2
|
+
//
|
|
3
|
+
// The 2D lane's pen (canvas-editor/src/stage/penTool.ts) buries the identical
|
|
4
|
+
// arithmetic inside a controller that owns DOM listeners, a preview element and
|
|
5
|
+
// a store subscription, so the only way to ask it "what does a drag past the
|
|
6
|
+
// threshold do to the incoming handle" is to drive a browser. Here the model is
|
|
7
|
+
// a function of (state, event) and the controller in StageApp.tsx is the thin
|
|
8
|
+
// part: every rule below is checkable in node, and the stage keeps no geometry
|
|
9
|
+
// of its own to disagree with.
|
|
10
|
+
//
|
|
11
|
+
// EVERYTHING IS DESIGN PX. The two thresholds are authored in SCREEN px because
|
|
12
|
+
// they are about the author's finger, not about the document, so the caller
|
|
13
|
+
// divides them by the current zoom before handing them in. That keeps the
|
|
14
|
+
// reducer free of view state without pretending an 8px slop means the same
|
|
15
|
+
// thing at 25% and 400%.
|
|
16
|
+
import type { PathData, PathPoint } from "@dailies-studio/shot/doc";
|
|
17
|
+
|
|
18
|
+
/** How near the first anchor a press has to land to CLOSE the path. Screen px,
|
|
19
|
+
* the same 8 the 2D lane uses — the two pens should feel identical. */
|
|
20
|
+
export const PEN_CLOSE_SLOP_PX = 8;
|
|
21
|
+
/** How far a press has to travel before it is pulling handles rather than
|
|
22
|
+
* placing a corner. Screen px, matching the move/resize lanes, so a press
|
|
23
|
+
* meant as a click cannot mint a curve the author did not ask for. */
|
|
24
|
+
export const PEN_DRAG_THRESHOLD_PX = 3;
|
|
25
|
+
|
|
26
|
+
type Vec2 = [number, number];
|
|
27
|
+
|
|
28
|
+
export interface PenState {
|
|
29
|
+
points: PathPoint[];
|
|
30
|
+
/** True once a press lands back on the first anchor. The caller commits on
|
|
31
|
+
* seeing it; the wrap segment is what `closed` means, so no anchor is added
|
|
32
|
+
* for it (a duplicate point there prints a zero-length segment into `d`). */
|
|
33
|
+
closed: boolean;
|
|
34
|
+
/** The anchor a live press is pulling handles out of, or null. */
|
|
35
|
+
drag: { index: number; from: Vec2 } | null;
|
|
36
|
+
/** Where the cursor last was, for the rubber band. Null before the first
|
|
37
|
+
* move, which is why the preview draws no band on the very first anchor. */
|
|
38
|
+
cursor: Vec2 | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PEN_EMPTY: PenState = { points: [], closed: false, drag: null, cursor: null };
|
|
42
|
+
|
|
43
|
+
export type PenEvent =
|
|
44
|
+
| { kind: "press"; at: Vec2; closeSlop: number }
|
|
45
|
+
| { kind: "move"; at: Vec2; dragThreshold: number }
|
|
46
|
+
| { kind: "release" };
|
|
47
|
+
|
|
48
|
+
/** Whether a press at `at` would close the path. Exported because the preview
|
|
49
|
+
* highlights the first anchor on hover using the same answer, and two
|
|
50
|
+
* implementations of "near enough" would highlight one thing and do another. */
|
|
51
|
+
export function penCloses(state: PenState, at: Vec2, closeSlop: number): boolean {
|
|
52
|
+
const first = state.points[0];
|
|
53
|
+
if (first === undefined || state.points.length < 2) return false;
|
|
54
|
+
return Math.hypot(at[0] - first.p[0], at[1] - first.p[1]) <= closeSlop;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function penReduce(state: PenState, ev: PenEvent): PenState {
|
|
58
|
+
switch (ev.kind) {
|
|
59
|
+
case "press": {
|
|
60
|
+
if (penCloses(state, ev.at, ev.closeSlop)) return { ...state, closed: true, drag: null, cursor: null };
|
|
61
|
+
// A DOUBLE-CLICK COMMIT LANDS ITS SECOND PRESS ON THE FIRST'S POINT, so
|
|
62
|
+
// an unconditional append would bake a zero-length segment and a
|
|
63
|
+
// redundant anchor into the committed model — and because the printer is
|
|
64
|
+
// fed that same model, `pathToD(points) === d` still holds and nothing
|
|
65
|
+
// downstream refuses it. It just ships a wrong shape. Caught HERE rather
|
|
66
|
+
// than in each commit path, so the preview, `penLayer` and anything
|
|
67
|
+
// later all inherit it; `layerFromDraw`'s own `dedupe` stays where it is,
|
|
68
|
+
// serving the bare-points `onDraw` lane it was written for.
|
|
69
|
+
const last = state.points[state.points.length - 1];
|
|
70
|
+
if (last !== undefined && last.p[0] === ev.at[0] && last.p[1] === ev.at[1]) {
|
|
71
|
+
// Re-point the drag at the anchor already there, so holding and
|
|
72
|
+
// dragging the second press still pulls that anchor's handles.
|
|
73
|
+
return { ...state, drag: { index: state.points.length - 1, from: ev.at }, cursor: ev.at };
|
|
74
|
+
}
|
|
75
|
+
// A fresh anchor is a CORNER until a drag proves otherwise: no handles,
|
|
76
|
+
// which is exactly what makes a click-only path a polyline.
|
|
77
|
+
const points = [...state.points, { p: [ev.at[0], ev.at[1]] as Vec2 }];
|
|
78
|
+
return { points, closed: false, drag: { index: points.length - 1, from: ev.at }, cursor: ev.at };
|
|
79
|
+
}
|
|
80
|
+
case "move": {
|
|
81
|
+
const drag = state.drag;
|
|
82
|
+
if (drag === null) return { ...state, cursor: ev.at };
|
|
83
|
+
if (
|
|
84
|
+
Math.abs(ev.at[0] - drag.from[0]) < ev.dragThreshold &&
|
|
85
|
+
Math.abs(ev.at[1] - drag.from[1]) < ev.dragThreshold
|
|
86
|
+
) {
|
|
87
|
+
return { ...state, cursor: ev.at };
|
|
88
|
+
}
|
|
89
|
+
const anchor = state.points[drag.index];
|
|
90
|
+
if (anchor === undefined) return { ...state, cursor: ev.at };
|
|
91
|
+
// THE MIRROR IS THE POINT. `out` is where the author dragged and `in` is
|
|
92
|
+
// its reflection through the anchor, which is what makes the curve smooth
|
|
93
|
+
// THROUGH the anchor rather than kinked at it. Writing `out` alone would
|
|
94
|
+
// give every dragged anchor a corner on its incoming side.
|
|
95
|
+
const out: Vec2 = [ev.at[0], ev.at[1]];
|
|
96
|
+
const inHandle: Vec2 = [2 * anchor.p[0] - ev.at[0], 2 * anchor.p[1] - ev.at[1]];
|
|
97
|
+
const points = [...state.points];
|
|
98
|
+
points[drag.index] = { p: anchor.p, in: inHandle, out };
|
|
99
|
+
return { ...state, points, cursor: ev.at };
|
|
100
|
+
}
|
|
101
|
+
case "release":
|
|
102
|
+
return state.drag === null ? state : { ...state, drag: null };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The authored model a commit writes, or null when there is not enough path to
|
|
107
|
+
* be one. Two points is the floor: one anchor prints a move-to and nothing
|
|
108
|
+
* else, which is a layer with no geometry and no way to select it. */
|
|
109
|
+
export function penData(state: PenState): PathData | null {
|
|
110
|
+
if (state.points.length < 2) return null;
|
|
111
|
+
return { points: state.points.map((p) => ({ ...p })), closed: state.closed };
|
|
112
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// World -> screen, and ray -> layer. The stage's whole geometry surface, pure
|
|
2
|
+
// enough to test against a hand-built camera.
|
|
3
|
+
//
|
|
4
|
+
// ── EVERY LAYER IS A QUAD, AND THAT IS EXPLOITED RATHER THAN ABSTRACTED ──
|
|
5
|
+
//
|
|
6
|
+
// A shot layer is a flat plane at a depth. So its outline on screen is FOUR
|
|
7
|
+
// PROJECTED POINTS, and its pick test is a ray against two triangles. Both are
|
|
8
|
+
// exact, both are a few lines, and neither needs the renderer.
|
|
9
|
+
//
|
|
10
|
+
// The selection overlay is therefore a POLYGON, never an axis-aligned rect. A
|
|
11
|
+
// tilted layer's outline is a trapezoid; a bounding box would lie about the
|
|
12
|
+
// tilt in every frame and would put the resize handles somewhere the layer is
|
|
13
|
+
// not.
|
|
14
|
+
//
|
|
15
|
+
// ── WHY PICKING IS NOT `Raycaster.intersectObjects` ─────────────────────
|
|
16
|
+
//
|
|
17
|
+
// Two reasons, and the second is the one that matters.
|
|
18
|
+
//
|
|
19
|
+
// 1. The MESH is not the layer. A paint layer's plane is grown by the
|
|
20
|
+
// rasterizer's shadow padding (raster.ts), so intersecting the mesh picks
|
|
21
|
+
// the layer up to a shadow-blur radius outside its own box.
|
|
22
|
+
//
|
|
23
|
+
// 2. DISTANCE IS NOT THE ORDER. Every layer draws with `depthTest: false` and
|
|
24
|
+
// `renderOrder = layer.order`, so what covers what is DOCUMENT order and
|
|
25
|
+
// not camera distance — a shot deliberately lets a layer sit further away
|
|
26
|
+
// yet on top. A raycaster sorts by distance, so its "first hit" is
|
|
27
|
+
// routinely a layer the author cannot see. Ordering the hits by `order`
|
|
28
|
+
// descending is what makes the front-most hit the one under the cursor.
|
|
29
|
+
import { Matrix4, Ray, Vector3 } from "three";
|
|
30
|
+
|
|
31
|
+
/** A layer's four world-space corners, in TL, TR, BR, BL order. */
|
|
32
|
+
export type WorldQuad = readonly [Vector3, Vector3, Vector3, Vector3];
|
|
33
|
+
|
|
34
|
+
/** The same four, projected, as x,y pairs in CSS px from the canvas top-left. */
|
|
35
|
+
export type ScreenQuad = readonly [number, number, number, number, number, number, number, number];
|
|
36
|
+
|
|
37
|
+
/** What picking and projection need to know about one layer. `matrixWorld` is
|
|
38
|
+
* the layer GROUP's — which carries position, tilt and roll but NOT the box
|
|
39
|
+
* scale, so the box is passed separately and the padded mesh cannot leak in. */
|
|
40
|
+
export interface LayerFrame {
|
|
41
|
+
id: string;
|
|
42
|
+
/** Design px. */
|
|
43
|
+
size: readonly [number, number];
|
|
44
|
+
matrixWorld: Matrix4;
|
|
45
|
+
/** Paint order. Higher is nearer the viewer, whatever the depth says. */
|
|
46
|
+
order: number;
|
|
47
|
+
/** Locked layers are skipped by picking and marquee, and by nothing else. */
|
|
48
|
+
locked?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* False for the kinds that paint NOTHING of their own.
|
|
51
|
+
*
|
|
52
|
+
* A GROUP and a CLONER — a device draws its own slab now, and the comment
|
|
53
|
+
* here said otherwise for long enough to be worth correcting.
|
|
54
|
+
*
|
|
55
|
+
* Clicking inside a group but outside every child must not select the group:
|
|
56
|
+
* what makes a group selectable is a hit on a CHILD, which is a chrome policy
|
|
57
|
+
* over the hit list rather than a question the stage can answer. A group is
|
|
58
|
+
* still projected, because a selection made from the layers panel needs an
|
|
59
|
+
* outline.
|
|
60
|
+
*/
|
|
61
|
+
pickable?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CORNERS: ReadonlyArray<readonly [number, number]> = [
|
|
65
|
+
[-0.5, 0.5], // TL — design +y is DOWN, three +y is UP, so the TOP corner is +y here
|
|
66
|
+
[0.5, 0.5], // TR
|
|
67
|
+
[0.5, -0.5], // BR
|
|
68
|
+
[-0.5, -0.5], // BL
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** The layer's four world corners. Allocates; call it per selected layer per
|
|
72
|
+
* frame, not per layer per frame. */
|
|
73
|
+
export function worldQuad(frame: LayerFrame): WorldQuad {
|
|
74
|
+
const [w, h] = frame.size;
|
|
75
|
+
return CORNERS.map(([u, v]) =>
|
|
76
|
+
new Vector3(u * w, v * h, 0).applyMatrix4(frame.matrixWorld),
|
|
77
|
+
) as unknown as WorldQuad;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How far out of one plane, and off one axis, a multi-select may be and still
|
|
82
|
+
* get a union box. In DESIGN px, because world units are design units.
|
|
83
|
+
*
|
|
84
|
+
* Half a pixel: below that the two answers round to the same drawn box anyway,
|
|
85
|
+
* and above it the box would start claiming an alignment the layers do not have.
|
|
86
|
+
*/
|
|
87
|
+
const UNION_EPSILON = 0.5;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The one box around a multi-select, or null when there honestly isn't one.
|
|
91
|
+
*
|
|
92
|
+
* ── WHY IT IS ALLOWED TO BE AN AABB HERE, WHEN THE HEADER FORBIDS IT ────
|
|
93
|
+
*
|
|
94
|
+
* The header's rule is that a layer's OUTLINE is a polygon, because a bounding
|
|
95
|
+
* box would lie about that layer's tilt. This is a different object: the union
|
|
96
|
+
* of several layers, which has no tilt of its own to lie about. It gets to be a
|
|
97
|
+
* rectangle exactly when the layers make one honest, and the predicate below is
|
|
98
|
+
* what "honest" means:
|
|
99
|
+
*
|
|
100
|
+
* COPLANAR — every corner of every member shares one world z. A member tilted
|
|
101
|
+
* out of the design plane fails here, and it must: its four corners span a
|
|
102
|
+
* range of depths, so no single rectangle contains them without inventing a
|
|
103
|
+
* depth for the box.
|
|
104
|
+
*
|
|
105
|
+
* AXIS-PARALLEL — every member's own edges run along world x and y. A member
|
|
106
|
+
* rolled 30 degrees fails, and it must for a subtler reason: the box would
|
|
107
|
+
* still be drawable, but the SCALE it offers could not be honoured. One
|
|
108
|
+
* `GestureDelta` carries one `axis`, and `localizeGesture` applies it in
|
|
109
|
+
* DESIGN x/y — so a one-axis drag on a box whose axes are rotated away from
|
|
110
|
+
* design would grow the wrong dimension of every rolled member. A member
|
|
111
|
+
* rolled a multiple of 180 still passes, which is right: its edges are still
|
|
112
|
+
* the design axes, and `size` has no sign for the flip to disturb.
|
|
113
|
+
*
|
|
114
|
+
* A selection that fails keeps its per-layer outlines and is told, once, that
|
|
115
|
+
* the union scale is not available — see `Overlays`. Drawing a box that lies is
|
|
116
|
+
* the one option not on the table.
|
|
117
|
+
*/
|
|
118
|
+
export function unionQuad(frames: readonly LayerFrame[]): WorldQuad | null {
|
|
119
|
+
// ONE LAYER IS NOT A UNION. It keeps its own quad, its own handles and its
|
|
120
|
+
// own `resize`, which changes that layer's `size` and is a different and
|
|
121
|
+
// better operation than a ratio about a pivot.
|
|
122
|
+
if (frames.length < 2) return null;
|
|
123
|
+
let minX = Infinity;
|
|
124
|
+
let maxX = -Infinity;
|
|
125
|
+
let minY = Infinity;
|
|
126
|
+
let maxY = -Infinity;
|
|
127
|
+
let z = 0;
|
|
128
|
+
let first = true;
|
|
129
|
+
for (const frame of frames) {
|
|
130
|
+
const quad = worldQuad(frame);
|
|
131
|
+
if (first) z = quad[0].z;
|
|
132
|
+
for (const p of quad) {
|
|
133
|
+
if (Math.abs(p.z - z) > UNION_EPSILON) return null;
|
|
134
|
+
minX = Math.min(minX, p.x);
|
|
135
|
+
maxX = Math.max(maxX, p.x);
|
|
136
|
+
minY = Math.min(minY, p.y);
|
|
137
|
+
maxY = Math.max(maxY, p.y);
|
|
138
|
+
}
|
|
139
|
+
// TL->TR must run along world x and TL->BL along world y. Testing the two
|
|
140
|
+
// edges rather than the matrix's axes keeps this reading the exact points
|
|
141
|
+
// that get drawn, so the box and the outline cannot disagree about tilt.
|
|
142
|
+
if (Math.abs(quad[1].y - quad[0].y) > UNION_EPSILON) return null;
|
|
143
|
+
if (Math.abs(quad[3].x - quad[0].x) > UNION_EPSILON) return null;
|
|
144
|
+
first = false;
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
|
|
147
|
+
// TL, TR, BR, BL — `worldQuad`'s order, because everything downstream
|
|
148
|
+
// (`HANDLE_SITES`, `handlePoint`, `rotateRingAt`) indexes corners by it.
|
|
149
|
+
// World +y is UP, so the TOP corners take `maxY`.
|
|
150
|
+
return [
|
|
151
|
+
new Vector3(minX, maxY, z),
|
|
152
|
+
new Vector3(maxX, maxY, z),
|
|
153
|
+
new Vector3(maxX, minY, z),
|
|
154
|
+
new Vector3(minX, minY, z),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A camera far enough for `project` — the slice of three's Camera this needs. */
|
|
159
|
+
export interface Projector {
|
|
160
|
+
projectionMatrix: Matrix4;
|
|
161
|
+
matrixWorldInverse: Matrix4;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* World -> CSS px from the canvas top-left.
|
|
166
|
+
*
|
|
167
|
+
* It goes through `projectionMatrix * matrixWorldInverse`, which is exactly
|
|
168
|
+
* what `Vector3.project` does — and, critically, that matrix ALREADY carries
|
|
169
|
+
* `setViewOffset`. So the working-area pan and zoom need no separate term here
|
|
170
|
+
* and cannot drift out of step with what the renderer drew.
|
|
171
|
+
*/
|
|
172
|
+
export function toScreen(
|
|
173
|
+
point: Vector3,
|
|
174
|
+
camera: Projector,
|
|
175
|
+
width: number,
|
|
176
|
+
height: number,
|
|
177
|
+
): [number, number] {
|
|
178
|
+
const v = new Vector3().copy(point).applyMatrix4(camera.matrixWorldInverse).applyMatrix4(
|
|
179
|
+
camera.projectionMatrix,
|
|
180
|
+
);
|
|
181
|
+
// Perspective divide is inside applyMatrix4 for a Vector3 (three divides by
|
|
182
|
+
// w), so `v` is already NDC.
|
|
183
|
+
return [((v.x + 1) / 2) * width, ((1 - v.y) / 2) * height];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function screenQuad(
|
|
187
|
+
frame: LayerFrame,
|
|
188
|
+
camera: Projector,
|
|
189
|
+
width: number,
|
|
190
|
+
height: number,
|
|
191
|
+
): ScreenQuad {
|
|
192
|
+
return projectQuad(worldQuad(frame), camera, width, height);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The same projection for a quad that is not a layer's — the union box. */
|
|
196
|
+
export function projectQuad(
|
|
197
|
+
quad: WorldQuad,
|
|
198
|
+
camera: Projector,
|
|
199
|
+
width: number,
|
|
200
|
+
height: number,
|
|
201
|
+
): ScreenQuad {
|
|
202
|
+
const pts: number[] = [];
|
|
203
|
+
for (const corner of quad) {
|
|
204
|
+
const [x, y] = toScreen(corner, camera, width, height);
|
|
205
|
+
pts.push(x, y);
|
|
206
|
+
}
|
|
207
|
+
return pts as unknown as ScreenQuad;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Did any corner move enough to be worth repainting the overlay? Half a CSS
|
|
211
|
+
* pixel: below that the SVG rounds to the same path and the repaint is pure
|
|
212
|
+
* cost, and the overlay is recomputed on EVERY frame the camera moves. */
|
|
213
|
+
export function quadMoved(a: ScreenQuad | undefined, b: ScreenQuad, epsilon = 0.5): boolean {
|
|
214
|
+
if (a === undefined) return true;
|
|
215
|
+
for (let i = 0; i < 8; i++) if (Math.abs(a[i]! - b[i]!) > epsilon) return true;
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Ray against a layer's quad. Returns the ray distance, or null for a miss.
|
|
221
|
+
*
|
|
222
|
+
* Two triangles rather than a plane intersection plus a 2D containment test:
|
|
223
|
+
* Möller–Trumbore gives the answer and the bounds together, it needs no
|
|
224
|
+
* degenerate-plane special case, and it is the same arithmetic for both halves.
|
|
225
|
+
*/
|
|
226
|
+
export function rayHitsQuad(ray: Ray, quad: WorldQuad): number | null {
|
|
227
|
+
return (
|
|
228
|
+
rayHitsTriangle(ray, quad[0], quad[1], quad[2]) ?? rayHitsTriangle(ray, quad[0], quad[2], quad[3])
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const EPSILON = 1e-8;
|
|
233
|
+
|
|
234
|
+
function rayHitsTriangle(ray: Ray, a: Vector3, b: Vector3, c: Vector3): number | null {
|
|
235
|
+
const edge1 = new Vector3().subVectors(b, a);
|
|
236
|
+
const edge2 = new Vector3().subVectors(c, a);
|
|
237
|
+
const pvec = new Vector3().crossVectors(ray.direction, edge2);
|
|
238
|
+
const det = edge1.dot(pvec);
|
|
239
|
+
// Two-sided on purpose: a layer flipped or tilted past 90 degrees is still
|
|
240
|
+
// the same layer, and refusing to select it from behind would be a bug the
|
|
241
|
+
// author could not diagnose.
|
|
242
|
+
if (Math.abs(det) < EPSILON) return null;
|
|
243
|
+
const invDet = 1 / det;
|
|
244
|
+
const tvec = new Vector3().subVectors(ray.origin, a);
|
|
245
|
+
const u = tvec.dot(pvec) * invDet;
|
|
246
|
+
if (u < 0 || u > 1) return null;
|
|
247
|
+
const qvec = new Vector3().crossVectors(tvec, edge1);
|
|
248
|
+
const v = ray.direction.dot(qvec) * invDet;
|
|
249
|
+
if (v < 0 || u + v > 1) return null;
|
|
250
|
+
const t = edge2.dot(qvec) * invDet;
|
|
251
|
+
return t >= 0 ? t : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every layer under the pointer, FRONT-MOST FIRST.
|
|
256
|
+
*
|
|
257
|
+
* Front-most means highest `order`, not nearest — see the header. The whole
|
|
258
|
+
* ordered list is returned rather than the winner, because the chrome owns
|
|
259
|
+
* what a modified click means (plain takes the front, meta walks behind), and
|
|
260
|
+
* a stage that decided it would be a second place selection policy lives.
|
|
261
|
+
*/
|
|
262
|
+
export function pickLayers(ray: Ray, frames: readonly LayerFrame[]): string[] {
|
|
263
|
+
const hits: Array<{ id: string; order: number }> = [];
|
|
264
|
+
for (const frame of frames) {
|
|
265
|
+
if (frame.locked === true) continue;
|
|
266
|
+
if (rayHitsQuad(ray, worldQuad(frame)) !== null) hits.push({ id: frame.id, order: frame.order });
|
|
267
|
+
}
|
|
268
|
+
hits.sort((a, b) => b.order - a.order);
|
|
269
|
+
return hits.map((h) => h.id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Every layer under the pointer, NEAREST FIRST.
|
|
274
|
+
*
|
|
275
|
+
* THE WORKING PANE'S PICKER, and the divergence from `pickLayers` is the point.
|
|
276
|
+
* In the camera pane, what covers what is DOCUMENT order: every layer draws
|
|
277
|
+
* with `depthTest: false` and `renderOrder = order`, so the thing on top is the
|
|
278
|
+
* thing with the highest order however far away it sits, and picking by
|
|
279
|
+
* distance there would routinely return a layer the author cannot see (the
|
|
280
|
+
* header's reason 2).
|
|
281
|
+
*
|
|
282
|
+
* In a free orbit view none of that holds. The author is looking at real 3D
|
|
283
|
+
* POSITIONS from an angle the document never specifies, so paint order is
|
|
284
|
+
* meaningless as a "what did I click" answer: the plane whose surface the ray
|
|
285
|
+
* reaches first is the one under the cursor. Same hit test, one different sort.
|
|
286
|
+
*
|
|
287
|
+
* Ties break on paint order, which is not a nicety: layers default to z = 0, so
|
|
288
|
+
* a stack of coplanar layers is the common case and `t` is equal for all of
|
|
289
|
+
* them. Falling back to the camera pane's rule there keeps the two panes
|
|
290
|
+
* agreeing whenever depth has nothing to say.
|
|
291
|
+
*/
|
|
292
|
+
export function pickLayersByDistance(ray: Ray, frames: readonly LayerFrame[]): string[] {
|
|
293
|
+
const hits: Array<{ id: string; t: number; order: number }> = [];
|
|
294
|
+
for (const frame of frames) {
|
|
295
|
+
if (frame.locked === true) continue;
|
|
296
|
+
const t = rayHitsQuad(ray, worldQuad(frame));
|
|
297
|
+
if (t !== null) hits.push({ id: frame.id, t, order: frame.order });
|
|
298
|
+
}
|
|
299
|
+
hits.sort((a, b) => a.t - b.t || b.order - a.order);
|
|
300
|
+
return hits.map((h) => h.id);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Layers whose quad intersects a screen-space rectangle — the marquee.
|
|
305
|
+
*
|
|
306
|
+
* ── CORNERS BOTH WAYS, NOT JUST THE LAYER'S ─────────────────────────────
|
|
307
|
+
*
|
|
308
|
+
* It tested only the LAYER's corners against the rect, which quietly failed
|
|
309
|
+
* the two commonest marquees there are:
|
|
310
|
+
*
|
|
311
|
+
* A box drawn INSIDE a big layer — a full-bleed background, a card larger
|
|
312
|
+
* than the band — selected nothing at all, because none of that layer's
|
|
313
|
+
* corners were in the box.
|
|
314
|
+
*
|
|
315
|
+
* A box crossing a card edge to edge, taller than it and narrower, likewise:
|
|
316
|
+
* the card's corners are outside on both sides.
|
|
317
|
+
*
|
|
318
|
+
* Testing the RECT's four corners against the quad as well covers both, and
|
|
319
|
+
* costs a point-in-convex-polygon test the file already has the geometry for.
|
|
320
|
+
* Edge-crossing with no corner inside either shape (a thin cross) is still
|
|
321
|
+
* missed; that is a rare enough marquee to leave, and named rather than
|
|
322
|
+
* pretended away.
|
|
323
|
+
*/
|
|
324
|
+
export function pickInRect(
|
|
325
|
+
frames: readonly LayerFrame[],
|
|
326
|
+
camera: Projector,
|
|
327
|
+
width: number,
|
|
328
|
+
height: number,
|
|
329
|
+
rect: { x0: number; y0: number; x1: number; y1: number },
|
|
330
|
+
): string[] {
|
|
331
|
+
const minX = Math.min(rect.x0, rect.x1);
|
|
332
|
+
const maxX = Math.max(rect.x0, rect.x1);
|
|
333
|
+
const minY = Math.min(rect.y0, rect.y1);
|
|
334
|
+
const maxY = Math.max(rect.y0, rect.y1);
|
|
335
|
+
const corners: ReadonlyArray<readonly [number, number]> = [
|
|
336
|
+
[minX, minY],
|
|
337
|
+
[maxX, minY],
|
|
338
|
+
[maxX, maxY],
|
|
339
|
+
[minX, maxY],
|
|
340
|
+
];
|
|
341
|
+
const hit: Array<{ id: string; order: number }> = [];
|
|
342
|
+
for (const frame of frames) {
|
|
343
|
+
if (frame.locked === true) continue;
|
|
344
|
+
// ── BEHIND THE CAMERA PROJECTS TO GARBAGE, PER CORNER ────────────────
|
|
345
|
+
//
|
|
346
|
+
// `toScreen` divides by w, which is negative behind the eye, so the NDC
|
|
347
|
+
// mirrors through the origin and lands somewhere entirely plausible — a
|
|
348
|
+
// layer behind the author would be marquee-selected from a box drawn in
|
|
349
|
+
// front of them. `rayHitsQuad` rejects `t < 0`, and both gizmos carry their
|
|
350
|
+
// own guard; the marquee was the one projection consumer without one.
|
|
351
|
+
//
|
|
352
|
+
// WHOLE-LAYER VETO WAS TOO BLUNT. A layer STRADDLING the eye plane — a
|
|
353
|
+
// full-bleed background under a wide orbit, or anything the author has
|
|
354
|
+
// dollied into — has some corners in front and some behind, and refusing
|
|
355
|
+
// the layer outright made it un-marqueeable, which it was not before the
|
|
356
|
+
// guard existed. So the guard is per corner: only the lying ones are
|
|
357
|
+
// dropped, and a layer whose four corners all lie is skipped entirely.
|
|
358
|
+
const world = worldQuad(frame);
|
|
359
|
+
const ahead = world.map((p) => new Vector3().copy(p).applyMatrix4(camera.matrixWorldInverse).z < 0);
|
|
360
|
+
if (!ahead.some(Boolean)) continue;
|
|
361
|
+
const q = projectQuad(world, camera, width, height);
|
|
362
|
+
let inside = false;
|
|
363
|
+
for (let i = 0; i < 8 && !inside; i += 2) {
|
|
364
|
+
// A corner behind the eye has no honest screen position, so it cannot be
|
|
365
|
+
// the corner that puts this layer in the box.
|
|
366
|
+
if (ahead[i / 2] !== true) continue;
|
|
367
|
+
inside = q[i]! >= minX && q[i]! <= maxX && q[i + 1]! >= minY && q[i + 1]! <= maxY;
|
|
368
|
+
}
|
|
369
|
+
// The RECT's corners against the quad — the test that catches a box drawn
|
|
370
|
+
// inside a big layer. Only meaningful when the whole quad projected
|
|
371
|
+
// honestly: `pointInQuad`'s edge signs are nonsense once one vertex has
|
|
372
|
+
// mirrored through the origin.
|
|
373
|
+
if (!inside && ahead.every(Boolean)) {
|
|
374
|
+
for (const c of corners) {
|
|
375
|
+
if (inside) break;
|
|
376
|
+
inside = pointInQuad(c[0], c[1], q);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (inside) hit.push({ id: frame.id, order: frame.order });
|
|
380
|
+
}
|
|
381
|
+
hit.sort((a, b) => a.order - b.order);
|
|
382
|
+
return hit.map((h) => h.id);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Point in a CONVEX quad, by the sign of the cross product against each edge.
|
|
386
|
+
* A projected layer quad is always convex — a projective map takes a convex
|
|
387
|
+
* quad to a convex quad — so the sign test is exact rather than a heuristic. */
|
|
388
|
+
function pointInQuad(x: number, y: number, q: ScreenQuad): boolean {
|
|
389
|
+
let positive = false;
|
|
390
|
+
let negative = false;
|
|
391
|
+
for (let i = 0; i < 4; i++) {
|
|
392
|
+
const ax = q[i * 2]!;
|
|
393
|
+
const ay = q[i * 2 + 1]!;
|
|
394
|
+
const bx = q[((i + 1) % 4) * 2]!;
|
|
395
|
+
const by = q[((i + 1) % 4) * 2 + 1]!;
|
|
396
|
+
const cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax);
|
|
397
|
+
if (cross > 0) positive = true;
|
|
398
|
+
if (cross < 0) negative = true;
|
|
399
|
+
if (positive && negative) return false;
|
|
400
|
+
}
|
|
401
|
+
return true;
|
|
402
|
+
}
|