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,755 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { GIZMO_LAYER, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
5
+ import { objectSize, rotatePatch, scalePatch, screenRotatePatch, translatePatch, wrapAngle } from "./scene-objects.js";
6
+
7
+ /**
8
+ * The transform gizmo for the selected scene object — the thing every 3D tool
9
+ * has and this one did not: axis arrows to slide an object along X/Y/Z, rings
10
+ * to spin it and boxes to scale it, straight in the shot view, with the plan
11
+ * board's 5 cm / 5° detents so both views agree.
12
+ *
13
+ * Three deliberate choices:
14
+ *
15
+ * - Picking is owned here, not delegated to R3F. The shot camera renders into
16
+ * a letterboxed 16:9 sub-rect of the canvas, so R3F's canvas-wide pointer
17
+ * NDC is vertically off by the size of the bars and small cones would never
18
+ * be hit. The rect the camera actually drew into is what the ray is built
19
+ * from.
20
+ * - The listener sits on `window` in the CAPTURE phase. The fly-camera
21
+ * controls listen on the canvas element itself and were registered first;
22
+ * only a capture-phase listener further up the tree can stop a press on the
23
+ * gizmo — or on an object — from also swinging the camera.
24
+ * - Unity's division of labour: a plain left press SELECTS and nothing else,
25
+ * transforms only ever come from a handle, and a press on empty space clears
26
+ * the selection. Dragging the body used to move it, which made selecting an
27
+ * object — just to read its numbers — nudge the set. The plane handles below
28
+ * are the honest replacement for that.
29
+ * (docs/unity-reference.md §3.2, §6, §9.3, §9.6)
30
+ *
31
+ * The axis rings drive the object's X/Y/Z Euler channels directly. With the
32
+ * yaw-only rotations blocking actually uses that is exact; with all three
33
+ * channels live it is the usual world-axis-to-Euler approximation, same as
34
+ * any DCC's global-space rotate. The outer screen-space ring is different:
35
+ * a roll about the view axis legitimately touches all three channels at
36
+ * once, so its delta is composed onto the current orientation in quaternion
37
+ * space and decomposed back into the same channels (screenRotatePatch).
38
+ */
39
+
40
+ const AXES = [
41
+ { axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
42
+ { axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
43
+ { axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
44
+ ];
45
+ /** the same three directions, addressable by the record's axis key */
46
+ const AXIS_VECTORS = { x: AXES[0].dir, y: AXES[1].dir, z: AXES[2].dir };
47
+ /** Move-tool plane handles. Unity colours each square after the axis it LOCKS,
48
+ * so the blue square (z locked) slides in xy. `normal` is the pinned axis. */
49
+ const PLANES = [
50
+ { id: "xz", axes: ["x", "z"], normal: "y", color: "#54e05c" },
51
+ { id: "xy", axes: ["x", "y"], normal: "z", color: "#3d8bff" },
52
+ { id: "yz", axes: ["y", "z"], normal: "x", color: "#ff5340" },
53
+ ];
54
+ const PLANE_OFFSET = 0.26; // where the square sits along each of its two axes
55
+ const PLANE_SIZE = 0.2;
56
+ const ARROW_LEN = 0.62;
57
+ const SHAFT_R = 0.016;
58
+ const TIP_R = 0.05;
59
+ const TIP_LEN = 0.14;
60
+ const RING_R = 0.52;
61
+ /**
62
+ * The axis rings are drawn as two half-tori instead of one full torus. A full
63
+ * ring is misleading: the half facing away from the camera is pixel-identical
64
+ * to the near half, so a drag started on the far side reads backwards.
65
+ * Depth-sorting the ring against the object would only help when the object
66
+ * is big enough to occlude the far half — the gizmo sits at the object's
67
+ * centre and the ring is only 0.52 m across, so for the small props a
68
+ * blocking tool is full of, the far half projects outside the silhouette and
69
+ * stays ambiguous. Fading it instead (FAR_HALF_ALPHA vs the near half's
70
+ * 0.95) is unambiguous at every object size and camera angle. Only the drawn
71
+ * halves fade — the pick proxy stays one full invisible torus per ring.
72
+ */
73
+ const FAR_HALF_ALPHA = 0.28;
74
+ /** the outer screen-space ring: larger than the axis rings (RING_R) so it
75
+ * encloses them from every angle, and far enough out that its pick band
76
+ * [0.61, 0.75] clears the axis rings' [0.45, 0.59] — the two can never fight
77
+ * for a grab. (§3.3: "a larger circle enclosing the sphere, drawn flat to
78
+ * the viewer" — rotates about the view axis.) */
79
+ const SCREEN_RING_R = 0.68;
80
+ /** Unity's yellow for the handle being dragged — and, after release, for the
81
+ * last-dragged one, until the selection or the tool changes (§3.1). */
82
+ const ACTIVE_COLOR = "#ffd23d";
83
+ const RING_TUBE = 0.022;
84
+ const BOX_SIZE = 0.09; // scale-mode axis knobs
85
+ const CENTRE_BOX = 0.11; // uniform-scale knob at the pivot
86
+ /** Pick proxies. A drawn 1.6 cm shaft is ~3 screen pixels — nothing anyone can
87
+ * reliably grab — so hit testing runs against invisible fat volumes around each
88
+ * arrow, ring and knob, the way every DCC gizmo does it. */
89
+ const PICK_SHAFT_R = 0.09;
90
+ const PICK_TUBE_R = 0.07;
91
+ const PICK_CENTRE_R = 0.16;
92
+ /** metres of gizmo per metre of camera distance: keeps it a constant size on
93
+ * screen whether the object is at the lens or across the room */
94
+ const SCREEN_SCALE = 0.16;
95
+
96
+ /** the object's own centre height, where the gizmo sits */
97
+ function gizmoHeight(object) {
98
+ return (object.y ?? 0) + Math.max(objectSize(object).height / 2, 0.25);
99
+ }
100
+
101
+ /** the handles-Map key a pick entry was registered under — the same
102
+ * expression __gizmoPick reports, so highlights and QA hooks agree on handle
103
+ * identity */
104
+ function handleKey(entry) {
105
+ return entry.axis ?? (entry.plane ? `plane:${entry.plane.id}` : "centre");
106
+ }
107
+
108
+ /**
109
+ * Screen-space ring result: the object rotated `deltaDeg` about the world
110
+ * `viewAxis`, written back as all three Euler channels in one patch. The
111
+ * stored channels are Euler degrees per world axis, so the drag-start
112
+ * orientation is composed with the delta in quaternion space and decomposed
113
+ * again in the same order the renderer uses — exact for any start
114
+ * orientation, where a single-channel approximation would drift. Snap
115
+ * quantizes the accumulated delta to the same 5° grid the axis rings snap
116
+ * their start+delta to (start values are always on the grid themselves).
117
+ */
118
+
119
+ // Path authoring intersects presses with the set floor, exactly where a
120
+ // dropped pin will live.
121
+ const GROUND = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
122
+
123
+ export default function ObjectGizmo({ object, objects = [], mode = "move", snap = true, enabled, paneRef, camRef, onChange, onSelect, onGroundClick, onDragStart, onDragEnd }) {
124
+ const { gl, scene } = useThree();
125
+ const rootRef = useRef(null);
126
+ const handlesRef = useRef(new Map()); // axis -> { mesh (pick proxy), axis, dir }
127
+ const screenRingRef = useRef(null); // the outer ring's group, billboarded to the camera each frame
128
+ const ringGroupsRef = useRef({}); // axis -> the group whose rotation.z picks that ring's near half
129
+ const [activeHandle, setActiveHandle] = useState(null); // the handle under a drag — and, after release, the last one (§3.1)
130
+ const [hoveredHandle, setHoveredHandle] = useState(null); // the handle under the pointer
131
+ const hoverRef = useRef(null); // last hovered key: pointer moves that keep it skip the re-render
132
+ const dragRef = useRef(null);
133
+ const stateRef = useRef(null);
134
+ stateRef.current = { object, objects, mode, snap, onChange, onSelect, onGroundClick, onDragStart, onDragEnd };
135
+ const tools = useMemo(
136
+ () => ({
137
+ raycaster: new THREE.Raycaster(),
138
+ ndc: new THREE.Vector2(),
139
+ hit: new THREE.Vector3(),
140
+ delta: new THREE.Vector3(),
141
+ eye: new THREE.Vector3(),
142
+ origin: new THREE.Vector3(),
143
+ camPos: new THREE.Vector3(),
144
+ }),
145
+ [],
146
+ );
147
+ /** each axis ring's in-plane basis in world space (u, v), for the
148
+ * per-frame near/far split — the torus sweeps cosθ·u + sinθ·v around the
149
+ * axis, and the frame loop only needs the world images of the group's
150
+ * local X and Z */
151
+ const ringBases = useMemo(
152
+ () =>
153
+ AXES.map(({ axis, dir }) => {
154
+ const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
155
+ return {
156
+ axis,
157
+ u: new THREE.Vector3(1, 0, 0).applyQuaternion(quat),
158
+ v: new THREE.Vector3(0, 0, 1).applyQuaternion(quat),
159
+ };
160
+ }),
161
+ [],
162
+ );
163
+
164
+ // Unity keeps the last-dragged handle yellow until the selection or the
165
+ // tool changes (§3.1). The scope the highlight was captured in is compared
166
+ // each render; when either moves, the stored key no longer names a handle
167
+ // in this view. (Render-phase state adjustment: conditional, and it
168
+ // converges because the refs flip first.)
169
+ const activeScopeRef = useRef(null);
170
+ if (activeScopeRef.current && (activeScopeRef.current.id !== object?.id || activeScopeRef.current.mode !== mode)) {
171
+ setActiveHandle(null);
172
+ if (hoverRef.current !== null) {
173
+ hoverRef.current = null;
174
+ setHoveredHandle(null);
175
+ }
176
+ }
177
+ activeScopeRef.current = { id: object?.id, mode };
178
+
179
+ /** pointer -> NDC inside the rect the shot camera actually rendered into */
180
+ const toNdc = (event) => {
181
+ const pane = paneRef?.current;
182
+ const camera = camRef?.current;
183
+ if (!pane || !camera) return null;
184
+ const bounds = pane.getBoundingClientRect();
185
+ if (bounds.width < 2 || bounds.height < 2) return null;
186
+ const rect = fitAspect({ x: bounds.left, y: bounds.top, w: bounds.width, h: bounds.height }, SHOT_ASPECT);
187
+ tools.ndc.set(
188
+ ((event.clientX - rect.x) / rect.w) * 2 - 1,
189
+ -((event.clientY - rect.y) / rect.h) * 2 + 1,
190
+ );
191
+ return Math.abs(tools.ndc.x) <= 1 && Math.abs(tools.ndc.y) <= 1 ? tools.ndc : null;
192
+ };
193
+
194
+ /** the scene object under the pointer, if any (meshes carry the id on an
195
+ * ancestor group, so the hit walks up to find it) */
196
+ const pickObject = () => {
197
+ // Edge linework (EdgesGeometry LineSegments) raycasts with a generous
198
+ // distance threshold, so it would claim pixels far from the solid mesh.
199
+ // Picking walks past lines/points to the first real surface.
200
+ const hits = tools.raycaster.intersectObjects(scene.children, true);
201
+ const hit = hits.find((entry) => entry.object.isMesh);
202
+ if (!hit) return null;
203
+ for (let node = hit.object; node; node = node.parent) {
204
+ if (node.userData?.sceneObjectId) return { id: node.userData.sceneObjectId, point: hit.point.clone() };
205
+ // Characters are click targets too: a namespaced id routes the
206
+ // selection to the hierarchy, so the Inspector owns the controls.
207
+ if (node.userData?.characterPick) return { id: `char:${node.userData.characterPick}`, point: hit.point.clone() };
208
+ }
209
+ return null; // whatever is in front is set, not an object
210
+ };
211
+
212
+ useEffect(() => {
213
+ if (!enabled) return undefined;
214
+
215
+ /** aims the shared raycaster through the pointer; null when the shot
216
+ * camera or the pane is not measurable yet */
217
+ const rayFrom = (event) => {
218
+ const camera = camRef?.current;
219
+ const ndc = camera ? toNdc(event) : null;
220
+ if (!ndc) return null;
221
+ // Picking happens outside the render loop: under demand rendering a
222
+ // programmatic camera move (framing, preset snap) may not have had a
223
+ // frame yet, and setFromCamera reads matrixWorld as-is. Refresh it
224
+ // here so the first pick after an idle gap aims from the true pose.
225
+ // The shot camera's aspect is locked to SHOT_ASPECT by the render
226
+ // loop (dualview); under demand rendering a layout change may not
227
+ // have had a frame yet, leaving a stale projection. Re-apply the
228
+ // render contract here so the pick matches what is on screen.
229
+ camera.aspect = SHOT_ASPECT;
230
+ camera.updateProjectionMatrix();
231
+ camera.updateMatrixWorld();
232
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
233
+ rootRef.current?.updateMatrixWorld(true);
234
+ // object meshes move with the drag record; between frames their
235
+ // matrixWorld can lag the same way, so refresh the scene too
236
+ scene.updateMatrixWorld();
237
+ tools.raycaster.setFromCamera(ndc, camera);
238
+ return { ndc, camera };
239
+ };
240
+
241
+ const applyDrag = (event) => {
242
+ const drag = dragRef.current;
243
+ if (!drag) return;
244
+ if (!rayFrom(event)) return;
245
+ if (!tools.raycaster.ray.intersectPlane(drag.plane, tools.hit)) return;
246
+ const { objects: all, onChange: change, snap: snapOn } = stateRef.current;
247
+ const live = all.find((entry) => entry.id === drag.id);
248
+ if (!live) return;
249
+ // Unity's polarity: a drag is continuous, and Ctrl/Cmd snaps it to the
250
+ // increment. A persistent Snap toggle flips which one the modifier
251
+ // gives you, so the always-on 5 cm grid stays available as a choice
252
+ // instead of a law. (docs/unity-reference.md §5.2, §9.5)
253
+ const snapping = snapOn !== (event.ctrlKey || event.metaKey);
254
+ if (drag.mode === "rotate") {
255
+ tools.delta.subVectors(tools.hit, drag.origin);
256
+ const angle = Math.atan2(tools.delta.dot(drag.binormal), tools.delta.dot(drag.tangent));
257
+ // Accumulate wrapped increments. Reading the raw difference from
258
+ // the drag-start angle instead makes the object snap a full turn
259
+ // the moment the pointer crosses the ring's ±180° seam.
260
+ drag.turned += wrapAngle(((angle - drag.lastAngle) * 180) / Math.PI);
261
+ drag.lastAngle = angle;
262
+ if (drag.axis === "screen") {
263
+ // The screen ring spins about the view axis: compose the
264
+ // delta onto the drag-start orientation in quaternion space
265
+ // and write all three Euler channels back in one patch.
266
+ const patch = screenRotatePatch(drag.start, drag.dir, drag.turned, snapping ? undefined : 0);
267
+ if (patch) change?.(drag.id, patch, drag.token);
268
+ return;
269
+ }
270
+ const patch = rotatePatch(drag.start, drag.axis, drag.turned, snapping ? undefined : 0);
271
+ if (patch) change?.(drag.id, patch, drag.token);
272
+ return;
273
+ }
274
+ if (drag.mode === "scale") {
275
+ // A ratio against the grab point's leverage, so the knob keeps
276
+ // tracking the cursor and a drag toward the pivot shrinks.
277
+ tools.delta.subVectors(tools.hit, drag.origin);
278
+ const travel = tools.delta.dot(drag.dir) - drag.startAlong;
279
+ const patch = scalePatch(drag.start, drag.axis, 1 + travel / drag.reference, snapping ? undefined : 0);
280
+ if (patch) change?.(drag.id, patch, drag.token);
281
+ return;
282
+ }
283
+ tools.delta.subVectors(tools.hit, drag.hitStart);
284
+ if (drag.planeAxes) {
285
+ // Plane handle: two axes travel, the third is pinned. This is what
286
+ // replaced body-dragging — the XZ square is how a set gets dressed.
287
+ const patch = {};
288
+ for (const axis of drag.planeAxes) {
289
+ Object.assign(patch, translatePatch(drag.start, axis, tools.delta.dot(AXIS_VECTORS[axis]), snapping ? undefined : 0));
290
+ }
291
+ change?.(drag.id, patch, drag.token);
292
+ return;
293
+ }
294
+ const patch = translatePatch(drag.start, drag.axis, tools.delta.dot(drag.dir), snapping ? undefined : 0);
295
+ if (patch) change?.(drag.id, patch, drag.token);
296
+ };
297
+
298
+ /** pure teardown: listeners down, drag ref null, cursor reset. This is
299
+ * also the `cancel` handed to the coordinator — teardown only, it never
300
+ * calls the end-style prop, because the coordinator owns the close
301
+ * (§5.2). */
302
+ const teardownDrag = () => {
303
+ if (!dragRef.current) return;
304
+ dragRef.current = null;
305
+ gl.domElement.style.cursor = "";
306
+ window.removeEventListener("pointermove", applyDrag);
307
+ window.removeEventListener("pointerup", onPointerUp);
308
+ window.removeEventListener("pointercancel", onPointerCancel);
309
+ window.removeEventListener("blur", onBlur);
310
+ window.removeEventListener("keydown", onKeyDown, true);
311
+ };
312
+
313
+ /** close the live drag with the given disposition. The token the
314
+ * coordinator issued is presented on the close; without one (the
315
+ * no-props fallback) the close is teardown-only, exactly today's
316
+ * behaviour. */
317
+ const endDrag = (commit) => {
318
+ const drag = dragRef.current;
319
+ if (!drag) return;
320
+ const end = stateRef.current.onDragEnd;
321
+ const token = drag.token;
322
+ teardownDrag();
323
+ if (token != null && end) end(token, { commit });
324
+ };
325
+
326
+ /** pointerup and pointercancel both commit: the travel already applied
327
+ * is work the user watched happen, and pointer loss is not intent to
328
+ * discard (§6.3). */
329
+ const onPointerUp = () => endDrag(true);
330
+ const onPointerCancel = () => endDrag(true);
331
+
332
+ /** window blur commits the drag as one entry — losing focus mid-drag is
333
+ * not an abort gesture, and silently rolling back work the user saw
334
+ * applied is the "undo eats work" failure (§6.3, §14.2). */
335
+ const onBlur = () => endDrag(true);
336
+
337
+ /** Escape cancels an in-flight drag: rollback, listeners down. Capture
338
+ * phase so stopPropagation keeps the same press away from App's
339
+ * Escape-clears-selection handler; with no drag open it returns without
340
+ * touching the event, so App still gets its deselect (§7). */
341
+ const onKeyDown = (event) => {
342
+ if ((event.code !== "Escape" && event.key !== "Escape") || !dragRef.current) return;
343
+ endDrag(false);
344
+ event.preventDefault();
345
+ event.stopPropagation();
346
+ };
347
+
348
+ /** Starts a `kind` drag ("move" / "rotate" / "scale"). The raycaster is
349
+ * already aimed at the pointer by the pick that got us here; `axis` is
350
+ * null for the uniform-scale knob and for plane handles, which carry
351
+ * their own `plane` axes instead. */
352
+ const beginDrag = (kind, axis, dir, camera) => {
353
+ const live = stateRef.current.object;
354
+ if (!live) return false;
355
+ tools.origin.set(live.x, gizmoHeight(live), live.z);
356
+ camera.getWorldDirection(tools.eye);
357
+ let plane;
358
+ let drag;
359
+ if (kind === "rotate") {
360
+ // spin plane: perpendicular to the ring's axis, through the
361
+ // pivot. The screen ring has no axis of its own: it spins
362
+ // about the camera's own forward, so its plane is the
363
+ // camera-facing plane through the pivot.
364
+ const spinDir = axis === "screen" ? tools.eye : dir;
365
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(spinDir, tools.origin.clone());
366
+ const hit = new THREE.Vector3();
367
+ if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
368
+ // a stable in-plane basis to read the pointer's angle from
369
+ const tangent = new THREE.Vector3(spinDir.y, spinDir.z, spinDir.x).cross(spinDir).normalize();
370
+ const binormal = spinDir.clone().cross(tangent).normalize();
371
+ const offset = hit.clone().sub(tools.origin);
372
+ if (offset.lengthSq() < 1e-8) return false;
373
+ drag = {
374
+ mode: "rotate",
375
+ tangent,
376
+ binormal,
377
+ lastAngle: Math.atan2(offset.dot(binormal), offset.dot(tangent)),
378
+ turned: 0,
379
+ origin: tools.origin.clone(),
380
+ dir: spinDir.clone(), // the world axis the delta spins about (the ring's, or the view's)
381
+ };
382
+ } else if (kind === "scale") {
383
+ // Scale reads a ratio against the grab point's distance from the
384
+ // pivot, so the knob tracks the cursor. The centre knob has no
385
+ // axis of its own: it rides the camera's right vector.
386
+ const dragDir = axis ? dir.clone() : new THREE.Vector3().crossVectors(tools.eye, new THREE.Vector3(0, 1, 0)).normalize();
387
+ const normal = dragDir.clone().cross(tools.eye).cross(dragDir).normalize();
388
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
389
+ const hit = new THREE.Vector3();
390
+ if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
391
+ const startAlong = hit.clone().sub(tools.origin).dot(dragDir);
392
+ // A grab right on the pivot has no leverage; fall back to the
393
+ // gizmo's own length so the first move is still sane.
394
+ const reference = Math.abs(startAlong) < 0.05 ? (rootRef.current?.scale.x ?? 1) * ARROW_LEN : startAlong;
395
+ drag = { mode: "scale", dir: dragDir, startAlong, reference, origin: tools.origin.clone() };
396
+ } else if (kind === "plane") {
397
+ // The handle's own plane, pinned through the pivot: the two loose
398
+ // axes are read straight off the hit point.
399
+ const normal = AXIS_VECTORS[dir.normal];
400
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
401
+ const hit = new THREE.Vector3();
402
+ if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
403
+ drag = { mode: "move", planeAxes: dir.axes, hitStart: hit };
404
+ } else {
405
+ // slide plane: contains the axis and faces the camera as squarely
406
+ // as it can, the same construction TransformControls uses
407
+ const normal = dir.clone().cross(tools.eye).cross(dir).normalize();
408
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
409
+ const hit = new THREE.Vector3();
410
+ if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
411
+ drag = { mode: "move", dir: dir.clone(), hitStart: hit };
412
+ }
413
+ // The coordinator (App) issues the token; the gizmo keeps it on its
414
+ // own drag ref and presents it on every apply and on the close, so
415
+ // a resumed pointer stream after a settle is inert by construction
416
+ // (§6.1). The `cancel` handed over is teardown-only — the
417
+ // coordinator owns the close, and a close attempted from inside it
418
+ // is inert because the token is retired first (§5.2).
419
+ const token = stateRef.current.onDragStart?.({
420
+ owner: "gizmo",
421
+ cancel: teardownDrag,
422
+ });
423
+ dragRef.current = { ...drag, id: live.id, axis, plane, start: { ...live }, token };
424
+ // the grabbed handle turns yellow now and stays yellow after
425
+ // release, until the selection or the tool changes (§3.1)
426
+ setActiveHandle(handleKey({ axis, plane }));
427
+ gl.domElement.style.cursor = "grabbing";
428
+ window.addEventListener("pointermove", applyDrag);
429
+ window.addEventListener("pointerup", onPointerUp);
430
+ window.addEventListener("pointercancel", onPointerCancel);
431
+ // Only a real transaction gets the extra lifecycle edges: blur and
432
+ // Escape would otherwise change mid-drag behaviour for callers that
433
+ // pass no transaction props (the byte-for-byte fallback path).
434
+ if (token != null) {
435
+ window.addEventListener("blur", onBlur);
436
+ window.addEventListener("keydown", onKeyDown, true);
437
+ }
438
+ return true;
439
+ };
440
+
441
+ /** the gizmo handle under the pointer, if any */
442
+ const pickHandle = (event) => {
443
+ const aim = rayFrom(event);
444
+ if (!aim) return null;
445
+ // entries whose mesh has left the scene (mode switch, deselect) are
446
+ // stale registrations, not pick targets
447
+ const handles = [...handlesRef.current.values()].filter((entry) => entry.mesh?.parent);
448
+ if (!handles.length) return null;
449
+ tools.raycaster.layers.set(GIZMO_LAYER);
450
+ const hits = tools.raycaster.intersectObjects(handles.map((entry) => entry.mesh), false)
451
+ if (!hits.length) return null;
452
+ const grabbed = handles.find((entry) => entry.mesh === hits[0].object);
453
+ return grabbed ? { ...grabbed, aim } : null;
454
+ };
455
+
456
+ const onDown = (event) => {
457
+ // Plain left only. Alt+left orbits, middle pans, right flies — those
458
+ // belong to the camera and must pass straight through.
459
+ if (event.button !== 0 || event.altKey || event.target !== gl.domElement) return;
460
+ const grabbed = pickHandle(event);
461
+ if (grabbed && beginDrag(grabbed.plane ? "plane" : stateRef.current.mode, grabbed.axis, grabbed.plane ?? grabbed.dir, grabbed.aim.camera)) {
462
+ // keep this press away from the fly camera
463
+ event.preventDefault();
464
+ event.stopPropagation();
465
+ return;
466
+ }
467
+ // Selection. A left press on a body selects it and does nothing else;
468
+ // a press on empty space clears the selection. Both claim the press so
469
+ // the fly camera cannot also react — navigation lives on the right and
470
+ // middle buttons now.
471
+ if (!rayFrom(event)) return;
472
+ tools.raycaster.layers.set(0);
473
+ const picked = pickObject();
474
+ event.preventDefault();
475
+ event.stopPropagation();
476
+ // Path authoring outranks deselection: an empty-floor press drops a
477
+ // waypoint where the ray meets the deck. Presses on bodies still select.
478
+ if (!picked && stateRef.current.onGroundClick && tools.raycaster.ray.intersectPlane(GROUND, tools.hit)) {
479
+ stateRef.current.onGroundClick({ x: tools.hit.x, z: tools.hit.z });
480
+ return;
481
+ }
482
+ stateRef.current.onSelect?.(picked ? picked.id : null);
483
+ };
484
+
485
+ const onHover = (event) => {
486
+ // A held button is a camera look-drag: no picking work per frame.
487
+ if (dragRef.current || event.buttons !== 0 || event.target !== gl.domElement) return;
488
+ const picked = pickHandle(event);
489
+ const key = picked ? handleKey(picked) : null;
490
+ // Re-render only when the handle under the pointer actually
491
+ // changes; a plain move across one handle would otherwise re-render
492
+ // the gizmo every pointer tick.
493
+ if (key !== hoverRef.current) {
494
+ hoverRef.current = key;
495
+ setHoveredHandle(key);
496
+ }
497
+ if (picked) {
498
+ gl.domElement.style.cursor = "grab";
499
+ return;
500
+ }
501
+ tools.raycaster.layers.set(0);
502
+ gl.domElement.style.cursor = pickObject() ? "pointer" : "";
503
+ };
504
+
505
+ // QA hooks: the real pickers, addressable from a headless check.
506
+ window.__gizmoPick = (x, y) => {
507
+ const grabbed = pickHandle({ clientX: x, clientY: y, button: 0 });
508
+ if (!grabbed) return null;
509
+ return grabbed.axis ?? (grabbed.plane ? `plane:${grabbed.plane.id}` : "centre");
510
+ };
511
+ window.__objectPick = (x, y) => {
512
+ if (!rayFrom({ clientX: x, clientY: y })) return null;
513
+ tools.raycaster.layers.set(0);
514
+ return pickObject()?.id ?? null;
515
+ };
516
+
517
+ window.addEventListener("pointerdown", onDown, true);
518
+ gl.domElement.addEventListener("pointermove", onHover);
519
+ return () => {
520
+ window.removeEventListener("pointerdown", onDown, true);
521
+ gl.domElement.removeEventListener("pointermove", onHover);
522
+ // unmount / enabled->false: the drag's travel is real work, so it
523
+ // commits as one entry rather than silently rolling back (§6.3)
524
+ endDrag(true);
525
+ };
526
+ }, [enabled, gl, scene, camRef, paneRef, tools]);
527
+
528
+ // Constant on-screen size: the gizmo is UI, not set dressing.
529
+ useFrame(() => {
530
+ const root = rootRef.current;
531
+ const camera = camRef?.current;
532
+ if (!root || !camera || !object) return;
533
+ camera.getWorldPosition(tools.camPos);
534
+ tools.origin.set(object.x, gizmoHeight(object), object.z);
535
+ root.scale.setScalar(Math.max(0.35, tools.camPos.distanceTo(tools.origin) * SCREEN_SCALE));
536
+ if (mode !== "rotate") return;
537
+ // The rotate rings are view-dependent, so their orientation is frame
538
+ // state: the screen ring billboards flat to the camera (the shot
539
+ // camera is a bare scene child, so its quaternion is world space),
540
+ // and each axis ring's near/far half boundary is the camera-facing
541
+ // plane through the pivot.
542
+ camera.getWorldDirection(tools.eye);
543
+ const screen = screenRingRef.current;
544
+ if (screen) screen.quaternion.copy(camera.quaternion);
545
+ for (const { axis, u, v } of ringBases) {
546
+ const ring = ringGroupsRef.current[axis];
547
+ if (!ring) continue;
548
+ // The near half points at the camera: its centre is −forward
549
+ // projected onto the ring plane, i.e. atan2(−f·v, −f·u) in the
550
+ // ring's (u, v) basis. The far half sits at φ + π.
551
+ ring.rotation.z = Math.atan2(-tools.eye.dot(v), -tools.eye.dot(u));
552
+ }
553
+ });
554
+
555
+ // QA hook: where each handle sits on screen, through the exact rect the
556
+ // picker builds its ray from. Headless checks drive real pointer events
557
+ // with it (harmless in normal use).
558
+ if (typeof window !== "undefined") {
559
+ window.__gizmoHandles = () => {
560
+ const camera = camRef?.current;
561
+ const pane = paneRef?.current;
562
+ if (!camera || !pane) return [];
563
+ // reads happen between frames (QA, headless checks): sync the
564
+ // matrices the projection depends on instead of trusting the last
565
+ // render tick under demand rendering, and re-apply the render
566
+ // loop's locked aspect (dualview) so QA geometry matches the
567
+ // drawn frame exactly
568
+ camera.aspect = SHOT_ASPECT;
569
+ camera.updateProjectionMatrix();
570
+ camera.updateMatrixWorld();
571
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
572
+ rootRef.current?.updateMatrixWorld(true);
573
+ const bounds = pane.getBoundingClientRect();
574
+ const rect = fitAspect({ x: bounds.left, y: bounds.top, w: bounds.width, h: bounds.height }, SHOT_ASPECT);
575
+ return [...handlesRef.current.values()]
576
+ .filter((entry) => entry.mesh?.parent)
577
+ .map(({ mesh, axis, plane }) => {
578
+ const point = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
579
+ return {
580
+ axis: axis ?? (plane ? `plane:${plane.id}` : "centre"),
581
+ x: rect.x + ((point.x + 1) / 2) * rect.w,
582
+ y: rect.y + ((1 - point.y) / 2) * rect.h,
583
+ };
584
+ });
585
+ };
586
+ }
587
+
588
+ if (!enabled || !object) {
589
+ handlesRef.current.clear();
590
+ return null;
591
+ }
592
+ // Keyed registration: a re-render replaces the pick proxy in place instead
593
+ // of stacking a second copy of the gizmo in the pick list.
594
+ const register = (axis, dir) => (mesh) => {
595
+ if (!mesh) return;
596
+ mesh.layers.set(GIZMO_LAYER);
597
+ handlesRef.current.set(axis ?? "centre", { mesh, axis, dir });
598
+ };
599
+ /** the group whose rotation.z picks which half of this axis ring is near */
600
+ const registerRingGroup = (axis) => (group) => {
601
+ ringGroupsRef.current[axis] = group;
602
+ };
603
+ /** a plane square: no single axis, so it carries its own two-axis descriptor */
604
+ const registerPlane = (plane) => (mesh) => {
605
+ if (!mesh) return;
606
+ mesh.layers.set(GIZMO_LAYER);
607
+ handlesRef.current.set(`plane:${plane.id}`, { mesh, axis: null, plane });
608
+ };
609
+ /** invisible-but-raycastable pick volume */
610
+ const pickMaterial = <meshBasicMaterial visible={false} />;
611
+
612
+ const screenActive = activeHandle === "screen";
613
+ const screenHovered = hoveredHandle === "screen";
614
+ const centreActive = activeHandle === "centre";
615
+ const centreHovered = hoveredHandle === "centre";
616
+ return (
617
+ <group ref={rootRef} position={[object.x, gizmoHeight(object), object.z]} renderOrder={999}>
618
+ {AXES.map(({ axis, dir, color }) => {
619
+ const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
620
+ // The handle under a drag — and, after release, the last one —
621
+ // is yellow; the one under the pointer is brighter (§3.1).
622
+ const isActive = activeHandle === axis;
623
+ const isHovered = hoveredHandle === axis;
624
+ const emissive = isActive ? ACTIVE_COLOR : color;
625
+ const emissiveIntensity = isActive ? 2.2 : isHovered ? 3.6 : 2.2;
626
+ const material = (opacity = 0.95) => (
627
+ <meshStandardMaterial
628
+ color="#000000"
629
+ emissive={emissive}
630
+ emissiveIntensity={emissiveIntensity}
631
+ toneMapped={false}
632
+ depthTest={false}
633
+ depthWrite={false}
634
+ transparent
635
+ opacity={opacity}
636
+ />
637
+ );
638
+ return (
639
+ <group key={axis} quaternion={quat}>
640
+ {mode === "rotate" ? (
641
+ <group ref={registerRingGroup(axis)} rotation={[Math.PI / 2, 0, 0]}>
642
+ {/* near half: full brightness */}
643
+ <mesh renderOrder={999}>
644
+ <torusGeometry args={[RING_R, RING_TUBE, 8, 24, Math.PI]} />
645
+ {material()}
646
+ </mesh>
647
+ {/* far half: faded so it reads as behind (see FAR_HALF_ALPHA) */}
648
+ <mesh rotation={[0, 0, Math.PI]} renderOrder={999}>
649
+ <torusGeometry args={[RING_R, RING_TUBE, 8, 24, Math.PI]} />
650
+ {material(FAR_HALF_ALPHA)}
651
+ </mesh>
652
+ <mesh ref={register(axis, dir)}>
653
+ <torusGeometry args={[RING_R, PICK_TUBE_R, 6, 32]} />
654
+ {pickMaterial}
655
+ </mesh>
656
+ </group>
657
+ ) : (
658
+ <>
659
+ <mesh position={[0, ARROW_LEN / 2, 0]} renderOrder={999}>
660
+ <cylinderGeometry args={[SHAFT_R, SHAFT_R, ARROW_LEN, 8]} />
661
+ {material()}
662
+ </mesh>
663
+ <mesh position={[0, ARROW_LEN + (mode === "scale" ? BOX_SIZE / 2 : TIP_LEN / 2), 0]} renderOrder={999}>
664
+ {mode === "scale" ? (
665
+ <boxGeometry args={[BOX_SIZE, BOX_SIZE, BOX_SIZE]} />
666
+ ) : (
667
+ <coneGeometry args={[TIP_R, TIP_LEN, 14]} />
668
+ )}
669
+ {material()}
670
+ </mesh>
671
+ <mesh ref={register(axis, dir)} position={[0, (ARROW_LEN + TIP_LEN) / 2, 0]}>
672
+ <cylinderGeometry args={[PICK_SHAFT_R, PICK_SHAFT_R, ARROW_LEN + TIP_LEN, 8]} />
673
+ {pickMaterial}
674
+ </mesh>
675
+ </>
676
+ )}
677
+ </group>
678
+ );
679
+ })}
680
+ {mode === "rotate" && (
681
+ <group ref={screenRingRef}>
682
+ {/* the outermost ring, always flat to the viewer: rolls the
683
+ object about the view axis (§3.3) */}
684
+ <mesh renderOrder={999}>
685
+ <torusGeometry args={[SCREEN_RING_R, RING_TUBE, 8, 64]} />
686
+ <meshStandardMaterial
687
+ color="#000000"
688
+ emissive={screenActive ? ACTIVE_COLOR : "#f2f2f2"}
689
+ emissiveIntensity={screenActive ? 2.2 : screenHovered ? 3.6 : 2.2}
690
+ toneMapped={false}
691
+ depthTest={false}
692
+ depthWrite={false}
693
+ transparent
694
+ opacity={0.95}
695
+ />
696
+ </mesh>
697
+ <mesh ref={register("screen", null)}>
698
+ <torusGeometry args={[SCREEN_RING_R, PICK_TUBE_R, 6, 48]} />
699
+ {pickMaterial}
700
+ </mesh>
701
+ </group>
702
+ )}
703
+ {mode === "move" &&
704
+ PLANES.map((plane) => {
705
+ // The square lies in its own plane, offset along both loose axes
706
+ // so it sits in the quadrant between the two arrows.
707
+ const position = [0, 0, 0];
708
+ const index = { x: 0, y: 1, z: 2 };
709
+ for (const axis of plane.axes) position[index[axis]] = PLANE_OFFSET;
710
+ const rotation = plane.normal === "y" ? [-Math.PI / 2, 0, 0] : plane.normal === "x" ? [0, Math.PI / 2, 0] : [0, 0, 0];
711
+ const planeActive = activeHandle === `plane:${plane.id}`;
712
+ const planeHovered = hoveredHandle === `plane:${plane.id}`;
713
+ return (
714
+ <group key={plane.id} position={position} rotation={rotation}>
715
+ <mesh renderOrder={999}>
716
+ <planeGeometry args={[PLANE_SIZE, PLANE_SIZE]} />
717
+ <meshBasicMaterial
718
+ color={planeActive ? ACTIVE_COLOR : plane.color}
719
+ transparent
720
+ opacity={planeActive ? 0.6 : planeHovered ? 0.55 : 0.34}
721
+ side={THREE.DoubleSide}
722
+ depthTest={false}
723
+ depthWrite={false}
724
+ />
725
+ </mesh>
726
+ <mesh ref={registerPlane(plane)}>
727
+ <planeGeometry args={[PLANE_SIZE * 1.5, PLANE_SIZE * 1.5]} />
728
+ <meshBasicMaterial visible={false} side={THREE.DoubleSide} />
729
+ </mesh>
730
+ </group>
731
+ );
732
+ })}
733
+ {mode === "scale" && (
734
+ <>
735
+ {/* uniform scale: one knob at the pivot, all three axes at once */}
736
+ <mesh renderOrder={999}>
737
+ <boxGeometry args={[CENTRE_BOX, CENTRE_BOX, CENTRE_BOX]} />
738
+ <meshStandardMaterial
739
+ color="#000000"
740
+ emissive={centreActive ? ACTIVE_COLOR : "#f2f2f2"}
741
+ emissiveIntensity={centreActive ? 2 : centreHovered ? 3.4 : 2}
742
+ toneMapped={false}
743
+ depthTest={false}
744
+ depthWrite={false}
745
+ />
746
+ </mesh>
747
+ <mesh ref={register(null, null)}>
748
+ <sphereGeometry args={[PICK_CENTRE_R, 8, 6]} />
749
+ {pickMaterial}
750
+ </mesh>
751
+ </>
752
+ )}
753
+ </group>
754
+ );
755
+ }