cozyclay 1.0.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 (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -0,0 +1,653 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { Text } from "@react-three/drei";
4
+ import * as THREE from "three";
5
+ import { aimAt } from "./controls.jsx";
6
+ import { PLAN_LAYER } from "./dualview.jsx";
7
+ import { objectSize } from "./scene-objects.js";
8
+
9
+ const ROOM_LIMIT = 11; // stay inside the set walls
10
+ const ACTOR_LIMIT = 4; // matches the Subject sliders' range
11
+ const PUCK_R = 0.34;
12
+ const GRAB_R = 1.0; // the pick radius, well beyond the drawn disc
13
+ const HANDLE_DIST = 1.25; // how far the facing handle sits from the centre
14
+ const HANDLE_R = 0.14;
15
+ const HANDLE_GRAB = 0.5;
16
+
17
+ /** yaw that makes the character's local +Z face the given direction */
18
+ const yawToward = (dx, dz) => Math.atan2(dx, dz);
19
+ const wrapDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
20
+ /** world position of a puck's facing handle */
21
+ const handleAt = (e) => {
22
+ const distance = e.handleDist ?? HANDLE_DIST;
23
+ return { x: e.x + distance * Math.sin(e.yaw), z: e.z + distance * Math.cos(e.yaw) };
24
+ };
25
+ const near = (ax, az, bx, bz, r) => (ax - bx) ** 2 + (az - bz) ** 2 < r * r;
26
+ const insideFootprint = (point, entity, padding = 0.2) => {
27
+ const dx = point.x - entity.x;
28
+ const dz = point.z - entity.z;
29
+ const cos = Math.cos(entity.yaw);
30
+ const sin = Math.sin(entity.yaw);
31
+ const localX = dx * cos - dz * sin;
32
+ const localZ = dx * sin + dz * cos;
33
+ return (
34
+ Math.abs(localX) <= entity.footprint.width / 2 + padding &&
35
+ Math.abs(localZ) <= entity.footprint.depth / 2 + padding
36
+ );
37
+ };
38
+
39
+ /** The lens cone, so you can see what the camera covers without leaving the plan. */
40
+ function FrustumWedge({ fovDeg, active }) {
41
+ const geometry = useMemo(() => new THREE.BufferGeometry(), []);
42
+ const half = (fovDeg * Math.PI) / 180 / 2;
43
+ // a 16:9 frame is wider than the vertical fov it is specified by
44
+ const spread = Math.atan(Math.tan(half) * (16 / 9));
45
+ const reach = 6;
46
+ useEffect(() => {
47
+ const points = new Float32Array([
48
+ 0, 0, 0,
49
+ -Math.sin(spread) * reach, 0, -Math.cos(spread) * reach,
50
+ Math.sin(spread) * reach, 0, -Math.cos(spread) * reach,
51
+ ]);
52
+ geometry.setAttribute("position", new THREE.BufferAttribute(points, 3));
53
+ geometry.computeVertexNormals();
54
+ }, [geometry, spread]);
55
+
56
+ return (
57
+ <mesh geometry={geometry} position={[0, 0.02, 0]} renderOrder={8}>
58
+ <meshBasicMaterial
59
+ color="#4e9fb3"
60
+ transparent
61
+ opacity={active ? 0.32 : 0.18}
62
+ side={THREE.DoubleSide}
63
+ depthWrite={false}
64
+ depthTest={false}
65
+ />
66
+ </mesh>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * A staging puck: drag the body to slide it, drag the outrigger handle to turn it.
72
+ *
73
+ * Rotation has to be available right here. Reaching for a numeric slider on the
74
+ * far side of the screen to spin an actor is the single most awkward thing a
75
+ * plan board can ask for, so the facing indicator doubles as the grab point.
76
+ *
77
+ * Purely visual — picking is done analytically against circles on the floor,
78
+ * because these live in a viewport that is not the one driving R3F's events.
79
+ */
80
+ function Puck({ color, dragging, turning, showBody = true, handleDist = HANDLE_DIST }) {
81
+ return (
82
+ <group>
83
+ {showBody && (
84
+ <>
85
+ <mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
86
+ <circleGeometry args={[PUCK_R, 6, Math.PI / 6]} />
87
+ <meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} depthTest={false} />
88
+ </mesh>
89
+ <mesh position={[0, 0.04, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
90
+ <ringGeometry
91
+ args={[
92
+ PUCK_R * (dragging ? 1.24 : 1.14),
93
+ PUCK_R * (dragging ? 1.45 : 1.32),
94
+ 6,
95
+ 1,
96
+ Math.PI / 6,
97
+ ]}
98
+ />
99
+ <meshBasicMaterial
100
+ color={color}
101
+ transparent
102
+ opacity={dragging ? 0.9 : 0.46}
103
+ depthWrite={false}
104
+ depthTest={false}
105
+ />
106
+ </mesh>
107
+ </>
108
+ )}
109
+
110
+ {/* stem: makes the handle read as attached to the puck, not as debris */}
111
+ <mesh position={[0, 0.035, handleDist / 2]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={9}>
112
+ <planeGeometry args={[0.045, handleDist]} />
113
+ <meshBasicMaterial
114
+ color={color}
115
+ transparent
116
+ opacity={turning ? 0.9 : 0.35}
117
+ depthWrite={false}
118
+ depthTest={false}
119
+ />
120
+ </mesh>
121
+ <mesh position={[0, 0.045, handleDist]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
122
+ <circleGeometry args={[turning ? HANDLE_R * 1.2 : HANDLE_R, 4, Math.PI / 4]} />
123
+ <meshBasicMaterial color={color} depthWrite={false} depthTest={false} />
124
+ </mesh>
125
+ <mesh position={[0, 0.044, handleDist]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
126
+ <ringGeometry args={[HANDLE_R * 1.42, HANDLE_R * 1.62, 4, 1, Math.PI / 4]} />
127
+ <meshBasicMaterial
128
+ color={color}
129
+ transparent
130
+ opacity={turning ? 0.85 : 0.38}
131
+ depthWrite={false}
132
+ depthTest={false}
133
+ />
134
+ </mesh>
135
+ </group>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Floor-plan annotation. Lies flat on the deck so it reads from straight above.
141
+ *
142
+ * Short by design: full names ran into each other the moment two actors stood
143
+ * closer than the width of the word, which in a two-shot is always.
144
+ */
145
+ function PlanLabel({ text, color, offset = -0.72 }) {
146
+ return (
147
+ <Text
148
+ position={[0, 0.05, offset]}
149
+ rotation={[-Math.PI / 2, 0, 0]}
150
+ fontSize={0.3}
151
+ color={color}
152
+ anchorX="center"
153
+ anchorY="middle"
154
+ outlineWidth={0.025}
155
+ outlineColor="#0e0d10"
156
+ outlineOpacity={0.72}
157
+ renderOrder={12}
158
+ depthOffset={-1}
159
+ >
160
+ {text}
161
+ </Text>
162
+ );
163
+ }
164
+
165
+ function SceneObjectFootprint({ object, selected, dragging, turning }) {
166
+ const { width, depth } = objectSize(object);
167
+ const handleDist = depth / 2 + 0.65;
168
+ const rotation = (object.rot * Math.PI) / 180;
169
+ return (
170
+ <group position={[object.x, 0, object.z]} rotation={[0, rotation, 0]}>
171
+ <mesh position={[0, 0.032, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
172
+ <planeGeometry args={[width, depth]} />
173
+ <meshBasicMaterial
174
+ color={selected ? "#e7b557" : "#c7836f"}
175
+ transparent
176
+ opacity={selected ? 0.46 : 0.24}
177
+ depthWrite={false}
178
+ depthTest={false}
179
+ />
180
+ </mesh>
181
+ <mesh position={[0, 0.04, 0]} renderOrder={11}>
182
+ <boxGeometry args={[width, 0.02, depth]} />
183
+ <meshBasicMaterial color={selected ? "#d99725" : "#9b695a"} wireframe depthTest={false} />
184
+ </mesh>
185
+ <group position={[0, 0, depth / 2 + 0.45]} rotation={[0, -rotation, 0]}>
186
+ <PlanLabel
187
+ text={object.name.slice(0, 8).toUpperCase()}
188
+ color={selected ? "#b37610" : "#7c574d"}
189
+ offset={0}
190
+ />
191
+ </group>
192
+ {selected && <Puck color="#e7b557" showBody={false} handleDist={handleDist} dragging={dragging} turning={turning} />}
193
+ </group>
194
+ );
195
+ }
196
+
197
+ const WAYPOINT_COLOR = "#6f9f86";
198
+ const RAIL_COLOR = "#a78bfa"; // the camera's violet, same identity as its timeline lane
199
+
200
+ /**
201
+ * The drawn camera rail (and the live stroke while drawing): a flat violet
202
+ * line on the deck. Display only — authoring happens through the pointer
203
+ * handlers, exactly like the pucks.
204
+ */
205
+ function CameraRailLine({ points, live = false }) {
206
+ const geometry = useMemo(() => {
207
+ if (!points || points.length < 2) return null;
208
+ const positions = new Float32Array(points.length * 3);
209
+ points.forEach((p, i) => {
210
+ positions[i * 3] = p.x;
211
+ positions[i * 3 + 1] = 0.03;
212
+ positions[i * 3 + 2] = p.z;
213
+ });
214
+ const g = new THREE.BufferGeometry();
215
+ g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
216
+ return g;
217
+ }, [points]);
218
+ useEffect(() => () => geometry?.dispose(), [geometry]);
219
+ if (!geometry) return null;
220
+ const first = points[0];
221
+ const last = points[points.length - 1];
222
+ return (
223
+ <group>
224
+ <line geometry={geometry} renderOrder={9}>
225
+ <lineBasicMaterial color={RAIL_COLOR} transparent opacity={live ? 0.5 : 0.85} depthWrite={false} depthTest={false} />
226
+ </line>
227
+ {!live && [first, last].map((p, i) => (
228
+ <mesh key={i} position={[p.x, 0.04, p.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
229
+ <circleGeometry args={[0.14, 16]} />
230
+ <meshBasicMaterial color={RAIL_COLOR} depthWrite={false} depthTest={false} />
231
+ </mesh>
232
+ ))}
233
+ </group>
234
+ );
235
+ }
236
+
237
+ /**
238
+ * The root path: numbered dots at each waypoint's floor position, connected
239
+ * in chronological frame order. Lies flat on the deck like the pucks, so it
240
+ * reads from straight above and never enters shot-camera exports.
241
+ */
242
+ function WaypointPath({ waypoints, start, activeWaypointFrame }) {
243
+ const line = useMemo(() => {
244
+ if (waypoints.length < 1) return null;
245
+ const pathPoints = [{ x: start.x, z: start.z }, ...waypoints];
246
+ const positions = new Float32Array(pathPoints.length * 3);
247
+ pathPoints.forEach((w, i) => {
248
+ positions[i * 3] = w.x;
249
+ positions[i * 3 + 1] = 0.02;
250
+ positions[i * 3 + 2] = w.z;
251
+ });
252
+ const geometry = new THREE.BufferGeometry();
253
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
254
+ return geometry;
255
+ }, [start.x, start.z, waypoints]);
256
+
257
+ return (
258
+ <group>
259
+ {line && (
260
+ <line geometry={line} renderOrder={9}>
261
+ <lineBasicMaterial color={WAYPOINT_COLOR} transparent opacity={0.55} depthWrite={false} depthTest={false} />
262
+ </line>
263
+ )}
264
+ {waypoints.map((w, i) => {
265
+ const active = w.frame === activeWaypointFrame;
266
+ return (
267
+ <group key={w.frame} position={[w.x, 0, w.z]}>
268
+ <mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
269
+ <circleGeometry args={[active ? 0.2 : 0.15, 20]} />
270
+ <meshBasicMaterial color={active ? "#ffd76c" : WAYPOINT_COLOR} depthWrite={false} depthTest={false} />
271
+ </mesh>
272
+ <Text
273
+ position={[0, 0.05, 0.3]}
274
+ rotation={[-Math.PI / 2, 0, 0]}
275
+ fontSize={0.26}
276
+ color={active ? "#ffd76c" : WAYPOINT_COLOR}
277
+ anchorX="center"
278
+ anchorY="middle"
279
+ outlineWidth={0.04}
280
+ outlineColor="#0e0d10"
281
+ outlineOpacity={0.85}
282
+ renderOrder={12}
283
+ depthOffset={-1}
284
+ >
285
+ {i + 1}
286
+ </Text>
287
+ </group>
288
+ );
289
+ })}
290
+ </group>
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Top-down staging board. Drag a puck to move it, drag its handle to turn it.
296
+ *
297
+ * Picking is analytic rather than raycast-against-meshes: the board lives in a
298
+ * second viewport with its own camera, and R3F's event system only knows about
299
+ * the default one. Everything here sits on the y=0 plane, so a ground-plane
300
+ * intersection plus circle tests is both simpler and exact.
301
+ *
302
+ * Dragging is tracked on `window`, not on the host element: an element only
303
+ * reports moves that still hit it, so a fast drag off the edge silently strands
304
+ * the puck.
305
+ */
306
+ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, charA, setCharA, charB, setCharB, showB, waypoints, activeWaypointFrame, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects = [], selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, cameraRailPoints = null, railDraw = false, onRailStroke }) {
307
+ const [drag, setDrag] = useState(null); // { id, mode }
308
+ // live stroke while the rail is being drawn; world XZ, display only
309
+ const [railStroke, setRailStroke] = useState(null);
310
+ const rootRef = useRef();
311
+ const camPos = useRef();
312
+ const camRot = useRef();
313
+ const dragRef = useRef(null);
314
+ // Numbered waypoints are direct-manipulation handles. Empty floor presses
315
+ // intentionally do nothing; root keyframes are authored in the timeline.
316
+ const tools = useMemo(
317
+ () => ({
318
+ raycaster: new THREE.Raycaster(),
319
+ plane: new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
320
+ pointer: new THREE.Vector2(),
321
+ hit: new THREE.Vector3(),
322
+ }),
323
+ [],
324
+ );
325
+
326
+ // The overlay belongs to the plan camera alone. Without this the pucks would
327
+ // be drawn flat across the floor of the shot — and into exported frames.
328
+ useLayoutEffect(() => {
329
+ rootRef.current?.traverse((o) => o.layers.set(PLAN_LAYER));
330
+ });
331
+
332
+ // The drag listeners must never be re-registered mid-drag: their cleanup
333
+ // clears dragRef, so a dep that changes while dragging (charA.x does, on the
334
+ // very first move) would kill the drag after one frame. Read live values
335
+ // through a ref and keep the effect's deps stable.
336
+ const latest = useRef({ charA, charB, showB, waypoints, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke });
337
+ latest.current = { charA, charB, showB, waypoints, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke };
338
+
339
+ const targets = () => {
340
+ const { charA: a, charB: b, showB: two } = latest.current;
341
+ const cam = shotCamRef.current;
342
+ const out = [];
343
+ if (cam) out.push({ id: "cam", x: cam.position.x, z: cam.position.z, yaw: look.current.yaw });
344
+ out.push({ id: "a", x: a.x, z: a.z, yaw: (a.rot * Math.PI) / 180 });
345
+ if (two) out.push({ id: "b", x: b.x, z: b.z, yaw: (b.rot * Math.PI) / 180 });
346
+ for (const object of latest.current.sceneObjects) {
347
+ const { width, depth } = objectSize(object);
348
+ out.push({
349
+ id: `object:${object.id}`,
350
+ objectId: object.id,
351
+ x: object.x,
352
+ z: object.z,
353
+ yaw: (object.rot * Math.PI) / 180,
354
+ rot: object.rot,
355
+ footprint: { width, depth },
356
+ handleDist: depth / 2 + 0.65,
357
+ selected: object.id === latest.current.selectedSceneObjectId,
358
+ });
359
+ }
360
+ return out;
361
+ };
362
+
363
+ const aimTarget = () => {
364
+ const { charA: a, charB: b, showB: two } = latest.current;
365
+ const y = 1.35;
366
+ if (!two) return { x: a.x, y, z: a.z };
367
+ return { x: (a.x + b.x) / 2, y, z: (a.z + b.z) / 2 };
368
+ };
369
+
370
+ useFrame(() => {
371
+ const cam = shotCamRef.current;
372
+ if (!cam || !camPos.current || !camRot.current) return;
373
+ camPos.current.position.set(cam.position.x, 0, cam.position.z);
374
+ camRot.current.rotation.y = look.current.yaw;
375
+ });
376
+
377
+ useEffect(() => {
378
+ const host = hostRef.current;
379
+ if (!host) return undefined;
380
+ const snap = (v, limit) => THREE.MathUtils.clamp(Math.round(v / 0.05) * 0.05, -limit, limit);
381
+
382
+ /** pointer -> a point on the floor, seen through the plan camera */
383
+ const toFloor = (event) => {
384
+ const cam = planCamRef.current;
385
+ if (!cam) return null;
386
+ const r = host.getBoundingClientRect();
387
+ if (r.width < 2 || r.height < 2) return null;
388
+ tools.pointer.set(
389
+ ((event.clientX - r.left) / r.width) * 2 - 1,
390
+ -((event.clientY - r.top) / r.height) * 2 + 1,
391
+ );
392
+ tools.raycaster.setFromCamera(tools.pointer, cam);
393
+ return tools.raycaster.ray.intersectPlane(tools.plane, tools.hit) ? tools.hit : null;
394
+ };
395
+
396
+ // A generous nearest-point hit target makes the numbered roots draggable
397
+ // directly, without requiring a prior timeline selection/focus step.
398
+ const pick = (p) => {
399
+ let nearestWaypoint = null;
400
+ let nearestDistance = Infinity;
401
+ for (const waypoint of latest.current.waypoints) {
402
+ const distance = (p.x - waypoint.x) ** 2 + (p.z - waypoint.z) ** 2;
403
+ if (distance < nearestDistance) {
404
+ nearestWaypoint = waypoint;
405
+ nearestDistance = distance;
406
+ }
407
+ }
408
+ if (nearestWaypoint && nearestDistance < 0.5 ** 2) {
409
+ return { id: `waypoint:${nearestWaypoint.frame}`, mode: "waypoint", origin: nearestWaypoint };
410
+ }
411
+ // Overlapping pucks (S1 sitting on the camera, like a close-up
412
+ // setup): the FIRST list entry used to win, so grabbing S1 near the
413
+ // cam moved the camera instead and the character never budged —
414
+ // "the puck doesn't move". Pick the NEAREST target instead.
415
+ const list = targets();
416
+ let best = null;
417
+ for (const e of list) {
418
+ const h = handleAt(e);
419
+ const dh = (p.x - h.x) ** 2 + (p.z - h.z) ** 2;
420
+ if ((!e.objectId || e.selected) && dh < HANDLE_GRAB * HANDLE_GRAB && (!best || dh < best.d)) best = { id: e.id, mode: "turn", origin: e, d: dh };
421
+ const db = (p.x - e.x) ** 2 + (p.z - e.z) ** 2;
422
+ const bodyHit = e.objectId ? insideFootprint(p, e) : db < GRAB_R * GRAB_R;
423
+ if (bodyHit && (!best || db < best.d)) best = { id: e.id, mode: "move", origin: e, d: db };
424
+ }
425
+ if (best) return { id: best.id, mode: best.mode, origin: best.origin };
426
+ return null;
427
+ };
428
+
429
+ const onDown = (event) => {
430
+ if (event.button !== 0) return;
431
+ // Rail drawing owns the pointer: a stroke starts anywhere on the
432
+ // deck, so puck picking would fight every stroke that crosses one.
433
+ if (latest.current.railDraw) {
434
+ const start = toFloor(event);
435
+ if (!start) return;
436
+ event.preventDefault();
437
+ event.stopPropagation();
438
+ dragRef.current = { mode: "rail", stroke: [{ x: start.x, z: start.z }] };
439
+ setDrag({ id: "rail", mode: "rail" });
440
+ setRailStroke([{ x: start.x, z: start.z }]);
441
+ host.style.cursor = "crosshair";
442
+ return;
443
+ }
444
+ const p = toFloor(event);
445
+ const grip = p && pick(p);
446
+ if (!grip) return;
447
+ event.preventDefault();
448
+ event.stopPropagation();
449
+ dragRef.current = grip;
450
+ setDrag({ id: grip.id, mode: grip.mode });
451
+ if (grip.mode === "waypoint") latest.current.onSelectWaypoint?.(grip.origin.frame);
452
+ else latest.current.onSelectEntity?.(grip.id);
453
+ // Scene-object grips only: select FIRST (the call above), then
454
+ // begin — App's select handler settles any open transaction, so
455
+ // beginning before the select would leak the freshly-issued token
456
+ // instantly (plan §6.4). Camera, character and waypoint grips
457
+ // write separate state and must never open a scene transaction.
458
+ if (grip.origin.objectId) {
459
+ dragRef.current.token = latest.current.onObjectMoveStart?.({
460
+ owner: "plan",
461
+ cancel: () => teardownDrag(grip),
462
+ });
463
+ }
464
+ host.style.cursor = grip.mode === "turn" ? "ew-resize" : "grabbing";
465
+ };
466
+
467
+ // hover is the only cue that these are handles at all
468
+ const onHover = (event) => {
469
+ if (dragRef.current) return;
470
+ if (latest.current.railDraw) {
471
+ host.style.cursor = "crosshair";
472
+ return;
473
+ }
474
+ const p = toFloor(event);
475
+ const grip = p && pick(p);
476
+ host.style.cursor = !grip
477
+ ? "default"
478
+ : grip.mode === "turn"
479
+ ? "ew-resize"
480
+ : "grab";
481
+ };
482
+
483
+ const onMove = (event) => {
484
+ const grip = dragRef.current;
485
+ if (!grip) return;
486
+ const p = toFloor(event);
487
+ if (!p) return;
488
+
489
+ if (grip.mode === "rail") {
490
+ // sample sparsely; RDP simplifies the rest on commit
491
+ const tail = grip.stroke[grip.stroke.length - 1];
492
+ if (Math.hypot(p.x - tail.x, p.z - tail.z) > 0.06) {
493
+ grip.stroke.push({ x: p.x, z: p.z });
494
+ setRailStroke([...grip.stroke]);
495
+ }
496
+ return;
497
+ }
498
+
499
+ if (grip.mode === "waypoint") {
500
+ latest.current.onMoveWaypoint?.(grip.origin.frame, snap(p.x, ROOM_LIMIT), snap(p.z, ROOM_LIMIT));
501
+ return;
502
+ }
503
+
504
+ if (grip.mode === "turn") {
505
+ const dx = p.x - grip.origin.x;
506
+ const dz = p.z - grip.origin.z;
507
+ // Right on the pivot there is no direction to read, and atan2 of a
508
+ // near-zero vector snaps wildly. Hold the last angle instead.
509
+ if (dx * dx + dz * dz < 0.04) return;
510
+ const yaw = yawToward(dx, dz);
511
+ if (grip.id === "cam") {
512
+ // a manual aim; the operator has decided to point off-subject
513
+ look.current.yaw = yaw;
514
+ return;
515
+ }
516
+ // 5° detents, because actors are blocked to clean angles
517
+ const deg = wrapDeg(Math.round((yaw * 180) / Math.PI / 5) * 5);
518
+ if (grip.origin.objectId) latest.current.onMoveSceneObject?.(grip.origin.objectId, { rot: deg }, grip.token);
519
+ else if (grip.id === "a") setCharA((prev) => ({ ...prev, rot: deg }));
520
+ else setCharB((prev) => ({ ...prev, rot: deg }));
521
+ return;
522
+ }
523
+
524
+ if (grip.id === "cam") {
525
+ const cam = shotCamRef.current;
526
+ if (!cam) return;
527
+ cam.position.set(snap(p.x, ROOM_LIMIT), cam.position.y, snap(p.z, ROOM_LIMIT));
528
+ // Sliding the camera across the floor plan must keep it pointed at the
529
+ // cast. Without this every drag leaves the lens staring at a wall.
530
+ const angles = aimAt(cam.position, aimTarget());
531
+ look.current.yaw = angles.yaw;
532
+ look.current.pitch = angles.pitch;
533
+ return;
534
+ }
535
+ const next = { x: snap(p.x, ACTOR_LIMIT), z: snap(p.z, ACTOR_LIMIT) };
536
+ if (grip.origin.objectId) latest.current.onMoveSceneObject?.(grip.origin.objectId, {
537
+ x: snap(p.x, ROOM_LIMIT),
538
+ z: snap(p.z, ROOM_LIMIT),
539
+ }, grip.token);
540
+ else if (grip.id === "a") setCharA((prev) => ({ ...prev, ...next }));
541
+ else setCharB((prev) => ({ ...prev, ...next }));
542
+ };
543
+
544
+ // Teardown-only close: null the drag ref and reset the drag visuals.
545
+ // Never closes the transaction — used by the store's cancel (which is
546
+ // forbidden from calling the end prop back, plan §6.2) as well as the
547
+ // producer's own close paths.
548
+ const teardownDrag = (grip) => {
549
+ if (!grip || dragRef.current !== grip) return;
550
+ dragRef.current = null;
551
+ setDrag(null);
552
+ host.style.cursor = "default";
553
+ };
554
+
555
+ // pointerup, pointercancel, window blur and unmount all COMMIT: the
556
+ // travel already applied is real work the user can see, so it becomes
557
+ // one undo entry rather than being silently reverted (plan §6.3).
558
+ // Escape is the only gesture that rolls back.
559
+ const closeDrag = (commit) => {
560
+ const grip = dragRef.current;
561
+ if (!grip) return;
562
+ if (grip.mode === "rail") {
563
+ // commit hands the raw stroke to the app (which simplifies and
564
+ // splines it); Escape throws the stroke away
565
+ teardownDrag(grip);
566
+ setRailStroke(null);
567
+ if (commit && grip.stroke.length >= 2) latest.current.onRailStroke?.(grip.stroke);
568
+ return;
569
+ }
570
+ const token = grip.token;
571
+ teardownDrag(grip);
572
+ if (token != null) latest.current.onObjectMoveEnd?.(token, { commit });
573
+ };
574
+
575
+ const onUp = () => {
576
+ closeDrag(true);
577
+ };
578
+
579
+ const onCancel = () => closeDrag(true);
580
+
581
+ const onBlur = () => closeDrag(true);
582
+
583
+ const onEscape = (event) => {
584
+ if (event.key !== "Escape") return;
585
+ if (!dragRef.current) return;
586
+ // Capture-phase stopPropagation keeps the same press from also
587
+ // reaching App's Escape-clears-selection handler (plan §7).
588
+ event.stopPropagation();
589
+ closeDrag(false);
590
+ };
591
+
592
+ host.addEventListener("pointerdown", onDown);
593
+ host.addEventListener("pointermove", onHover);
594
+ window.addEventListener("pointermove", onMove);
595
+ window.addEventListener("pointerup", onUp);
596
+ window.addEventListener("pointercancel", onCancel);
597
+ window.addEventListener("blur", onBlur);
598
+ window.addEventListener("keydown", onEscape, true);
599
+ return () => {
600
+ host.removeEventListener("pointerdown", onDown);
601
+ host.removeEventListener("pointermove", onHover);
602
+ window.removeEventListener("pointermove", onMove);
603
+ window.removeEventListener("pointerup", onUp);
604
+ window.removeEventListener("pointercancel", onCancel);
605
+ window.removeEventListener("blur", onBlur);
606
+ window.removeEventListener("keydown", onEscape, true);
607
+ closeDrag(true);
608
+ };
609
+ }, [hostRef, planCamRef, shotCamRef, tools, setCharA, setCharB, look]);
610
+
611
+ const state = (id) => ({ dragging: drag?.id === id && drag.mode === "move", turning: drag?.id === id && drag.mode === "turn" });
612
+
613
+ return (
614
+ <group ref={rootRef}>
615
+ <group ref={camPos}>
616
+ <PlanLabel text="CAM" color="#247da0" />
617
+ <group ref={camRot}>
618
+ <FrustumWedge fovDeg={fovDeg} active={drag?.id === "cam"} />
619
+ <Puck color="#4e9fb3" {...state("cam")} />
620
+ </group>
621
+ </group>
622
+
623
+ <group position={[charA.x, 0, charA.z]}>
624
+ <PlanLabel text="S1" color="#273849" />
625
+ <group rotation={[0, (charA.rot * Math.PI) / 180, 0]}>
626
+ <Puck color="#273849" {...state("a")} />
627
+ </group>
628
+ </group>
629
+
630
+ {showB && (
631
+ <group position={[charB.x, 0, charB.z]}>
632
+ <PlanLabel text="S2" color="#d65f55" />
633
+ <group rotation={[0, (charB.rot * Math.PI) / 180, 0]}>
634
+ <Puck color="#d65f55" {...state("b")} />
635
+ </group>
636
+ </group>
637
+ )}
638
+
639
+ {sceneObjects.map((object) => (
640
+ <SceneObjectFootprint
641
+ key={object.id}
642
+ object={object}
643
+ selected={object.id === selectedSceneObjectId}
644
+ {...state(`object:${object.id}`)}
645
+ />
646
+ ))}
647
+
648
+ {waypoints.length > 0 && <WaypointPath waypoints={waypoints} start={charA} activeWaypointFrame={activeWaypointFrame} />}
649
+ {cameraRailPoints && cameraRailPoints.length > 1 && <CameraRailLine points={cameraRailPoints} />}
650
+ {railStroke && railStroke.length > 1 && <CameraRailLine points={railStroke} live />}
651
+ </group>
652
+ );
653
+ }