cozyclay 2.0.2 → 2.1.1

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 (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,302 @@
1
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useThree } from "@react-three/fiber";
3
+ import { Line, useFBX } from "@react-three/drei";
4
+ import * as THREE from "three";
5
+ import { SkeletonUtils } from "three/examples/jsm/Addons.js";
6
+ import { ProxyFigure } from "./proxy-figure.jsx";
7
+ import { isProxyFigure } from "./scenes.js";
8
+ import { applyPartColours } from "./part-colours.js";
9
+ import { applyHipsOffset, applyPose, primeBindPose } from "./poses.js";
10
+ import { TRAIL_TRACKS, falloffWeight, jointTrailPoints, trailEditRange } from "./motion-trail.js";
11
+ import { pickTrailPoint } from "./trail-pick.js";
12
+ import { REST_BONES } from "./stage-startup-defaults.js";
13
+
14
+ // Memoized: unchanged cast members skip re-rendering on every playhead tick.
15
+ export const Character = memo(function Character(props) {
16
+ return isProxyFigure(props) ? <ProxyFigure {...props} /> : <RiggedCharacter {...props} />;
17
+ });
18
+
19
+ const RiggedCharacter = memo(function RiggedCharacter({ url, position, rot, tint, pose, scale = 1, onRig, pickId, partColoursEnabled = false, partColoursMode = "shaded" }) {
20
+ const fbx = useFBX(url);
21
+ const model = useMemo(() => {
22
+ const clone = SkeletonUtils.clone(fbx);
23
+ // Mixamo exports centimetres; `scale` is the FILMED person's stature.
24
+ // THE INVARIANT: extraction divided this take's root travel by that
25
+ // stature, so the clip and the scale must travel together — apply one
26
+ // without the other and every stride overshoots by the same factor and
27
+ // the feet skate. It belongs HERE, on the world transform, and never
28
+ // inside rig space: playback.js derives prep.scale from the bind pose,
29
+ // so a scaled bone hierarchy would be measured as a different rig.
30
+ clone.scale.setScalar(0.01 * scale);
31
+ // The joint shells (Alpha_Joints / Beta_Joints — elbows, knees, the
32
+ // exoskeleton bands) render in a darkened shade of the body tint so
33
+ // the segments read as separate pieces.
34
+ // The shipped clay is intentionally pale, but the stage deck is pale too.
35
+ // Lift neither with emissive nor a second light; a small value drop keeps
36
+ // the same authored hue while restoring silhouette contrast in the editor.
37
+ const bodyTint = new THREE.Color(tint).multiplyScalar(0.9);
38
+ const jointTint = bodyTint.clone().multiplyScalar(0.62);
39
+ clone.traverse((child) => {
40
+ if (child.isMesh) {
41
+ // warm clay reads as a maquette; cold grey reads as a broken render
42
+ child.material = new THREE.MeshStandardMaterial({
43
+ color: /_Joints$/i.test(child.name) ? jointTint : bodyTint,
44
+ // A slightly tighter highlight gives the clay enough form to read
45
+ // at blocking distance without turning it into shiny plastic.
46
+ roughness: 0.66,
47
+ metalness: 0,
48
+ envMapIntensity: 0.35,
49
+ });
50
+ child.frustumCulled = false;
51
+ // The subject's own shadow is what plants it on the deck; a blocking
52
+ // frame without one leaves the figure floating over flat colour.
53
+ child.castShadow = true;
54
+ child.receiveShadow = true;
55
+ }
56
+ });
57
+ if (partColoursEnabled) applyPartColours(clone, partColoursMode);
58
+ // Stamp the bind pose while the rig is still untouched: the pose effect
59
+ // below runs immediately after and would otherwise be baked into "rest".
60
+ primeBindPose(clone);
61
+ return clone;
62
+ }, [fbx, tint, partColoursEnabled, partColoursMode]);
63
+
64
+ // A new stature (a fresh extraction on this character) must not rebuild the
65
+ // clone — that would drop the rig the playback effects hold. Only the world
66
+ // transform moves; the same invariant as above applies.
67
+ useEffect(() => {
68
+ model.scale.setScalar(0.01 * scale);
69
+ }, [model, scale]);
70
+
71
+ useEffect(() => {
72
+ // During playback the pose prop is null and the motion drives the rig;
73
+ // this effect can flush AFTER the playback effect in a later commit
74
+ // (measured: playback at t, pose reset at t+1.8ms), and resetting to
75
+ // REST then would clobber the animation back to the bind pose. So a
76
+ // null pose means "playback owns the rig" — do not touch it.
77
+ if (!pose) return;
78
+ // every joint is reset first, otherwise switching presets leaves stale limbs
79
+ applyPose(model, { ...REST_BONES, ...pose.bones });
80
+ // A measured pose (photo, bottled take frame) carries its hips height;
81
+ // rotations alone would leave a crouch floating at standing height.
82
+ applyHipsOffset(model, pose.rootY ?? 0);
83
+ }, [model, pose]);
84
+
85
+ const onRigRef = useRef(onRig);
86
+ onRigRef.current = onRig;
87
+ // Report on MODEL change only — a per-render callback identity change
88
+ // must not re-run this effect.
89
+ useEffect(() => {
90
+ onRigRef.current?.(model);
91
+ }, [model]);
92
+
93
+ return (
94
+ // characterPick makes the body a first-class click target: the Scene
95
+ // picker walks up from any hit mesh, finds the tag, and routes the
96
+ // selection to the hierarchy so the Inspector owns the controls.
97
+ <group position={position} rotation={[0, (rot * Math.PI) / 180, 0]} userData={pickId ? { characterPick: pickId } : undefined}>
98
+ <primitive object={model} />
99
+ </group>
100
+ );
101
+ });
102
+
103
+ /* ------------------------- IK-mode motion trails --------------------------
104
+ * World-space trajectory polylines of the loaded take: the selected track,
105
+ * or all tracks when the user asks for an overview. The whole clip is
106
+ * drawn as a faint line; while a grab is active the falloff window is
107
+ * re-drawn on top as a bright highlight. Grabbing any point of a line starts
108
+ * a drag on a camera-facing plane through the grab point; the caller deforms
109
+ * the take (motion-trail.js falloff math) so the preview updates live. */
110
+ export const MotionTrails = memo(function MotionTrails({ motion, rig = null, baseY, charScale, ikFocus, activeTrackId = null, falloffFrames, playheadFrame, pendingEdit, enabled, visible = true, onDragStart, onDragPreview, onDragEnd }) {
111
+ const { camera, gl, invalidate } = useThree();
112
+ const [drag, setDrag] = useState(null);
113
+ const callbacksRef = useRef({ onDragStart, onDragPreview, onDragEnd });
114
+ callbacksRef.current = { onDragStart, onDragPreview, onDragEnd };
115
+ const toTriples = (flat) => {
116
+ const out = [];
117
+ for (let index = 0; index + 2 < flat.length; index += 3) out.push([flat[index], flat[index + 1], flat[index + 2]]);
118
+ return out;
119
+ };
120
+ // Every trail track (root + IK endpoints + head) in its handle colour,
121
+ // sampled where playback renders each bone on this rig (not the clip's
122
+ // own joints, which sit 4-15 cm off the body), so draw and pick agree.
123
+ const tracks = useMemo(
124
+ () => TRAIL_TRACKS.map((track) => {
125
+ const flat = jointTrailPoints(motion, track.joint, { baseY, scale: charScale, rig });
126
+ return flat ? { ...track, flat, points: toTriples(flat) } : null;
127
+ }).filter(Boolean),
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ [motion, rig, baseY, charScale],
130
+ );
131
+ const trackById = (id) => tracks.find((track) => track.id === id) ?? null;
132
+ const focusTrack = activeTrackId ? tracks.find((track) => track.id === activeTrackId)?.id ?? null : null;
133
+ const visibleTracks = focusTrack ? tracks.filter((track) => track.id === focusTrack) : tracks;
134
+ // The falloff window rides whichever line is being (or was last) grabbed.
135
+ const highlightCandidate = drag ?? pendingEdit;
136
+ const highlight = highlightCandidate && (!focusTrack || highlightCandidate.track === focusTrack) ? highlightCandidate : null;
137
+ const highlightPoints = useMemo(() => {
138
+ if (!highlight || !motion?.frames) return null;
139
+ const flat = trackById(highlight.track)?.flat ?? trackById("hips")?.flat;
140
+ if (!flat) return null;
141
+ const { startFrame, endFrame } = trailEditRange(motion.frames, highlight.grabFrame, falloffFrames);
142
+ const out = [];
143
+ for (let frame = startFrame; frame < endFrame; frame += 1) {
144
+ out.push([flat[frame * 3], flat[frame * 3 + 1], flat[frame * 3 + 2]]);
145
+ }
146
+ return out.length > 1 ? out : null;
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [highlight, motion, tracks, falloffFrames]);
149
+
150
+ // Manual capture-phase picking instead of r3f pointer handlers: handlers on
151
+ // a drei Line make react-three-fiber raycast the fat-line shader geometry on
152
+ // EVERY pointermove for hover bookkeeping, which is exactly the IK-mode lag.
153
+ // A single pointerdown listener that measures point-to-ray distance against
154
+ // the cached trail arrays costs nothing while the mouse merely moves.
155
+ const pickRef = useRef(null);
156
+ pickRef.current = { tracks: visibleTracks, enabled, falloffFrames, playheadFrame };
157
+ // Line2 instances for in-place geometry rewrites during a drag.
158
+ const lineRefs = useRef({});
159
+ const highlightRef = useRef(null);
160
+ useEffect(() => {
161
+ const dom = gl.domElement;
162
+ const raycaster = new THREE.Raycaster();
163
+ const hit = new THREE.Vector3();
164
+ const down = (event) => {
165
+ const pick = pickRef.current;
166
+ if (!pick?.enabled || event.button !== 0) return;
167
+ const rect = dom.getBoundingClientRect();
168
+ const ndc = new THREE.Vector2(
169
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
170
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
171
+ );
172
+ raycaster.setFromCamera(ndc, camera);
173
+ const { origin, direction } = raycaster.ray;
174
+ const picked = pickTrailPoint({
175
+ tracks: pick.tracks,
176
+ playheadFrame: pick.playheadFrame,
177
+ falloffFrames: pick.falloffFrames,
178
+ rayOrigin: origin,
179
+ rayDirection: direction,
180
+ maxDistance: 0.2,
181
+ });
182
+ if (!picked) return;
183
+ const { track, grabFrame } = picked;
184
+ // The grab wins over the camera controls listening in the bubble phase.
185
+ event.stopPropagation();
186
+ event.preventDefault();
187
+ const flat = pick.tracks.find((candidate) => candidate.id === track)?.flat;
188
+ if (!flat) return;
189
+ const start = new THREE.Vector3(flat[grabFrame * 3], flat[grabFrame * 3 + 1], flat[grabFrame * 3 + 2]);
190
+ const normal = camera.getWorldDirection(new THREE.Vector3()).negate();
191
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, start);
192
+ setDrag({ track, grabFrame });
193
+ callbacksRef.current.onDragStart?.({ track, grabFrame });
194
+ // The drag never touches React state: per rAF the grabbed line and the
195
+ // falloff highlight are rewritten in place on their Line2 geometries,
196
+ // and onDragPreview poses the rig imperatively. Re-rendering the whole
197
+ // app per pointermove is exactly the drag lag this avoids; the real
198
+ // commit (setMotion + pending edit) happens once, on pointerup.
199
+ const frames = flat.length / 3;
200
+ const { startFrame, endFrame } = trailEditRange(frames, grabFrame, pickRef.current.falloffFrames);
201
+ const deformedFlat = new Float32Array(flat);
202
+ const windowFlat = new Float32Array((endFrame - startFrame) * 3);
203
+ let lastDelta = null;
204
+ let queued = null;
205
+ let rafId = 0;
206
+ const applyPreview = (delta) => {
207
+ for (let frame = startFrame; frame < endFrame; frame += 1) {
208
+ const weight = falloffWeight(frame - grabFrame, pickRef.current.falloffFrames);
209
+ deformedFlat[frame * 3] = flat[frame * 3] + delta.x * weight;
210
+ deformedFlat[frame * 3 + 1] = flat[frame * 3 + 1] + delta.y * weight;
211
+ deformedFlat[frame * 3 + 2] = flat[frame * 3 + 2] + delta.z * weight;
212
+ }
213
+ windowFlat.set(deformedFlat.subarray(startFrame * 3, endFrame * 3));
214
+ lineRefs.current[track]?.geometry?.setPositions(deformedFlat);
215
+ highlightRef.current?.geometry?.setPositions(windowFlat);
216
+ callbacksRef.current.onDragPreview?.({ track, grabFrame, delta });
217
+ invalidate();
218
+ };
219
+ const flush = () => {
220
+ rafId = 0;
221
+ if (queued) applyPreview(queued);
222
+ queued = null;
223
+ };
224
+ const move = (pointerEvent) => {
225
+ const moveRect = dom.getBoundingClientRect();
226
+ const moveNdc = new THREE.Vector2(
227
+ ((pointerEvent.clientX - moveRect.left) / moveRect.width) * 2 - 1,
228
+ -((pointerEvent.clientY - moveRect.top) / moveRect.height) * 2 + 1,
229
+ );
230
+ raycaster.setFromCamera(moveNdc, camera);
231
+ if (!raycaster.ray.intersectPlane(plane, hit)) return;
232
+ lastDelta = { x: hit.x - start.x, y: hit.y - start.y, z: hit.z - start.z };
233
+ queued = lastDelta;
234
+ if (!rafId) rafId = requestAnimationFrame(flush);
235
+ };
236
+ const up = () => {
237
+ window.removeEventListener("pointermove", move);
238
+ window.removeEventListener("pointerup", up);
239
+ if (rafId) cancelAnimationFrame(rafId);
240
+ if (queued) applyPreview(queued);
241
+ setDrag(null);
242
+ callbacksRef.current.onDragEnd?.({ track, grabFrame, delta: lastDelta });
243
+ };
244
+ window.addEventListener("pointermove", move);
245
+ window.addEventListener("pointerup", up);
246
+ };
247
+ dom.addEventListener("pointerdown", down, true);
248
+ return () => dom.removeEventListener("pointerdown", down, true);
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, [gl, camera]);
251
+
252
+ if (!tracks.length) return null;
253
+ // depthTest off + high renderOrder: the trails read through the character
254
+ // and the floor instead of vanishing into them. Every part rides its own
255
+ // IK-handle colour; the focused part draws thicker.
256
+ return (
257
+ <group
258
+ visible={visible}
259
+ renderOrder={900}
260
+ userData={{ editorOverlay: true }}
261
+ ref={(group) => {
262
+ // QA-only escape hatch (same spirit as window.__cozyclay): lets
263
+ // headless perf probes toggle the trails without a rebuild.
264
+ if (typeof window !== "undefined") {
265
+ window.__cozyclayTrails = group;
266
+ window.__cozyclayVisibleTrailTracks = visibleTracks.map((track) => track.id);
267
+ }
268
+ }}
269
+ >
270
+ {visibleTracks.map((track) => track.points.length > 1 && (
271
+ <Line
272
+ key={track.id}
273
+ ref={(line) => {
274
+ if (line) lineRefs.current[track.id] = line;
275
+ else delete lineRefs.current[track.id];
276
+ }}
277
+ points={track.points}
278
+ color={track.color}
279
+ lineWidth={track.id === focusTrack ? 4 : 2.5}
280
+ depthTest={false}
281
+ transparent
282
+ opacity={track.id === focusTrack ? 1 : 0.85}
283
+ />
284
+ ))}
285
+ {highlightPoints && (
286
+ <Line ref={highlightRef} points={highlightPoints} color={drag ? "#3dff7a" : "#ff8c42"} lineWidth={5} depthTest={false} />
287
+ )}
288
+ </group>
289
+ );
290
+ }, (previous, next) => (
291
+ previous.motion === next.motion &&
292
+ previous.rig === next.rig &&
293
+ previous.baseY === next.baseY &&
294
+ previous.charScale === next.charScale &&
295
+ previous.ikFocus === next.ikFocus &&
296
+ previous.activeTrackId === next.activeTrackId &&
297
+ previous.falloffFrames === next.falloffFrames &&
298
+ previous.playheadFrame === next.playheadFrame &&
299
+ previous.pendingEdit === next.pendingEdit &&
300
+ previous.enabled === next.enabled &&
301
+ previous.visible === next.visible
302
+ ));