@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.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,172 @@
1
+ // The shot camera drawn as an object: apex, framed rectangle, pyramid edges and
2
+ // an up-marker, as pure arithmetic.
3
+ //
4
+ // ── WHY THE VERTICES ARE NOT COMPUTED IN THE COMPONENT ──────────────────
5
+ //
6
+ // Because they are the only part that can be WRONG in a way nobody notices. A
7
+ // gizmo drawn a few degrees off still looks like a frustum, and the author would
8
+ // read the error as the camera being somewhere it is not. Pulled out here it is
9
+ // asserted against a hand-computed pose in node, with no GL and no three.
10
+ //
11
+ // THREE-FREE, plain arrays, same rule panes.ts and workingView.ts follow.
12
+ //
13
+ // ── THE POSE IS DERIVED FROM THE RIG, NEVER RE-DERIVED FROM THE DOCUMENT ─
14
+ //
15
+ // `cameraRigAt` (packages/shot/src/resolve.ts) already answers "where is the
16
+ // camera and what is its lens at this frame", including the dolly-vs-zoom
17
+ // distinction that pins the distance for a zoom shot. Recomputing any of that
18
+ // here would be a second camera model, and the two would disagree on exactly
19
+ // the shots that are hardest to eyeball.
20
+ //
21
+ // The cross-section follows from the rig alone: half-height is
22
+ // `distance * tan(fov/2)`, which by `planeDistance`'s definition is exactly
23
+ // `frameHeight / (2 * zoom)` for a dolly AND for a zoom shot. So the drawn
24
+ // rectangle is the framed rectangle by construction rather than by agreement.
25
+ //
26
+ // COORDINATES ARE WORLD (three). The one mapping — design (x, y, z) is world
27
+ // (x, -y, -z) — is applied here and nowhere downstream, the same note
28
+ // workingView.ts carries.
29
+ import type { Framing } from "@dailies-studio/shot/doc";
30
+ import type { FrameSize } from "./view.js";
31
+
32
+ export type Vec3 = [number, number, number];
33
+
34
+ /** Exactly what `cameraRigAt` returns, structurally. Named here so this file
35
+ * states what it needs rather than importing a shape it cannot check. */
36
+ export interface CameraRig {
37
+ framing: Framing;
38
+ distance: number;
39
+ fovDeg: number;
40
+ }
41
+
42
+ /**
43
+ * The gizmo's vertices.
44
+ *
45
+ * `corners` are TL, TR, BR, BL — the SAME winding `project.ts` uses for a
46
+ * layer quad, which is what lets them be handed to `rayHitsQuad` unchanged.
47
+ */
48
+ export interface FrustumShape {
49
+ /** The camera's own position. */
50
+ apex: Vec3;
51
+ /** The framed rectangle at design z = 0. */
52
+ corners: [Vec3, Vec3, Vec3, Vec3];
53
+ /** The up-marker above the top edge: left base, apex, right base. */
54
+ up: [Vec3, Vec3, Vec3];
55
+ }
56
+
57
+ /** The up-marker, as fractions of the frustum's half-extents. Small enough not
58
+ * to read as part of the picture, big enough to be seen at a fit. */
59
+ const UP_BASE_FRACTION = 0.16;
60
+ const UP_HEIGHT_FRACTION = 0.26;
61
+
62
+ const DEG = Math.PI / 180;
63
+
64
+ export function frustumShape(rig: CameraRig, frame: FrameSize): FrustumShape {
65
+ const distance = finite(rig.distance, 1);
66
+ const halfH = Math.abs(distance * Math.tan((finite(rig.fovDeg, 35) * DEG) / 2));
67
+ const aspect = frame.height > 0 ? frame.width / frame.height : 1;
68
+ const halfW = halfH * aspect;
69
+
70
+ // The camera's own basis, straight out of the roll. ShotRuntime writes
71
+ // `camera.rotation.set(0, 0, -rollDeg)`, so this angle is that rotation and
72
+ // the two cannot disagree about which way a positive roll turns.
73
+ const a = -finite(rig.framing.rollDeg, 0) * DEG;
74
+ const cos = Math.cos(a);
75
+ const sin = Math.sin(a);
76
+ const right: Vec3 = [cos, sin, 0];
77
+ const up: Vec3 = [-sin, cos, 0];
78
+
79
+ // The framing's pan IS the camera's position on the plane, negated in y by
80
+ // the one mapping.
81
+ const cx = finite(rig.framing.x, 0);
82
+ const cy = -finite(rig.framing.y, 0);
83
+ const at = (u: number, v: number): Vec3 => [
84
+ cx + right[0] * u * halfW + up[0] * v * halfH,
85
+ cy + right[1] * u * halfW + up[1] * v * halfH,
86
+ 0,
87
+ ];
88
+
89
+ const topMid = at(0, 1);
90
+ const marker = (u: number, v: number): Vec3 => [
91
+ topMid[0] + right[0] * u * halfW + up[0] * v * halfH,
92
+ topMid[1] + right[1] * u * halfW + up[1] * v * halfH,
93
+ 0,
94
+ ];
95
+
96
+ return {
97
+ apex: [cx, cy, distance],
98
+ corners: [at(-1, 1), at(1, 1), at(1, -1), at(-1, -1)],
99
+ up: [
100
+ marker(-UP_BASE_FRACTION, 0),
101
+ marker(0, UP_HEIGHT_FRACTION),
102
+ marker(UP_BASE_FRACTION, 0),
103
+ ],
104
+ };
105
+ }
106
+
107
+ /** Every segment as a flat xyz pair list, ready for a `LineSegments` position
108
+ * attribute: four pyramid edges, the base rectangle, then the up-marker. */
109
+ export function frustumSegments(shape: FrustumShape): number[] {
110
+ const { apex, corners, up } = shape;
111
+ const out: number[] = [];
112
+ const seg = (a: Vec3, b: Vec3): void => {
113
+ out.push(a[0], a[1], a[2], b[0], b[1], b[2]);
114
+ };
115
+ for (const corner of corners) seg(apex, corner);
116
+ for (let i = 0; i < 4; i++) seg(corners[i]!, corners[(i + 1) % 4]!);
117
+ seg(up[0], up[1]);
118
+ seg(up[1], up[2]);
119
+ seg(up[2], up[0]);
120
+ return out;
121
+ }
122
+
123
+ /** How many floats `frustumSegments` writes. The buffer is allocated once and
124
+ * rewritten in place, so the count has to be a constant rather than whatever
125
+ * the first call happened to produce. */
126
+ export const FRUSTUM_SEGMENT_FLOATS = 11 * 2 * 3;
127
+
128
+ /** Did any vertex move enough to be worth re-uploading the buffer? Half a
129
+ * design pixel, mirroring `quadMoved`'s gate for the same reason: this runs on
130
+ * every committed frame and a still playhead must cost nothing. */
131
+ export function segmentsMoved(a: ArrayLike<number>, b: ArrayLike<number>, epsilon = 0.5): boolean {
132
+ if (a.length !== b.length) return true;
133
+ for (let i = 0; i < a.length; i++) if (Math.abs(a[i]! - b[i]!) > epsilon) return true;
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Distance from a screen point to the nearest of the quad's four border
139
+ * segments, in the same px the points arrive in.
140
+ *
141
+ * The corners come PROJECTED (x, y pairs, TL TR BR BL) so this stays
142
+ * camera-free and testable in node. It exists because the base quad is the
143
+ * FRAMED rectangle — `frameHeight / (2 * zoom)` tall — so a small zoom makes
144
+ * it dwarf the canvas and cover the whole working pane. A body grab that
145
+ * claimed the quad's INTERIOR would then take every press in the pane: orbit
146
+ * becomes unreachable, and an author who thinks they are orbiting slides the
147
+ * camera by thousands of design px instead — each slide a document write. The
148
+ * BORDER is the affordance that is actually drawn, so it is the thing a press
149
+ * can mean.
150
+ */
151
+ export function borderDistance(
152
+ x: number,
153
+ y: number,
154
+ corners: readonly (readonly [number, number])[],
155
+ ): number {
156
+ let best = Number.POSITIVE_INFINITY;
157
+ for (let i = 0; i < corners.length; i++) {
158
+ const [ax, ay] = corners[i]!;
159
+ const [bx, by] = corners[(i + 1) % corners.length]!;
160
+ const dx = bx - ax;
161
+ const dy = by - ay;
162
+ const lengthSq = dx * dx + dy * dy;
163
+ // A degenerate edge is its endpoint (an edge-on quad projects to a line).
164
+ const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / lengthSq));
165
+ best = Math.min(best, Math.hypot(x - (ax + t * dx), y - (ay + t * dy)));
166
+ }
167
+ return best;
168
+ }
169
+
170
+ function finite(value: number, fallback: number): number {
171
+ return Number.isFinite(value) ? value : fallback;
172
+ }
@@ -0,0 +1,494 @@
1
+ // The arithmetic behind the working pane's three placement aids: the floor
2
+ // grid, the axis-drag gizmo, and the corner navigation ball.
3
+ //
4
+ // THREE-FREE, like workingView.ts and modalState.ts and for the same reason:
5
+ // every rule here is a table row a node test can pin, and the three-side
6
+ // components (GridPlane, MoveGizmo) and the SVG overlay all read the same
7
+ // answers rather than each deriving their own.
8
+ //
9
+ // ── DESIGN AXES, NOT WORLD AXES ─────────────────────────────────────────
10
+ //
11
+ // The author types G then Y and reads "Y: 300" in the inspector, and both of
12
+ // those mean DESIGN y, which is DOWN. So the gizmo's three arms are the design
13
+ // axes, mapped into world by the one mapping workingView.ts states once:
14
+ // `world = (x, -y, -z)`. Labelling the arms with world axes instead would put
15
+ // the letter Y on the arm that moves the layer the opposite way from the field
16
+ // the author is watching.
17
+ import { presetWorkingView, viewBasis } from "./workingView.js";
18
+ import type { WorkingView, WorkingViewPreset } from "./workingView.js";
19
+ import type { ModalState } from "./modalState.js";
20
+ import type { FramingDelta } from "../bridgeTypes.js";
21
+
22
+ export type Vec3 = readonly [number, number, number];
23
+ export type DesignAxis = "x" | "y" | "z";
24
+
25
+ /**
26
+ * The three design axes as UNIT WORLD directions, and the numpad view each one
27
+ * looks back from.
28
+ *
29
+ * The preset is the view whose EYE sits on the positive end of that axis: eye
30
+ * on world +x is `right`, eye on world -y (which is design +y, down) is
31
+ * `bottom`, eye on world -z (design +z, away) is `back`. Stated as a table so
32
+ * the nav ball and the numpad can never disagree about what "look down X"
33
+ * means — `presetWorkingView` is the only place either of them turns into
34
+ * angles.
35
+ */
36
+ export const DESIGN_AXES: readonly {
37
+ axis: DesignAxis;
38
+ world: Vec3;
39
+ /** Looking back along the axis from its POSITIVE end. */
40
+ preset: WorkingViewPreset;
41
+ /** …and from its negative end. */
42
+ presetNegative: WorkingViewPreset;
43
+ }[] = [
44
+ { axis: "x", world: [1, 0, 0], preset: "right", presetNegative: "left" },
45
+ { axis: "y", world: [0, -1, 0], preset: "bottom", presetNegative: "top" },
46
+ { axis: "z", world: [0, 0, -1], preset: "back", presetNegative: "front" },
47
+ ];
48
+
49
+ /** What a press on one of the drag arms turned out to be. Plain arrays and a
50
+ * letter, so the pointer lane can carry it without touching three — and so
51
+ * the origin is a SNAPSHOT: the gizmo re-anchors every frame, and an axis
52
+ * line that moved with the thing being dragged along it would chase its own
53
+ * translation.
54
+ *
55
+ * `subject` is `modalTarget`'s own answer, carried rather than re-derived:
56
+ * the arms are drawn at ONE anchor, and a drag that decided for itself
57
+ * whether that anchor was a layer or the camera could pick the other one
58
+ * mid-gesture if the selection changed under it. */
59
+ export interface GizmoGrab {
60
+ subject: ModalState["target"];
61
+ axis: DesignAxis;
62
+ origin: Vec3;
63
+ direction: Vec3;
64
+ }
65
+
66
+ /**
67
+ * A camera arm's travel, as the framing delta it means.
68
+ *
69
+ * The camera has THREE authorable degrees of freedom for placement and they do
70
+ * not map one-to-one onto the axes: a `Framing` is (x, y, zoom, roll) for a
71
+ * camera that always looks straight down -z (workingView.ts's note), so x and y
72
+ * are its position on the plane and there is no free z — the distance IS the
73
+ * zoom, by `planeDistance`.
74
+ *
75
+ * So X and Y are the pan, unchanged, and Z is the DOLLY. `planeDistance` is
76
+ * `k / zoom`, which makes `distance * zoom` constant and the ratio exact:
77
+ * pushing the camera from `t0` to `t` multiplies its zoom by `t0 / t`. That is
78
+ * why this can be a factor rather than an approximation, and why a drag along
79
+ * the Z arm keeps the arm under the cursor where the apex's exponential drag
80
+ * (which has no depth to solve against) cannot.
81
+ *
82
+ * `travel` is measured along the arm's own DESIGN direction, so +z is towards
83
+ * the canvas and `t = t0 - travel`.
84
+ *
85
+ * Null once the camera has been pushed onto or through the canvas: there is no
86
+ * framing on the far side (`framingFromWorkingView` refuses the same pose for
87
+ * the same reason), and the ratio would flip sign rather than grow. Refusing
88
+ * leaves the last good delta standing, and dragging back recovers because every
89
+ * emit re-derives from the press.
90
+ */
91
+ export function framingArmDelta(
92
+ axis: DesignAxis,
93
+ travel: number,
94
+ distanceAtPress: number,
95
+ ): FramingDelta | null {
96
+ if (axis !== "z") {
97
+ return { dx: axis === "x" ? travel : 0, dy: axis === "y" ? travel : 0, dzoom: 1 };
98
+ }
99
+ const t0 = distanceAtPress;
100
+ const t = t0 - travel;
101
+ if (!Number.isFinite(t0) || t0 <= 0 || !Number.isFinite(t) || t <= 0) return null;
102
+ // The zoom CLAMP is camera.ts's (MIN/MAX_FRAMING_ZOOM), deliberately not
103
+ // repeated here: one boundary for the drag, the align, the fit and the agent
104
+ // op, and a second opinion would let one of them past the others.
105
+ return { dx: 0, dy: 0, dzoom: t0 / t };
106
+ }
107
+
108
+ function dot(a: Vec3, b: Vec3): number {
109
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
110
+ }
111
+
112
+ /** One end of one axis, as the nav ball draws it. */
113
+ export interface NavBall {
114
+ axis: DesignAxis;
115
+ positive: boolean;
116
+ /** Screen direction from the gizmo's centre, y DOWN like every other screen
117
+ * number in this lane. NOT normalized: its length is the FORESHORTENING,
118
+ * which is what pulls an axis pointing at the author in towards the centre
119
+ * — the cue that tells them they are looking down it. */
120
+ x: number;
121
+ y: number;
122
+ /** How far towards the viewer this end points, -1..1. Sort ASCENDING and
123
+ * draw in that order, so the near balls land on top of the far ones — which
124
+ * is the only depth cue a flat SVG has. */
125
+ toward: number;
126
+ preset: WorkingViewPreset;
127
+ }
128
+
129
+ /**
130
+ * The six axis ends, projected for the corner gizmo.
131
+ *
132
+ * ORTHOGRAPHIC on purpose: a navigation gizmo shows DIRECTIONS, and running it
133
+ * through the working camera's perspective divide would make the arm pointing
134
+ * at the author swing wildly with the pane's aspect for no information gained.
135
+ * Directions are exactly what the camera basis gives, so this is three dot
136
+ * products per end and no matrices.
137
+ */
138
+ export function navBalls(view: WorkingView): NavBall[] {
139
+ const { right, up, forward } = viewBasis(view);
140
+ const out: NavBall[] = [];
141
+ for (const { axis, world, preset, presetNegative } of DESIGN_AXES) {
142
+ const x = dot(world, right);
143
+ // Screen y is down and the basis's up is up, so the sign flips exactly
144
+ // once, here.
145
+ const y = -dot(world, up);
146
+ // `forward` points from the eye AT the target, so a vector coming back at
147
+ // the viewer has a negative component along it.
148
+ const toward = -dot(world, forward);
149
+ out.push({ axis, positive: true, x, y, toward, preset });
150
+ out.push({ axis, positive: false, x: -x, y: -y, toward: -toward, preset: presetNegative });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** The view a nav ball click lands on. Re-exported through this module so the
156
+ * overlay never needs workingView.ts's angle vocabulary — it holds a preset
157
+ * string and hands it back. */
158
+ export function viewForBall(view: WorkingView, ball: NavBall): WorkingView {
159
+ return presetWorkingView(view, ball.preset);
160
+ }
161
+
162
+ /**
163
+ * How far along an axis line the closest point to a ray sits.
164
+ *
165
+ * THIS IS WHAT MAKES A GRABBED ARM STAY UNDER THE CURSOR. The alternative —
166
+ * unproject onto a plane and zero the other two components, which is what the
167
+ * modal lane does because it has no arm to stay under — drifts away from the
168
+ * pointer as the view turns, and at a grazing angle it runs away entirely.
169
+ *
170
+ * The standard closest-approach solve for two lines, with both directions
171
+ * taken as UNIT (the caller passes an axis from `DESIGN_AXES` and a normalized
172
+ * ray direction), which collapses `a` and `e` to 1.
173
+ *
174
+ * Null when the ray is PARALLEL to the axis: the closest point is then every
175
+ * point, the solve divides by zero, and skipping the emit is the only honest
176
+ * answer — the same refusal `designDelta` makes for an edge-on plane.
177
+ */
178
+ const MIN_AXIS_DENOM = 1e-6;
179
+
180
+ /**
181
+ * Can this arm, as DRAWN, answer a press?
182
+ *
183
+ * ── THE QUANTITY IS THE SEGMENT'S OWN LENGTH ────────────────────────────
184
+ *
185
+ * `ARM_START_PX` and `ARM_PX` are world lengths taken at the anchor's depth and
186
+ * then projected, so what the author can actually hit is those lengths times
187
+ * the sine of the axis's angle to the view direction. An axis pointing at the
188
+ * camera collapses: at Numpad7 the design y arm draws about 2.6 px, and under
189
+ * an orthographic rig the design z arm draws a mathematical POINT on the
190
+ * anchor. `borderDistance` to a degenerate segment is just the distance to that
191
+ * point, so every press near the selection centre grabbed it — and then
192
+ * `axisParam` divided by a zero denominator, answered null, and the press
193
+ * opened no scrub and did nothing at all.
194
+ *
195
+ * The first version of this gate measured the ROOT's offset from the anchor
196
+ * instead, reasoning that the tip sits three times further out so the root test
197
+ * implies the length test. That is backwards, and the cost was total: the root
198
+ * offset is at most `ARM_START_PX = 30` px, the threshold was 28, and at the
199
+ * SHIPPED DEFAULT view all three arms measured 20 to 27 — so every arm failed
200
+ * and the gizmo answered nothing anywhere while still being painted.
201
+ *
202
+ * ── AND THE APEX CLEARANCE IS A SEPARATE QUESTION ───────────────────────
203
+ *
204
+ * Only on the CAMERA, where the anchor IS the frustum's apex and the apex's own
205
+ * 14 px grab is tested AFTER this one. An arm whose root lands inside that
206
+ * radius takes every press aimed at the dolly. A layer has no such neighbour,
207
+ * so it does not pay for it.
208
+ *
209
+ * PURE, and here rather than inside `MoveGizmo`'s frame loop, because a gate
210
+ * that decides whether a whole widget works has to be checkable without a GL
211
+ * context — see test/stageGeometry.test.ts.
212
+ */
213
+ export function armGrabbable(
214
+ root: readonly [number, number],
215
+ tip: readonly [number, number],
216
+ anchor: readonly [number, number],
217
+ subject: "layers" | "camera",
218
+ minLengthPx: number,
219
+ apexClearPx: number,
220
+ ): boolean {
221
+ if (Math.hypot(tip[0] - root[0], tip[1] - root[1]) < minLengthPx) return false;
222
+ if (subject !== "camera") return true;
223
+ return Math.hypot(root[0] - anchor[0], root[1] - anchor[1]) >= apexClearPx;
224
+ }
225
+
226
+ export function axisParam(
227
+ origin: Vec3,
228
+ axis: Vec3,
229
+ rayOrigin: Vec3,
230
+ rayDirection: Vec3,
231
+ ): number | null {
232
+ const r: Vec3 = [origin[0] - rayOrigin[0], origin[1] - rayOrigin[1], origin[2] - rayOrigin[2]];
233
+ const b = dot(axis, rayDirection);
234
+ const denom = 1 - b * b;
235
+ if (!(Math.abs(denom) > MIN_AXIS_DENOM)) return null;
236
+ const s = (b * dot(rayDirection, r) - dot(axis, r)) / denom;
237
+ return Number.isFinite(s) ? s : null;
238
+ }
239
+
240
+ /** The 1 / 2 / 5 decimal ladder rung nearest a target, in LOG space so "nearest"
241
+ * means nearest by ratio — 384 wants 500, not 200, and a linear comparison
242
+ * says the opposite. */
243
+ const LADDER = [1, 2, 5, 10];
244
+
245
+ export function niceStep(target: number): number {
246
+ if (!Number.isFinite(target) || target <= 0) return 1;
247
+ const decade = Math.pow(10, Math.floor(Math.log10(target)));
248
+ const scaled = target / decade;
249
+ let best = LADDER[0]!;
250
+ for (const rung of LADDER) {
251
+ if (Math.abs(Math.log(rung / scaled)) < Math.abs(Math.log(best / scaled))) best = rung;
252
+ }
253
+ return best * decade;
254
+ }
255
+
256
+ /**
257
+ * The floor grid's line segments, flat xyz pairs for a `LineSegments` position
258
+ * attribute.
259
+ *
260
+ * In the XZ plane at a fixed height, which is the plane the canvas STANDS on
261
+ * rather than the one it lives in — see GridPlane for why a grid through the
262
+ * design origin is the wrong answer here even though it is Blender's.
263
+ */
264
+ export function gridPositions(step: number, cells: number, height: number): number[] {
265
+ const half = step * cells;
266
+ const out: number[] = [];
267
+ for (let i = -cells; i <= cells; i++) {
268
+ const at = i * step;
269
+ out.push(at, height, -half, at, height, half);
270
+ out.push(-half, height, at, half, height, at);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * The grid's spacing at a given camera distance.
277
+ *
278
+ * A fixed spacing is a grid that only reads right at one zoom. Dollied out it
279
+ * is a solid grey field of lines nobody can count, and the cells stop meaning a
280
+ * distance at all; dollied in there is nothing between the lines at all.
281
+ *
282
+ * The rule is one line per tenth of what the camera can SEE, which is the same
283
+ * rule the frame-height version was already applying — `2 * distance *
284
+ * tan(fov/2)` is the visible height at the target, and at the fit distance that
285
+ * is roughly the canvas. Then the same `niceStep` ladder, so the spacing is
286
+ * always a round number an author can count in, and it changes in jumps rather
287
+ * than sliding continuously under the artwork.
288
+ *
289
+ * FLOORED AT THE FRAME'S OWN HEIGHT, which keeps close range exactly as it was:
290
+ * zooming in past the canvas refines nothing, because a grid finer than the
291
+ * document's own tenth is measuring in units the document does not use.
292
+ *
293
+ * Pure in (frameHeight, distance, fovDeg): no hysteresis, no memory of the last
294
+ * answer. A quantised function of the current view is the only kind of
295
+ * adaptation this lane can have.
296
+ */
297
+ export function gridStep(frameHeight: number, distance: number, fovDeg: number): number {
298
+ return niceStep(gridSpan(frameHeight, distance, fovDeg) / GRID_CELLS_PER_SPAN);
299
+ }
300
+
301
+ /**
302
+ * What the working camera can SEE at the target, floored at the frame's height.
303
+ *
304
+ * The one length the whole floor is built from: the spacing divides it, and the
305
+ * fade is a multiple of it. Two aids derived from one quantity move together
306
+ * under a dolly, which is what stops the fade landing inside the drawn patch at
307
+ * some distances and outside it at others.
308
+ */
309
+ export function gridSpan(frameHeight: number, distance: number, fovDeg: number): number {
310
+ const visible = 2 * Math.max(0, distance) * Math.tan((Math.max(1, Math.min(179, fovDeg)) * Math.PI) / 360);
311
+ return Math.max(frameHeight, Number.isFinite(visible) ? visible : 0);
312
+ }
313
+
314
+ /** Lines across the visible span. A tenth is the coarsest division that still
315
+ * reads as a grid rather than as a floor with four lines on it. */
316
+ export const GRID_CELLS_PER_SPAN = 10;
317
+
318
+ /**
319
+ * Cells each way from the grid's centre.
320
+ *
321
+ * ── WHY THIS IS LARGE AND NOT 24 ────────────────────────────────────────
322
+ *
323
+ * A ground plane is not bounded by what the camera can see. Look ALONG it and
324
+ * it recedes to a horizon, so no multiple of the visible height covers it —
325
+ * the old 24 cells (about 2.4 spans) ran out in open view at any grazing
326
+ * angle, and the pane showed the floor simply stopping in a square.
327
+ *
328
+ * The cost of the bigger number is one draw call with `(2 * cells + 1) * 4`
329
+ * vertices and no per-frame work at all, because the buffer is static and the
330
+ * OBJECT is what follows the author. The number itself is chosen against
331
+ * `gridFade`: see `gridExtent`.
332
+ */
333
+ export const GRID_CELLS = 192;
334
+
335
+ /**
336
+ * Where the floor fades out, as `[start, end]` distances FROM THE EYE.
337
+ *
338
+ * Extent alone does not buy an infinite grid. A patch large enough to reach
339
+ * past every view is a patch whose far lines converge into an aliased band,
340
+ * and a band that ends is still an edge. Fading the lines into the pane's own
341
+ * backdrop is what turns the end into a horizon.
342
+ *
343
+ * Measured from the CAMERA, not from the grid's centre: fog is a property of
344
+ * how far away a thing is, and a fade centred on the target would sit
345
+ * lopsided in the view the moment the author orbits off-centre.
346
+ *
347
+ * In spans, so the fade dollies WITH the spacing — the author always sees
348
+ * roughly the same number of cells before the floor dissolves, at every zoom.
349
+ */
350
+ export function gridFade(frameHeight: number, distance: number, fovDeg: number): [number, number] {
351
+ const span = gridSpan(frameHeight, distance, fovDeg);
352
+ return [span * GRID_FADE_START_SPANS, span * GRID_FADE_END_SPANS];
353
+ }
354
+
355
+ const GRID_FADE_START_SPANS = 1.5;
356
+ const GRID_FADE_END_SPANS = 6;
357
+
358
+ /**
359
+ * Half the drawn patch's width, which must always outreach the fade.
360
+ *
361
+ * The gate `GRID_CELLS` is chosen against: the fade ring is centred on the
362
+ * EYE and the patch on the target, so the geometry has to cover
363
+ * `fadeEnd + distance` or the author finds a hard edge inside the region the
364
+ * fade has not finished dimming. `gizmoMath.test.ts` sweeps the legal dolly
365
+ * band against exactly that inequality.
366
+ */
367
+ export function gridExtent(frameHeight: number, distance: number, fovDeg: number): number {
368
+ return GRID_CELLS * gridStep(frameHeight, distance, fovDeg);
369
+ }
370
+
371
+ /**
372
+ * One world axis, as a single segment through the origin.
373
+ *
374
+ * `axis` is the WORLD axis index (0 = x, 1 = y, 2 = z). The design axes map to
375
+ * the world ones by `world = (x, -y, -z)`, and a LINE THROUGH THE ORIGIN is
376
+ * invariant under that sign flip — so these are the design axes too, as sets,
377
+ * and the colours match the nav gizmo's letters without a conversion.
378
+ */
379
+ /** Radians of key rotation per CSS pixel dragged. A half turn in ~314 px, the
380
+ * same order as the modal falloffs, so the two feel like one tool. */
381
+ export const KEY_DRAG_RAD_PER_PX = 0.01;
382
+
383
+
384
+ /**
385
+ * Turn a key direction by a screen drag.
386
+ *
387
+ * ── AN ARCBALL, NOT TWO EULER ANGLES ──────────────────────────────────────
388
+ *
389
+ * The author is dragging a point around a sphere they are looking at, so the
390
+ * rotation axes have to be the ones on screen: horizontal travel turns the key
391
+ * about the view's UP, vertical travel about the view's RIGHT. Yaw and pitch in
392
+ * world coordinates would drift the moment the author orbited, because "up" in
393
+ * the picture would no longer be up in the maths.
394
+ *
395
+ * `dir` and the result are in the DESIGN frame, which is what
396
+ * `mesh.shading.keyDir` stores; `viewBasis` speaks the world frame, so the
397
+ * mapping `world = (x, -y, -z)` is applied on the way in and undone on the way
398
+ * out. It is its own inverse, which is why both directions are the same line.
399
+ *
400
+ * PURE, and measured from a base rather than accumulated: the caller passes the
401
+ * direction at the PRESS and the total travel, so a coalesced drag lands
402
+ * exactly where an uncoalesced one does. Unit length is restored at the end,
403
+ * because a rotation of a unit vector is one and a document should not carry
404
+ * float drift.
405
+ */
406
+ export function rotateKeyDir(
407
+ dir: readonly [number, number, number],
408
+ view: WorkingView,
409
+ dxPx: number,
410
+ dyPx: number,
411
+ ): [number, number, number] {
412
+ const { right, up } = viewBasis(view);
413
+ // Design -> world, the one mapping.
414
+ let v: [number, number, number] = [dir[0], -dir[1], -dir[2]];
415
+ v = rotateAbout(v, up, -dxPx * KEY_DRAG_RAD_PER_PX);
416
+ v = rotateAbout(v, right, -dyPx * KEY_DRAG_RAD_PER_PX);
417
+ const length = Math.hypot(v[0], v[1], v[2]);
418
+ // A zero-length key has no direction to turn. Handing back the input is the
419
+ // only answer that cannot write NaN into the document.
420
+ if (!Number.isFinite(length) || length < 1e-9) return [dir[0], dir[1], dir[2]];
421
+ // …and world -> design, which is the same sign flip.
422
+ return [v[0] / length, -v[1] / length, -v[2] / length];
423
+ }
424
+
425
+ /** Rodrigues' rotation of `v` about a unit `axis` by `angle` radians. Written
426
+ * out rather than built through three's Quaternion so this file stays
427
+ * three-free and testable in node. */
428
+ function rotateAbout(
429
+ v: readonly [number, number, number],
430
+ axis: readonly [number, number, number],
431
+ angle: number,
432
+ ): [number, number, number] {
433
+ const c = Math.cos(angle);
434
+ const s = Math.sin(angle);
435
+ const dot = v[0] * axis[0] + v[1] * axis[1] + v[2] * axis[2];
436
+ const cross: [number, number, number] = [
437
+ axis[1] * v[2] - axis[2] * v[1],
438
+ axis[2] * v[0] - axis[0] * v[2],
439
+ axis[0] * v[1] - axis[1] * v[0],
440
+ ];
441
+ return [
442
+ v[0] * c + cross[0] * s + axis[0] * dot * (1 - c),
443
+ v[1] * c + cross[1] * s + axis[1] * dot * (1 - c),
444
+ v[2] * c + cross[2] * s + axis[2] * dot * (1 - c),
445
+ ];
446
+ }
447
+
448
+ /**
449
+ * A key direction projected into the pane, for drawing the arm.
450
+ *
451
+ * `null` when the key points nearly along the view axis: there is no readable
452
+ * 2D direction there, and drawing a near-zero arm would make the handle jitter
453
+ * around the layer centre. The caller draws the disc alone instead.
454
+ */
455
+ export function keyDirOnScreen(
456
+ dir: readonly [number, number, number],
457
+ view: WorkingView,
458
+ ): [number, number] | null {
459
+ const { right, up } = viewBasis(view);
460
+ const world: [number, number, number] = [dir[0], -dir[1], -dir[2]];
461
+ const sx = world[0] * right[0] + world[1] * right[1] + world[2] * right[2];
462
+ // NEGATED: screen y runs DOWN and the basis's up runs up.
463
+ const sy = -(world[0] * up[0] + world[1] * up[1] + world[2] * up[2]);
464
+ const length = Math.hypot(sx, sy);
465
+ if (!Number.isFinite(length) || length < 1e-3) return null;
466
+ return [sx / length, sy / length];
467
+ }
468
+
469
+ export function axisPositions(axis: 0 | 1 | 2, extent: number): number[] {
470
+ const a = [0, 0, 0];
471
+ const b = [0, 0, 0];
472
+ a[axis] = -extent;
473
+ b[axis] = extent;
474
+ return [...a, ...b];
475
+ }
476
+
477
+ /**
478
+ * Where an axis segment SITS, given the view target and the floor's world y.
479
+ *
480
+ * Two rules in one expression. Along its OWN direction each axis follows the
481
+ * target, which is the identity on the infinite line it stands for, so no pan
482
+ * reaches an end and nothing appears to slide. Across the other two it takes
483
+ * the FLOOR, not zero — the world origin here is the canvas centre, half a
484
+ * frame height up, and axes placed there hang in mid-air above the grid
485
+ * instead of crossing it. The vertical axis needs no y of its own: it meets
486
+ * the floor at that same point on the way through.
487
+ */
488
+ export function axisOrigin(
489
+ axis: 0 | 1 | 2,
490
+ target: readonly [number, number, number],
491
+ floorY: number,
492
+ ): [number, number, number] {
493
+ return [axis === 0 ? target[0] : 0, axis === 1 ? target[1] : floorY, axis === 2 ? target[2] : 0];
494
+ }