@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,151 @@
1
+ // The selection box's eight resize handles, as pure screen geometry.
2
+ //
3
+ // ── WHY THIS IS ITS OWN MODULE ──────────────────────────────────────────
4
+ //
5
+ // THREE READERS, and they must never disagree. `Overlays` DRAWS the handles,
6
+ // `StageTools.grabAt` HIT-TESTS them, and the cursor lane asks which way one
7
+ // points. A drawn handle the hit test does not answer, or an answered one
8
+ // nobody drew, is precisely the bug a hover cursor exists to make impossible —
9
+ // and the two used to be separate tables in separate files, one of which only
10
+ // knew about four of the eight.
11
+ //
12
+ // It is also the only shape here that can be checked in node: `StageTools`
13
+ // pulls in three's WebGPU node materials through its neighbours, so a test that
14
+ // wanted to ask "what does a press at this point grab" had to mount a canvas.
15
+ //
16
+ // ── AND WHY THE QUAD IS A TRAPEZOID, NOT A RECTANGLE ────────────────────
17
+ //
18
+ // `screenQuad` projects a layer's four corners through whichever camera the
19
+ // pane is using. Under the camera pane's perspective lens a tilted layer is
20
+ // already a trapezoid; under the working pane's free orbit it can be any convex
21
+ // quad at all. So nothing here may assume axis alignment, and in particular a
22
+ // handle's NAME says nothing about which way it points on screen.
23
+ import { RESIZE_HANDLES } from "@dailies-studio/shot/doc";
24
+ import type { ResizeHandle } from "@dailies-studio/shot/doc";
25
+ import type { ScreenQuad } from "./project.js";
26
+
27
+ /** How close a press has to land to a handle, in CSS px. Larger than the drawn
28
+ * 7px handle, because a 7px target is a 7px target and everyone misses it. */
29
+ export const HANDLE_GRAB_PX = 10;
30
+
31
+ /**
32
+ * The eight handle sites, in HIT-TEST ORDER: four corners, then four edge
33
+ * midpoints.
34
+ *
35
+ * Corners first is load-bearing rather than cosmetic. The grab radius is larger
36
+ * than the drawn handle, so a corner's target overlaps both of its neighbouring
37
+ * edge targets; testing edges first would answer a corner press with an edge,
38
+ * and the author would resize one axis when they asked for two.
39
+ *
40
+ * `a` and `b` are quad corner indices (TL, TR, BR, BL) and the site is their
41
+ * MIDPOINT — which is the corner itself when they are equal. The midpoint of
42
+ * two projected corners is exactly the projection of the edge's midpoint,
43
+ * because a projective map takes lines to lines, so an edge handle sits on the
44
+ * drawn edge at every angle with no extra maths.
45
+ *
46
+ * The handle's two signs ARE the resize arithmetic (packages/shot's
47
+ * `resizeTransform`). `RESIZE_HANDLES` has carried all eight since the document
48
+ * model was written; four of them were unreachable from the pointer until this
49
+ * table existed.
50
+ */
51
+ export const HANDLE_SITES: ReadonlyArray<{ a: number; b: number; handle: ResizeHandle }> = [
52
+ { a: 0, b: 0, handle: RESIZE_HANDLES["nw"]! },
53
+ { a: 1, b: 1, handle: RESIZE_HANDLES["ne"]! },
54
+ { a: 2, b: 2, handle: RESIZE_HANDLES["se"]! },
55
+ { a: 3, b: 3, handle: RESIZE_HANDLES["sw"]! },
56
+ { a: 0, b: 1, handle: RESIZE_HANDLES["n"]! },
57
+ { a: 1, b: 2, handle: RESIZE_HANDLES["e"]! },
58
+ { a: 2, b: 3, handle: RESIZE_HANDLES["s"]! },
59
+ { a: 3, b: 0, handle: RESIZE_HANDLES["w"]! },
60
+ ];
61
+
62
+ /** Where a site sits on screen. */
63
+ export function handlePoint(quad: ScreenQuad, site: { a: number; b: number }): [number, number] {
64
+ return [
65
+ (quad[site.a * 2]! + quad[site.b * 2]!) / 2,
66
+ (quad[site.a * 2 + 1]! + quad[site.b * 2 + 1]!) / 2,
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * The mean of the four projected corners.
72
+ *
73
+ * NOT the diagonals' intersection: under a perspective camera the quad is a
74
+ * trapezoid whose diagonals do not bisect each other, and the two answers
75
+ * disagree by enough at a steep angle to flip a cursor across a 45 degree
76
+ * bucket boundary.
77
+ */
78
+ export function quadCentre(quad: ScreenQuad): [number, number] {
79
+ let cx = 0;
80
+ let cy = 0;
81
+ for (let i = 0; i < 4; i++) {
82
+ cx += quad[i * 2]!;
83
+ cy += quad[i * 2 + 1]!;
84
+ }
85
+ return [cx / 4, cy / 4];
86
+ }
87
+
88
+ /**
89
+ * Which of the eight handles a press grabbed, with the direction it points.
90
+ *
91
+ * `angle` is the OUTWARD screen direction from the quad's centre, in radians in
92
+ * the overlay's y-DOWN space. It is here rather than at the cursor's end
93
+ * because this is where the quad is, and deriving it twice is how the drawn
94
+ * handle and the cursor end up a rotation apart.
95
+ */
96
+ export function nearestHandle(
97
+ quad: ScreenQuad | undefined,
98
+ x: number,
99
+ y: number,
100
+ ): { handle: ResizeHandle; angle: number } | null {
101
+ if (quad === undefined) return null;
102
+ for (const site of HANDLE_SITES) {
103
+ const [hx, hy] = handlePoint(quad, site);
104
+ if (Math.hypot(hx - x, hy - y) > HANDLE_GRAB_PX) continue;
105
+ const [cx, cy] = quadCentre(quad);
106
+ return { handle: site.handle, angle: Math.atan2(hy - cy, hx - cx) };
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * The rotate ring: a band JUST OUTSIDE each corner.
113
+ *
114
+ * ── WHY OUTSIDE, AND WHY ONLY AT THE CORNERS ────────────────────────────
115
+ *
116
+ * Figma and Framer both put rotation in the empty space beyond a corner, and
117
+ * that placement is not arbitrary: it is the one region around a selection box
118
+ * that no other gesture claims. Inside is a body move, on the corner is a
119
+ * resize, along an edge is a one-axis resize, and further out is empty space.
120
+ *
121
+ * Corners only, not the whole perimeter, for the same reason: the band beside
122
+ * an EDGE is a few px from that edge's own resize handle, so a ring there would
123
+ * make one-axis resizes hard to hit for a gesture that has somewhere else to
124
+ * live.
125
+ *
126
+ * It starts where the resize slop ends, so the two never both answer, and the
127
+ * annulus is wide enough to hit without being a trap the pointer falls into on
128
+ * the way out of the box.
129
+ */
130
+ export const RING_INNER_PX = HANDLE_GRAB_PX;
131
+ export const RING_OUTER_PX = 26;
132
+
133
+ /** Is this press in the rotate ring, and which way does that corner point? */
134
+ export function rotateRingAt(
135
+ quad: ScreenQuad | undefined,
136
+ x: number,
137
+ y: number,
138
+ ): { angle: number } | null {
139
+ if (quad === undefined) return null;
140
+ const [cx, cy] = quadCentre(quad);
141
+ for (let i = 0; i < 4; i++) {
142
+ const d = Math.hypot(quad[i * 2]! - x, quad[i * 2 + 1]! - y);
143
+ if (d <= RING_INNER_PX || d > RING_OUTER_PX) continue;
144
+ // OUTSIDE THE BOX ONLY. The annulus around a corner reaches inwards too,
145
+ // and that half belongs to the body move — a press an author aimed at the
146
+ // layer must not rotate it.
147
+ if (Math.hypot(x - cx, y - cy) < Math.hypot(quad[i * 2]! - cx, quad[i * 2 + 1]! - cy)) continue;
148
+ return { angle: Math.atan2(y - cy, x - cx) };
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,152 @@
1
+ // Resolving a shot's INSTANCE targets to real modules, on the stage.
2
+ //
3
+ // WHY IT BELONGS HERE AND NOWHERE ELSE. The shot stage is served through the
4
+ // PROJECT's Vite (`/shot-stage.html` + `/@fs`, shotRoutes.ts) precisely so the
5
+ // three stack resolves to the studio's pinned copies and the project's own
6
+ // modules are reachable. The chrome cannot do it (it is a prebuilt static
7
+ // bundle with no view of the project graph) and @dailies-studio/shot must not
8
+ // (codegen supplies static imports there). This is a faithful sibling of the
9
+ // canvas lane's `embedModules.ts`, and it is deliberately a sibling rather than
10
+ // a shared module: the two chromes depend on neither each other nor a third
11
+ // package, and the whole file is forty lines of dynamic import.
12
+ //
13
+ // TWO LOADS PER COMPONENT, mirroring codegen exactly (shot codegen emit.ts):
14
+ // the COMPONENT through `virtual:studio-components`, whose `load()` already
15
+ // resolves the named export and handles BUILTINS (bare
16
+ // package specifiers, not project paths) — hand-rolling a URL
17
+ // here would get exactly that case wrong.
18
+ // the CONTRACT a DECLARED contract is a module, imported from the same
19
+ // specifier codegen writes. A SNAPSHOT comes off the document
20
+ // (`snapshotInstanceContracts`, folded in by `ShotRuntime`)
21
+ // and needs no network at all.
22
+ //
23
+ // FAILURE IS NEVER FATAL. Every load is caught: a component that will not
24
+ // resolve is simply absent from the map and its layer draws nothing, with the
25
+ // reason travelling up the stage's existing error channel. A stage that threw
26
+ // here would blank the whole editor over one broken import.
27
+ import {
28
+ controlsModuleOf,
29
+ instanceComponentIdsOf,
30
+ instanceContractBindings,
31
+ instanceModuleOf,
32
+ isBareModuleSpecifier,
33
+ } from "@dailies-studio/shot/doc";
34
+ import type { Doc3D } from "@dailies-studio/shot/doc";
35
+ import type { Contract } from "@dailies-studio/contract";
36
+ import type { ComponentType } from "react";
37
+
38
+ /** The `virtual:studio-components` row shape (cli registry.ts). */
39
+ interface RegistryEntry {
40
+ id: string;
41
+ host: string;
42
+ load(): Promise<unknown>;
43
+ }
44
+
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ export type InstanceModules = Record<string, ComponentType<any>>;
47
+
48
+ export interface InstanceResolution {
49
+ instances: InstanceModules;
50
+ contracts: Record<string, Contract>;
51
+ /** One line per component that could not be resolved. Empty when everything
52
+ * loaded (or the document has no instances). */
53
+ errors: string[];
54
+ }
55
+
56
+ const EMPTY: InstanceResolution = { instances: {}, contracts: {}, errors: [] };
57
+
58
+ /** Module-level caches: the stage re-resolves on every document identity change
59
+ * (an inspector keystroke), and a dynamic import is idempotent but a REJECTED
60
+ * one is not cheap to retry in a loop. */
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ const componentCache = new Map<string, Promise<ComponentType<any> | null>>();
63
+ const contractCache = new Map<string, Promise<Contract | null>>();
64
+
65
+ /** The registry's URL, not its module ID. `virtual:studio-components` is
66
+ * resolvable only by the studio server's own Vite plugin, so a bare specifier
67
+ * would be a hard build error in every OTHER Vite that sees this file. Held in
68
+ * a const so Vite's import analysis cannot see a literal and try. */
69
+ const COMPONENT_REGISTRY_URL = "/@id/virtual:studio-components";
70
+
71
+ async function registry(): Promise<Record<string, RegistryEntry>> {
72
+ const mod = (await import(/* @vite-ignore */ COMPONENT_REGISTRY_URL)) as {
73
+ components?: Record<string, RegistryEntry>;
74
+ };
75
+ return mod.components ?? {};
76
+ }
77
+
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ function loadComponent(id: string): Promise<ComponentType<any> | null> {
80
+ const hit = componentCache.get(id);
81
+ if (hit !== undefined) return hit;
82
+ const p = (async () => {
83
+ const entry = (await registry())[id];
84
+ if (entry === undefined) return null;
85
+ const value = await entry.load();
86
+ // A vanished export resolves to undefined; treat it as "not resolvable"
87
+ // rather than rendering `undefined` into the tree.
88
+ return typeof value === "function" || (typeof value === "object" && value !== null)
89
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ (value as ComponentType<any>)
91
+ : null;
92
+ })().catch(() => null);
93
+ componentCache.set(id, p);
94
+ return p;
95
+ }
96
+
97
+ function loadDeclaredContract(specifier: string): Promise<Contract | null> {
98
+ const hit = contractCache.get(specifier);
99
+ if (hit !== undefined) return hit;
100
+ const url = isBareModuleSpecifier(specifier) ? specifier : `/${specifier}`;
101
+ const p = (async () => {
102
+ const mod = (await import(/* @vite-ignore */ url)) as { controls?: unknown };
103
+ const controls = mod.controls;
104
+ return controls !== null && typeof controls === "object" && "keys" in controls
105
+ ? (controls as Contract)
106
+ : null;
107
+ })().catch(() => null);
108
+ contractCache.set(specifier, p);
109
+ return p;
110
+ }
111
+
112
+ /** Resolve every instance target in `doc`. Pure I/O — no React, no state — so
113
+ * the caller owns when it runs and what it does with a partial answer. */
114
+ export async function resolveInstanceModules(doc: Doc3D): Promise<InstanceResolution> {
115
+ const ids = instanceComponentIdsOf(doc);
116
+ if (ids.length === 0) return EMPTY;
117
+
118
+ const instances: InstanceModules = {};
119
+ const errors: string[] = [];
120
+ await Promise.all(
121
+ ids.map(async (id) => {
122
+ const comp = await loadComponent(id);
123
+ if (comp === null) {
124
+ errors.push(`instance "${id}" could not be loaded from the project — its layer is empty.`);
125
+ return;
126
+ }
127
+ instances[id] = comp;
128
+ }),
129
+ );
130
+
131
+ // Only the DECLARED ones. `ShotRuntime` folds the document's own snapshots in
132
+ // underneath, so there is nothing to merge here and no chance of this half
133
+ // shadowing a snapshot with an undefined.
134
+ const contracts: Record<string, Contract> = {};
135
+ await Promise.all(
136
+ instanceContractBindings(doc).map(async ({ component, ref }) => {
137
+ if (ref.source === "snapshot") return;
138
+ const specifier = controlsModuleOf(instanceModuleOf(component), ref.source);
139
+ const contract = await loadDeclaredContract(specifier);
140
+ if (contract === null) {
141
+ errors.push(
142
+ `instance "${component}" declares its contract in ${JSON.stringify(specifier)}, which ` +
143
+ `did not load — its props render unmapped here.`,
144
+ );
145
+ return;
146
+ }
147
+ contracts[component] = contract;
148
+ }),
149
+ );
150
+
151
+ return { instances, contracts, errors };
152
+ }
@@ -0,0 +1,172 @@
1
+ // Turning a pointer into a DESIGN-SPACE delta, which is the one thing the
2
+ // chrome's document math cannot do for itself.
3
+ //
4
+ // ── THE V-vs-G RULE, WHICH IS THE WHOLE FILE ────────────────────────────
5
+ //
6
+ // The 2D lane could say "screen delta ÷ zoom" because everything was coplanar.
7
+ // Here each layer sits at its OWN depth under a perspective camera, so a screen
8
+ // delta maps to a different world delta per layer — and there is no single
9
+ // correct answer, because two different gestures are being asked for:
10
+ //
11
+ // SELECT (V) unprojects onto the LAYER'S OWN tilted plane. Dragging a tilted
12
+ // layer slides it along its own surface, which is what "move this thing"
13
+ // means once the thing is a plane in space.
14
+ //
15
+ // MOVE (G) unprojects onto the plane through the layer's origin PARALLEL to
16
+ // the export frame. Flat screen space: z never changes and tilt is ignored,
17
+ // which is what "move it on the canvas" means.
18
+ //
19
+ // Two `Ray.intersectPlane` calls. One plane constructor different. That is the
20
+ // entire distinction, and stating it as two planes rather than as two code
21
+ // paths is what keeps it from drifting.
22
+ import { Plane, Vector3 } from "three";
23
+ import type { Ray } from "three";
24
+
25
+ export type MovePlane = "layer" | "frame";
26
+
27
+ /**
28
+ * The plane a gesture unprojects onto.
29
+ *
30
+ * `matrixWorld` is the layer group's, so its third basis column is the layer's
31
+ * own normal — no separate rotation bookkeeping, and a layer flipped or tilted
32
+ * past 90 degrees still gets its own plane rather than a mirrored one.
33
+ */
34
+ export function gesturePlane(kind: MovePlane, matrixWorld: { elements: ArrayLike<number> }): Plane {
35
+ const e = matrixWorld.elements;
36
+ const origin = new Vector3(e[12]!, e[13]!, e[14]!);
37
+ if (kind === "frame") {
38
+ // Parallel to the export frame, through the layer's own depth: the design
39
+ // z = 0 plane's normal is world +z, and moving the plane to the layer keeps
40
+ // the drag anchored to the thing being dragged rather than to the canvas.
41
+ return new Plane().setFromNormalAndCoplanarPoint(new Vector3(0, 0, 1), origin);
42
+ }
43
+ const normal = new Vector3(e[8]!, e[9]!, e[10]!).normalize();
44
+ return new Plane().setFromNormalAndCoplanarPoint(normal, origin);
45
+ }
46
+
47
+ /** Where a ray meets a plane, or null when it is parallel to it (an edge-on
48
+ * layer) — which is a real case and must not become a NaN delta. */
49
+ export function unproject(ray: Ray, plane: Plane): Vector3 | null {
50
+ const hit = ray.intersectPlane(plane, new Vector3());
51
+ return hit === null || !Number.isFinite(hit.x) ? null : hit;
52
+ }
53
+
54
+ /**
55
+ * The world-space displacement between two rays on one plane, expressed in the
56
+ * DESIGN coordinates the schema uses.
57
+ *
58
+ * The one mapping is undone here and nowhere else: design +y is DOWN and +z is
59
+ * AWAY, so world (x, y, z) reads back as design (x, -y, -z).
60
+ */
61
+ export function designDelta(
62
+ from: Ray,
63
+ to: Ray,
64
+ plane: Plane,
65
+ ): { dx: number; dy: number; dz: number } | null {
66
+ const a = unproject(from, plane);
67
+ const b = unproject(to, plane);
68
+ if (a === null || b === null) return null;
69
+ return { dx: b.x - a.x, dy: -(b.y - a.y), dz: -(b.z - a.z) };
70
+ }
71
+
72
+ /** A point on the gesture's plane, in DESIGN coordinates. The same one mapping
73
+ * `designDelta` undoes, for the absolute point rather than the difference —
74
+ * the union scale needs both, because a ratio is a quotient of distances and
75
+ * not a displacement. */
76
+ export function designPoint(ray: Ray, plane: Plane): [number, number, number] | null {
77
+ const p = unproject(ray, plane);
78
+ return p === null ? null : [p.x, -p.y, -p.z];
79
+ }
80
+
81
+ /**
82
+ * A multi-select's box, in DESIGN px. `x0,y0` is the top-left corner.
83
+ *
84
+ * It is a rectangle rather than a quad because `unionQuad` only hands one back
85
+ * for a selection that makes a rectangle honest — coplanar and axis-parallel.
86
+ */
87
+ export interface DesignBox {
88
+ x0: number;
89
+ y0: number;
90
+ x1: number;
91
+ y1: number;
92
+ }
93
+
94
+ /** Below this the anchor is ON the pivot and every ratio is a division by
95
+ * nothing. In design px, so it is the same threshold at every zoom. */
96
+ const MIN_SCALE_ARM = 1;
97
+
98
+ /**
99
+ * The smallest ratio a drag may report, matching `MIN_INVERTIBLE_SCALE`.
100
+ *
101
+ * Dragging PAST the pivot asks for a negative factor, which `applyGesture`
102
+ * refuses by name — correctly, since `size` has no sign. Clamping here rather
103
+ * than letting the refusal fire means the box stops at the pivot instead of
104
+ * filling the error line on every pointermove of an overshoot, and nothing is
105
+ * lost on the way back out: every emit is a total from the press, so the clamp
106
+ * is re-evaluated from the base and never compounds.
107
+ */
108
+ const MIN_RATIO = 1e-3;
109
+
110
+ /**
111
+ * A drag on the union box's handle, as the ratio-about-a-pivot the document
112
+ * model actually has.
113
+ *
114
+ * ── WHY A RATIO AND NOT EIGHT EDGE DISTANCES ────────────────────────────
115
+ *
116
+ * A `resize` moves ONE layer's edge to the pointer, which is exact for one
117
+ * layer and meaningless for eight: each member sits a different distance from
118
+ * the box's edge, so "the edge travelled 40px" is a different size change for
119
+ * every one of them. What a group resize means is that the whole arrangement
120
+ * grows about a fixed point — one factor, applied to every member's size AND to
121
+ * its offset from that point. That is `GestureDelta`'s `scale`.
122
+ *
123
+ * ── THE ANCHOR IS THE PRESS, NOT THE HANDLE ─────────────────────────────
124
+ *
125
+ * The grab radius is 10px, so a press is near the handle and not on it.
126
+ * Measuring from the handle's own corner would make the box jump by up to that
127
+ * distance on the first pointermove; measuring from where the author actually
128
+ * pressed starts every drag at a ratio of exactly 1.
129
+ *
130
+ * Returns null when the anchor sits on the pivot along the axis being scaled —
131
+ * a division by nothing, which is a real case for a press dead-centre on an
132
+ * edge handle with ALT held, and NaN is the alternative.
133
+ */
134
+ export function unionScale(
135
+ box: DesignBox,
136
+ handle: { sx: number; sy: number },
137
+ anchor: readonly [number, number],
138
+ travel: { dx: number; dy: number },
139
+ aboutCentre: boolean,
140
+ ): { ratio: number; axis: "x" | "y" | "both"; pivot: [number, number] } | null {
141
+ const midX = (box.x0 + box.x1) / 2;
142
+ const midY = (box.y0 + box.y1) / 2;
143
+ // The corner OPPOSITE the one grabbed, which is what stays put. An edge
144
+ // handle has one sign of zero, and negating zero gives the box's own middle
145
+ // on that axis — exactly the "opposite edge" pivot, with no second rule.
146
+ const pivot: [number, number] = aboutCentre
147
+ ? [midX, midY]
148
+ : [
149
+ handle.sx === 0 ? midX : handle.sx < 0 ? box.x1 : box.x0,
150
+ handle.sy === 0 ? midY : handle.sy < 0 ? box.y1 : box.y0,
151
+ ];
152
+ const ax = anchor[0] - pivot[0];
153
+ const ay = anchor[1] - pivot[1];
154
+ const bx = ax + travel.dx;
155
+ const by = ay + travel.dy;
156
+ // A corner scales UNIFORMLY, by the ratio of the two distances from the
157
+ // pivot. One `ratio` cannot express the independent x and y a single-layer
158
+ // corner resize gives, and a uniform group scale is both the honest reading
159
+ // of one number and what Blender's `S` does.
160
+ if (handle.sx !== 0 && handle.sy !== 0) {
161
+ const a = Math.hypot(ax, ay);
162
+ if (a < MIN_SCALE_ARM) return null;
163
+ return { ratio: Math.max(Math.hypot(bx, by) / a, MIN_RATIO), axis: "both", pivot };
164
+ }
165
+ // An edge handle names ONE of the box's axes, and the box's axes are the
166
+ // design axes — `unionQuad` refuses any selection where they are not.
167
+ const axis = handle.sx !== 0 ? "x" : "y";
168
+ const a = axis === "x" ? ax : ay;
169
+ const b = axis === "x" ? bx : by;
170
+ if (Math.abs(a) < MIN_SCALE_ARM) return null;
171
+ return { ratio: Math.max(b / a, MIN_RATIO), axis, pivot };
172
+ }
@@ -0,0 +1,60 @@
1
+ // Pan and zoom of the working area. Pure: (event, view) -> view.
2
+ //
3
+ // It writes only `ShotView`, never the camera, because `view.ts` owns the one
4
+ // conversion into `setViewOffset`. Two places computing a camera from a gesture
5
+ // is how a lane ends up with a frame border that disagrees with the render.
6
+ import { clampZoom, zoomAt } from "../view.js";
7
+ import type { FrameSize, ShotView, Viewport } from "../view.js";
8
+
9
+ /**
10
+ * A wheel event.
11
+ *
12
+ * ctrl/meta + wheel is a browser pinch-zoom gesture — trackpads report a pinch
13
+ * as exactly that, so honouring it is what makes two-finger zoom work at all.
14
+ * A plain wheel PANS, which is what every design tool does and what makes a
15
+ * trackpad's two-finger scroll usable.
16
+ *
17
+ * `deltaMode` is honoured because Firefox reports lines (1) and pages (2) where
18
+ * Chrome reports pixels (0); ignoring it makes the same gesture ~16x weaker on
19
+ * one browser.
20
+ */
21
+ export function wheelToView(
22
+ event: {
23
+ deltaX: number;
24
+ deltaY: number;
25
+ deltaMode: number;
26
+ ctrlKey: boolean;
27
+ metaKey: boolean;
28
+ },
29
+ point: readonly [number, number],
30
+ view: ShotView,
31
+ viewport: Viewport,
32
+ frame: FrameSize,
33
+ ): ShotView {
34
+ const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.height : 1;
35
+ const dx = event.deltaX * scale;
36
+ const dy = event.deltaY * scale;
37
+ if (event.ctrlKey || event.metaKey) {
38
+ // Exponential, so a fixed wheel notch is a fixed RATIO at every zoom —
39
+ // linear steps crawl when zoomed out and jump when zoomed in.
40
+ return zoomAt(view, viewport, frame, point, clampZoom(view.zoom * Math.exp(-dy / 320)));
41
+ }
42
+ return { ...view, panX: view.panX - dx, panY: view.panY - dy };
43
+ }
44
+
45
+ /** A pan drag: the view at press, plus the pointer's total displacement. Always
46
+ * measured from the PRESS, never accumulated per move, so a coalesced move or
47
+ * a clamp cannot make the canvas drift away from the cursor. */
48
+ export function panBy(start: ShotView, deltaX: number, deltaY: number): ShotView {
49
+ return { ...start, panX: start.panX + deltaX, panY: start.panY + deltaY };
50
+ }
51
+
52
+ /**
53
+ * Does this pointer event start a PAN rather than a selection?
54
+ *
55
+ * Middle button, or space held. Both are the conventions a designer already
56
+ * has, and neither collides with a left-drag marquee.
57
+ */
58
+ export function isPanGesture(event: { button: number }, spaceHeld: boolean): boolean {
59
+ return event.button === 1 || spaceHeld;
60
+ }