@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,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
|
+
}
|