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
package/src/app-stage.jsx CHANGED
@@ -4,2487 +4,144 @@
4
4
  // extraction safe. Behavior-identical by construction; verified by the build
5
5
  // and the browser QA suites.
6
6
  import {
7
- memo,
8
- useEffect,
9
- useMemo,
10
7
  useRef,
11
8
  useState,
12
9
  } from "react";
13
- import { Canvas, useFrame, useThree } from "@react-three/fiber";
14
- import { Line, Text, useFBX } from "@react-three/drei";
15
- import * as THREE from "three";
16
- import { SkeletonUtils } from "three/examples/jsm/Addons.js";
17
- import { addFacingMarks } from "./facing-marks.js";
18
- import { retimeMotion } from "./ardy/retime.js";
19
- import {
20
- TRAIL_EFFECTOR_JOINTS,
21
- TRAIL_TRACKS,
22
- falloffWeight,
23
- jointTrailPoints,
24
- trailEditRange,
25
- } from "./motion-trail.js";
26
- import { pickTrailPoint } from "./trail-pick.js";
27
- import Timeline from "./ardy/timeline.jsx";
28
- import { FlyControls, aimAt } from "./controls.jsx";
29
- import { GIZMO_LAYER } from "./dualview.jsx";
30
- import {
31
- CURVE_GRAB_RADIUS_PX,
32
- LINE_EDIT_TRACK_IDS,
33
- PINNED_CURVE_ENDS,
34
- curveToPoints2d,
35
- validateLineEdit,
36
- } from "./line-edit.js";
37
- import { craneHeightAt, railPoint } from "./camera-follow.js";
38
- import { CUTOUT_KIND, DEFAULT_SCENE_OBJECTS, MESH_KIND, SCENE_ATTACH_BONES, updateSceneObject } from "./scene-objects.js";
39
10
  import { imageFilesFrom } from "./scene-assets.js";
40
11
  import { splitDroppedFiles } from "./scene-mesh.js";
41
- import {
42
- SCENES_QUARANTINE_KEY,
43
- createSceneDocument,
44
- DEFAULT_SUBJECT_ONE,
45
- DEFAULT_SUBJECT_TWO,
46
- loadSceneDocumentFromStorage,
47
- } from "./scenes.js";
48
- import ObjectGizmo from "./object-gizmo.jsx";
49
- import { MAX_PATH_POINTS } from "./object-path.js";
50
- import { track } from "./analytics.js";
51
- import { ko, isKo } from "./locale.js";
52
- import { isPlaygroundEmbed, takePlaygroundProject } from "./playground.js";
53
- import { applyPartColours } from "./part-colours.js";
54
- import { POSE_BONES, applyHipsOffset, applyPose, primeBindPose, normalizeBoneName } from "./poses.js";
55
- import { FK_TRACKS, IK_TRACKS, MID_TRACKS } from "./ardy/ik.js";
56
- import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
57
- import { CUSTOM_MOVE, SHOT_ASPECT_RATIOS, SUBJECT_HEIGHT_M } from "./shot.js";
58
- import { sampleAt } from "./sample-at.js";
59
- import { shotAtFrame } from "./cuts.js";
60
-
61
- // Stated the way a crew states a setup: how far back, which side, how high the
62
- // lens rides, and what glass is on it. Order matters — Medium is the setup a
63
- // director reaches for first, so it leads.
64
- export const PRESETS = {
65
- medium: { label: ko("Medium", "미디엄"), distance: 2.6, azimuth: 22, elevation: 6, fov: 45, targetY: 1.35, two: false },
66
- wide: { label: ko("Wide", "와이드"), distance: 7, azimuth: 25, elevation: 4, fov: 38, targetY: 1.2, two: false },
67
- closeup: { label: ko("Close-Up", "클로즈업"), distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
68
- low: { label: ko("Low Angle", "로우 앵글"), distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
69
- high: { label: ko("High Angle", "하이 앵글"), distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
70
- };
71
-
72
- export const RIG_HIERARCHY_FOCUS = {
73
- "rig.torso": "spine",
74
- "rig.hips": "hips",
75
- "rig.spine": "spine",
76
- "rig.chest": "chest",
77
- "rig.neck": "neck",
78
- "rig.head": "head",
79
- "rig.leftArm": "leftHand",
80
- "rig.leftShoulder": "leftShoulder",
81
- "rig.leftElbow": "leftElbow",
82
- "rig.leftHand": "leftHand",
83
- "rig.rightArm": "rightHand",
84
- "rig.rightShoulder": "rightShoulder",
85
- "rig.rightElbow": "rightElbow",
86
- "rig.rightHand": "rightHand",
87
- "rig.leftLeg": "leftFoot",
88
- "rig.leftKnee": "leftKnee",
89
- "rig.leftFoot": "leftFoot",
90
- "rig.rightLeg": "rightFoot",
91
- "rig.rightKnee": "rightKnee",
92
- "rig.rightFoot": "rightFoot",
93
- };
94
-
95
- export const HIERARCHY_INSPECTOR_TITLES = {
96
- shot: ko("Shot settings", "샷 설정"),
97
- camera: ko("Camera", "카메라"),
98
- light: ko("Light", "조명"),
99
- characters: ko("Characters", "인물"),
100
- characterA: ko("Character 1", "인물 1"),
101
- rig: ko("Rig", "리그"),
102
- characterB: ko("Character 2", "인물 2"),
103
- environment: ko("Environment", "환경"),
104
- props: ko("Props", "소품"),
105
- "rig.torso": ko("Torso", "몸통"),
106
- "rig.hips": ko("Root / Hips", "루트 / 엉덩이"),
107
- "rig.spine": ko("Spine", "척추"),
108
- "rig.chest": ko("Chest", "가슴"),
109
- "rig.neck": ko("Neck", "목"),
110
- "rig.head": ko("Head", "머리"),
111
- "rig.leftArm": ko("Left Arm", "왼팔"),
112
- "rig.leftShoulder": ko("Left Shoulder", "왼쪽 어깨"),
113
- "rig.leftElbow": ko("Left Elbow", "왼쪽 팔꿈치"),
114
- "rig.leftHand": ko("Left Hand", "왼손"),
115
- "rig.rightArm": ko("Right Arm", "오른팔"),
116
- "rig.rightShoulder": ko("Right Shoulder", "오른쪽 어깨"),
117
- "rig.rightElbow": ko("Right Elbow", "오른쪽 팔꿈치"),
118
- "rig.rightHand": ko("Right Hand", "오른손"),
119
- "rig.leftLeg": ko("Left Leg", "왼다리"),
120
- "rig.leftKnee": ko("Left Knee", "왼쪽 무릎"),
121
- "rig.leftFoot": ko("Left Foot", "왼발"),
122
- "rig.rightLeg": ko("Right Leg", "오른다리"),
123
- "rig.rightKnee": ko("Right Knee", "오른쪽 무릎"),
124
- "rig.rightFoot": ko("Right Foot", "오른발"),
125
- };
126
-
127
- /* ----------------------------------------- carried props (attachment) --- */
128
-
129
- /** The rig rows that ARE an attach frame: `rig.<track id>`, the same camel
130
- * spellings SCENE_ATTACH_BONES uses. The group rows above them (rig.torso,
131
- * rig.leftArm …) name a limb, not a frame, so they are deliberately absent. */
132
- export const ATTACH_BONE_ROWS = new Map(SCENE_ATTACH_BONES.map((bone) => [`rig.${bone}`, bone]));
133
-
134
- /** Attach track id -> the Mixamo bone carrying that frame on the shipped rigs.
135
- * TRAIL_EFFECTOR_JOINTS names the cskel27 joint, and cskel27 and Mixamo agree
136
- * everywhere except the spine, which sits one bone up the Mixamo chain (the
137
- * same shift ardy/playback.js SKINNING_MAP makes). */
138
- export const ATTACH_BONE_NAMES = Object.fromEntries(SCENE_ATTACH_BONES.map((bone) => {
139
- const joint = TRAIL_EFFECTOR_JOINTS[bone];
140
- return [bone, joint === "Spine1" ? "Spine" : joint === "Spine2" ? "Spine1" : joint];
141
- }));
142
-
143
- // One scratch set for the whole module: resolving a frame runs per attached
144
- // prop per frame and must never allocate.
145
- export const attachPos = new THREE.Vector3();
146
- export const attachQuat = new THREE.Quaternion();
147
- export const attachScale = new THREE.Vector3();
148
- export const attachRootPos = new THREE.Vector3();
149
- export const attachRootQuat = new THREE.Quaternion();
150
- export const ATTACH_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
151
- export const attachWorldMatrix = new THREE.Matrix4();
152
- export const attachLocalMatrix = new THREE.Matrix4();
153
- export const attachToMatrix = new THREE.Matrix4();
154
- export const attachInverseMatrix = new THREE.Matrix4();
155
- export const placePos = new THREE.Vector3();
156
- export const placeQuat = new THREE.Quaternion();
157
- export const placeScale = new THREE.Vector3();
158
- export const placeEuler = new THREE.Euler();
159
-
160
- /** The bone a rig offers under `mixamoName`, by the project's matching rule
161
- * (normalised names equal, or one a suffix of the other; first depth-first
162
- * match wins — poses.js and ardy/playback.js agree). Cached per rig: this is
163
- * asked once per attached prop per rendered frame. */
164
- export const attachBoneCache = new WeakMap();
165
- export function attachBoneOf(rig, mixamoName) {
166
- let byName = attachBoneCache.get(rig);
167
- if (!byName) attachBoneCache.set(rig, (byName = new Map()));
168
- if (byName.has(mixamoName)) return byName.get(mixamoName);
169
- const target = normalizeBoneName(mixamoName);
170
- let found = null;
171
- rig.traverse((node) => {
172
- if (found || !node.isBone) return;
173
- const norm = normalizeBoneName(node.name);
174
- if (norm === target || norm.endsWith(target)) found = node;
175
- });
176
- byName.set(mixamoName, found);
177
- return found;
178
- }
179
-
180
- /**
181
- * The LIVE world frame an attachment rides, or null when the rig (or that bone)
182
- * is not there — a dangling attachment then renders where its numbers say,
183
- * which is exactly what a detached prop does.
184
- *
185
- * The frame is deliberately UNSCALED. The shipped rigs are Mixamo centimetres
186
- * scaled by 0.01 on the model, so a raw bone matrix would push a hundredfold
187
- * into every carried prop's local numbers and leave the Inspector unreadable.
188
- * A carried prop takes the bone's place and facing; its size stays its own.
189
- *
190
- * `bone === null` is the character's animated ROOT: the hips' travel (during
191
- * playback the whole root trajectory lives in the bones — see
192
- * ardy/playback.js) carried at the character's own yaw, so a prop dropped on
193
- * the character walks with the body instead of rolling with the hips.
194
- */
195
- export function attachFrameMatrix(rig, bone, out = new THREE.Matrix4()) {
196
- if (!rig) return null;
197
- const name = ATTACH_BONE_NAMES[bone ?? "hips"];
198
- if (!name) return null;
199
- const node = attachBoneOf(rig, name);
200
- if (!node) return null;
201
- // Ancestors included: the bones are written imperatively by playback and by
202
- // the export, either of which can land before the renderer's own pass.
203
- node.updateWorldMatrix(true, false);
204
- node.matrixWorld.decompose(attachPos, attachQuat, attachScale);
205
- if (bone != null) return out.compose(attachPos, attachQuat, ATTACH_UNIT_SCALE);
206
- // The Character group carries the scene placement and the clip-to-scene yaw;
207
- // the rig model hangs off it, so its parent IS that group.
208
- const group = rig.parent ?? rig;
209
- group.updateWorldMatrix(true, false);
210
- group.matrixWorld.decompose(attachRootPos, attachRootQuat, attachScale);
211
- return out.compose(attachPos, attachRootQuat, ATTACH_UNIT_SCALE);
212
- }
213
-
214
- /** A prop's placement as a matrix — the same compose props.jsx renders from
215
- * (position, XYZ euler in degrees, per-axis scale). */
216
- export function sceneObjectMatrix(object, out = new THREE.Matrix4()) {
217
- return out.compose(
218
- placePos.set(object.x ?? 0, object.y ?? 0, object.z ?? 0),
219
- placeQuat.setFromEuler(placeEuler.set(
220
- (object.rotX ?? 0) * THREE.MathUtils.DEG2RAD,
221
- (object.rot ?? 0) * THREE.MathUtils.DEG2RAD,
222
- (object.rotZ ?? 0) * THREE.MathUtils.DEG2RAD,
223
- )),
224
- placeScale.set(object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1),
225
- );
226
- }
227
-
228
- /** …and back out into the nine authored channels, or null if the matrix could
229
- * not be read as a placement at all (a singular frame inverts to zeros, and a
230
- * zero matrix decomposes to NaN). Rounded, because a matrix round trip
231
- * otherwise turns a typed 1.5 into 1.4999999999999998 in the Inspector the
232
- * moment a prop is picked up and put down again. */
233
- export function sceneObjectPlacement(matrix) {
234
- matrix.decompose(placePos, placeQuat, placeScale);
235
- placeEuler.setFromQuaternion(placeQuat);
236
- const round = (value) => Math.round(value * 1e6) / 1e6;
237
- const placement = {
238
- x: round(placePos.x), y: round(placePos.y), z: round(placePos.z),
239
- rotX: round(placeEuler.x * THREE.MathUtils.RAD2DEG),
240
- rot: round(placeEuler.y * THREE.MathUtils.RAD2DEG),
241
- rotZ: round(placeEuler.z * THREE.MathUtils.RAD2DEG),
242
- scaleX: round(placeScale.x), scaleY: round(placeScale.y), scaleZ: round(placeScale.z),
243
- };
244
- return Object.values(placement).every((value) => Number.isFinite(value)) ? placement : null;
245
- }
246
-
247
- /**
248
- * The nine channels rewritten so a prop does not MOVE when it changes frames.
249
- *
250
- * `world` is where the prop actually IS — read off the live group in the scene
251
- * graph, NOT recomputed from its numbers. That distinction is the whole point:
252
- * an attach->attach drop would otherwise have to reconstruct the world
253
- * transform through the frame the prop is leaving, and any disagreement
254
- * between that reconstruction and what the renderer last drew (a rig posed a
255
- * beat later than the last drawn frame, a frame that no longer resolves) lands
256
- * on screen as a jump. Reading the group makes every drop the same one-frame
257
- * conversion the world->attach and attach->world cases already were.
258
- *
259
- * Returns null when the frame it needs is missing — converting against a rig
260
- * that is not on stage would be precisely the jump this exists to prevent —
261
- * and the caller then refuses the whole drop rather than re-labelling numbers
262
- * it could not convert.
263
- */
264
- export function attachPlacementPatch(world, attach, frameOf) {
265
- if (!world) return null;
266
- if (!attach) return sceneObjectPlacement(attachLocalMatrix.copy(world));
267
- const frame = frameOf(attach.characterId, attach.bone ?? null, attachToMatrix);
268
- if (!frame) return null;
269
- return sceneObjectPlacement(attachLocalMatrix.copy(world).premultiply(attachInverseMatrix.copy(frame).invert()));
270
- }
271
-
272
- /**
273
- * Write a computed placement straight onto the record, around
274
- * updateSceneObject. Deliberate, and the only call site allowed to: those
275
- * limits are WORLD limits (y floors at the deck, x/z stay inside the room),
276
- * and while a prop is attached its numbers are a LOCAL transform in a bone
277
- * frame — a hand rides 1.3 m above the deck, so a carried prop's local y is
278
- * routinely negative and clamping it to the floor IS the visual jump the
279
- * conversion exists to prevent. Every value here comes out of a decomposed
280
- * matrix, so the record shape is sound by construction.
281
- */
282
- export function placeSceneObject(objects, id, placement) {
283
- if (!placement) return objects;
284
- let changed = false;
285
- const next = objects.map((object) => {
286
- if (object.id !== id) return object;
287
- if (Object.entries(placement).every(([key, value]) => object[key] === value)) return object;
288
- changed = true;
289
- return { ...object, ...placement };
290
- });
291
- return changed ? next : objects;
292
- }
293
-
294
- export const CAMERA_MOVE_LABELS_KO = new Map([
295
- ["Static / locked-off", ko("Static / locked-off", "고정 샷")],
296
- ["Zoom in", ko("Zoom in", "줌 인")],
297
- ["Zoom out", ko("Zoom out", "줌 아웃")],
298
- ["Push-in (dolly in)", ko("Push-in (dolly in)", "푸시인(돌리 인)")],
299
- ["Pull-out (dolly out)", ko("Pull-out (dolly out)", "풀아웃(돌리 아웃)")],
300
- ["Pan left", ko("Pan left", "왼쪽 팬")],
301
- ["Pan right", ko("Pan right", "오른쪽 팬")],
302
- ["Tilt up", ko("Tilt up", "틸트 업")],
303
- ["Tilt down", ko("Tilt down", "틸트 다운")],
304
- ["Tracking / follow", ko("Tracking / follow", "트래킹 / 팔로우")],
305
- ["Orbit / arc", ko("Orbit / arc", "오빗 / 아크")],
306
- ["Crane up", ko("Crane up", "크레인 업")],
307
- ["Crane down", ko("Crane down", "크레인 다운")],
308
- ["Handheld", ko("Handheld", "핸드헬드")],
309
- ["Crash zoom in", ko("Crash zoom in", "크래시 줌 인")],
310
- ["Dolly-zoom (vertigo)", ko("Dolly-zoom (vertigo)", "돌리 줌(버티고)")],
311
- ["Whip pan", ko("Whip pan", "휩 팬")],
312
- ["Aerial / drone", ko("Aerial / drone", "공중 / 드론")],
313
- [CUSTOM_MOVE, ko(CUSTOM_MOVE, "직접 입력…")],
314
- ]);
315
-
316
- export const POSE_LABELS_KO = new Map([
317
- ["T-pose", ko("T-pose", "T 포즈")],
318
- ["Relaxed", ko("Relaxed", "편안한 자세")],
319
- ["Contrapposto", ko("Contrapposto", "콘트라포스토")],
320
- ["Walking", ko("Walking", "걷는 자세")],
321
- ["Seated", ko("Seated", "앉은 자세")],
322
- ["Arms crossed", ko("Arms crossed", "팔짱")],
323
- ["Pointing", ko("Pointing", "가리키기")],
324
- ["Hands on hips", ko("Hands on hips", "허리에 손")],
325
- ["Looking back", ko("Looking back", "뒤돌아보기")],
326
- ["Hands up", ko("Hands up", "손 올리기")],
327
- ]);
328
-
329
- export const SHOT_SIZE_LABELS_KO = new Map([
330
- ["extreme close-up", ko("extreme close-up", "익스트림 클로즈업")],
331
- ["close-up", ko("close-up", "클로즈업")],
332
- ["medium close-up", ko("medium close-up", "미디엄 클로즈업")],
333
- ["medium shot", ko("medium shot", "미디엄 샷")],
334
- ["medium-wide shot", ko("medium-wide shot", "미디엄 와이드 샷")],
335
- ["wide shot", ko("wide shot", "와이드 샷")],
336
- ["extreme wide shot", ko("extreme wide shot", "익스트림 와이드 샷")],
337
- ]);
338
-
339
- export const SHOT_LEVEL_LABELS_KO = new Map([
340
- ["overhead", ko("overhead", "오버헤드")],
341
- ["high angle", ko("high angle", "하이 앵글")],
342
- ["eye level", ko("eye level", "아이 레벨")],
343
- ["chest level", ko("chest level", "가슴 높이")],
344
- ["hip level", ko("hip level", "엉덩이 높이")],
345
- ["knee level", ko("knee level", "무릎 높이")],
346
- ["ground level", ko("ground level", "바닥 높이")],
347
- ]);
348
-
349
- export const SCENE_RENDERER_LABELS_KO = new Map([
350
- ["cube", ko("cube", "큐브")],
351
- ["sphere", ko("sphere", "구")],
352
- ["capsule", ko("capsule", "캡슐")],
353
- ["cylinder", ko("cylinder", "원기둥")],
354
- ["cone", ko("cone", "원뿔")],
355
- ["plane", ko("plane", "평면")],
356
- ["chair", ko("chair", "의자")],
357
- ["car", ko("car", "자동차")],
358
- ["aircraft", ko("aircraft", "비행기")],
359
- [CUTOUT_KIND, ko("cutout", "컷아웃")],
360
- [MESH_KIND, ko("Mesh", "모델")],
361
- ]);
362
-
363
- export const SCENE_OBJECT_NAME_LABELS_KO = new Map([
364
- ["Cube", ko("Cube", "큐브")],
365
- ["Sphere", ko("Sphere", "구")],
366
- ["Capsule", ko("Capsule", "캡슐")],
367
- ["Cylinder", ko("Cylinder", "원기둥")],
368
- ["Cone", ko("Cone", "원뿔")],
369
- ["Plane", ko("Plane", "평면")],
370
- ["Chair", ko("Chair", "의자")],
371
- ["Car", ko("Car", "자동차")],
372
- ["Plane (aircraft)", ko("Plane (aircraft)", "비행기")],
373
- ["Mesh", ko("Mesh", "모델")],
374
- ["Model", ko("Model", "모델")],
375
- ]);
376
-
377
- export function poseLabelKo(pose) {
378
- return pose?.custom ? pose.label : POSE_LABELS_KO.get(pose?.label) ?? pose?.label ?? "";
379
- }
380
-
381
- export function cameraMoveLabelKo(move) {
382
- return CAMERA_MOVE_LABELS_KO.get(move) ?? move;
383
- }
384
-
385
- export function sceneRendererLabelKo(renderer) {
386
- return SCENE_RENDERER_LABELS_KO.get(renderer) ?? renderer;
387
- }
12
+ import { ko } from "./locale.js";
388
13
 
389
- export function sceneObjectNameDisplayKo(name) {
390
- const match = /^(.+?)(?: ([2-9]\d*))?$/.exec(name ?? "");
391
- if (!match) return name;
392
- const [, base, suffix] = match;
393
- const label = SCENE_OBJECT_NAME_LABELS_KO.get(base);
394
- return label ? `${label}${suffix ? ` ${suffix}` : ""}` : name;
395
- }
396
-
397
- export function viewShortKo(viewShort) {
398
- if (viewShort === "front") return ko("front", "정면");
399
- if (viewShort === "back") return ko("back", "후면");
400
- if (viewShort?.includes("profile")) return viewShort.startsWith("left") ? ko("left profile", "왼쪽 측면") : ko("right profile", "오른쪽 측면");
401
- if (viewShort?.startsWith("front ¾")) return / L$/.test(viewShort) ? ko("front ¾ L", "정면 ¾ 왼쪽") : ko("front ¾ R", "정면 ¾ 오른쪽");
402
- if (viewShort?.startsWith("rear ¾")) return / L$/.test(viewShort) ? ko("rear ¾ L", "후면 ¾ 왼쪽") : ko("rear ¾ R", "후면 ¾ 오른쪽");
403
- return viewShort;
404
- }
405
-
406
- export function slateLineKo(shot) {
407
- return [
408
- SHOT_SIZE_LABELS_KO.get(shot.sizeLabel) ?? shot.sizeLabel,
409
- viewShortKo(shot.viewShort),
410
- SHOT_LEVEL_LABELS_KO.get(shot.levelLabel) ?? shot.levelLabel,
411
- `${shot.focalMm}mm`,
412
- ].filter(Boolean).join(" · ");
413
- }
414
-
415
- export function moveSequenceSlateKo(segments) {
416
- if (!segments.length) return "";
417
- if (segments.length === 1) {
418
- const seg = segments[0];
419
- return `${slateLineKo(seg.from)} → ${slateLineKo(seg.to)} · ${cameraMoveLabelKo(seg.label)}`;
420
- }
421
- const parts = [slateLineKo(segments[0].from)];
422
- for (const seg of segments) parts.push(`${cameraMoveLabelKo(seg.label)} → ${slateLineKo(seg.to)}`);
423
- return parts.join(" · ");
424
- }
425
-
426
- /** The hierarchy node id an IK focus lights up. Focus maps stay keyed by the
427
- * BONE TOKEN; the row id namespaces the result so each cast member's tree
428
- * highlights its own bone (#76). */
429
- export function hierarchyIdForIkFocus(focus, rowId = "characterA") {
430
- if (!focus) return null;
431
- const exact = Object.entries(RIG_HIERARCHY_FOCUS)
432
- .find(([id, mappedFocus]) =>
433
- !["rig.torso", "rig.leftArm", "rig.rightArm", "rig.leftLeg", "rig.rightLeg"].includes(id) &&
434
- mappedFocus === focus
435
- );
436
- return `${rowId}.${exact?.[0] ?? "rig"}`;
437
- }
438
-
439
- export const CAPTURE_W = 1920;
440
- export const CAPTURE_H = 1080;
441
- export const MCP_CAPTURE_W = 640;
442
- export const MCP_CAPTURE_H = 360;
443
- export const MCP_CAPTURE_SAMPLE_STEP = 32;
444
- export const SHOT_ASPECT_PRESETS = Object.freeze({
445
- "16:9": Object.freeze({ label: "16:9", aspect: SHOT_ASPECT_RATIOS["16:9"], width: 1920, height: 1080 }),
446
- "2.39:1": Object.freeze({ label: "2.39:1", aspect: SHOT_ASPECT_RATIOS["2.39:1"], width: 2390, height: 1000 }),
447
- "9:16": Object.freeze({ label: "9:16", aspect: SHOT_ASPECT_RATIOS["9:16"], width: 1080, height: 1920 }),
448
- "1:1": Object.freeze({ label: "1:1", aspect: SHOT_ASPECT_RATIOS["1:1"], width: 1080, height: 1080 }),
449
- "4:3": Object.freeze({ label: "4:3", aspect: SHOT_ASPECT_RATIOS["4:3"], width: 1440, height: 1080 }),
450
- "12:7": Object.freeze({ label: "12:7", aspect: SHOT_ASPECT_RATIOS["12:7"], width: 1536, height: 896, title: "12:7 video-inbetweener ratio" }),
451
- "fal 480P": Object.freeze({ label: "fal 480P", aspect: SHOT_ASPECT_RATIOS["fal 480P"], width: 1664, height: 960, title: "Fal H3 480P canvas (832x480)" }),
452
- });
453
- // Pre-generated clip shipped with the build so a bridge-less session (a hosted
454
- // static demo, or `npm run dev:ui`) still shows real generated motion.
455
- // Root-absolute on purpose: the studio is served from "/app/" while these
456
- // public files stay at the site root, so a page-relative path would resolve
457
- // to "/app/models/..." and 404. Vite's base is "/" (own apex domain), so a
458
- // leading slash is now the correct — and only — form that works from both
459
- // the dev server and the built site.
460
- // Per-character rig model: the stage entry's `model` is the FBX file stem in
461
- // public/models AND the wire rig name sent to ARDY, so mesh and export can
462
- // never drift apart.
463
- export const characterModelUrl = (model) => `/models/${model}.fbx`;
464
-
465
- /** Shipped rig names as the operator says them, not as the files spell them. */
466
- export const CHARACTER_MODEL_LABELS = { "y-bot-tpose": "Y Bot", "x-bot-tpose": "X Bot" };
467
-
468
- /** Sequential ids for spawned characters, collision-free against the cast. */
469
- export function nextCharacterId(list) {
470
- const ids = new Set(list.map((entry) => entry.id));
471
- let n = list.length + 1;
472
- let id = `char-${n}`;
473
- while (ids.has(id)) {
474
- n += 1;
475
- id = `char-${n}`;
476
- }
477
- return id;
478
- }
479
- export const DEMO_MOTION_URL = "/demo/walk-then-stop.npz";
480
- export const DEMO_MOTION_PROMPT = "A person walks forward.";
481
- // How long a deletion keeps offering its one-press Undo. Both toasts use the
482
- // same window so the two deletion paths feel like one rule.
483
- export const OBJECT_DELETE_UNDO_MS = 7000;
484
- export const ASSET_DELETE_UNDO_MS = 7000;
485
- // Neutral grey clay: the set is the grey workbench, so the figures are grey
486
- // too, light enough to stand off the deck and the sky.
487
- export const CLAY = "#bdbec3";
488
- export const CLAY_B = "#a4a5aa";
489
- // X Bot's shell is smooth (no raised exoskeleton like Y Bot's), so it gets a
490
- // brighter, whiter clay to keep it readable against the set.
491
- export const CLAY_X = "#d0d0d4";
492
- // Model/role default for a cast member; a user-picked entry.tint always wins
493
- // over this at render time.
494
- export const defaultCharacterTint = (entry, index) => (entry.model === "x-bot-tpose" ? CLAY_X : index === 0 ? CLAY : CLAY_B);
495
-
496
- /**
497
- * The first editor view should read like a blocking desk, not a security
498
- * camera at the back of the stage. Keep the selected subject large enough to
499
- * judge a pose while leaving a little floor around the feet. This is a pure
500
- * framing helper so the seed and any future reset action share the same
501
- * distance rule.
502
- */
503
- export function editorFrameForSubject(subject) {
504
- const scale = Number.isFinite(subject?.scale) && subject.scale > 0 ? subject.scale : 1;
505
- const distance = THREE.MathUtils.clamp(3.5 / scale, 2.8, 4.8);
506
- const x = subject?.x ?? 0;
507
- const z = subject?.z ?? 0;
508
- const target = { x, y: 1.05, z };
509
- return {
510
- target,
511
- position: {
512
- x: x + distance * 0.62,
513
- y: target.y + distance * 0.34,
514
- z: z + distance * 0.78,
515
- },
516
- };
517
- }
518
-
519
- export const DEFAULT_SUBJECT = DEFAULT_SUBJECT_ONE;
520
- export const DEFAULT_SUBJECT2 = DEFAULT_SUBJECT_TWO;
521
- export const DEFAULT_ENVIRONMENT = "a sunlit modern living room";
522
- export const DEFAULT_CAMERA_POSITION = { x: 0.97, y: 1.62, z: 2.39 };
523
- export const REST_BONES = Object.fromEntries(POSE_BONES.map((b) => [b.id, [0, 0, 0]]));
524
- // Two clocks, one boundary. The app timeline runs the production 24 fps —
525
- // the rate the reference footage and the recorded export are counted in.
526
- // ARDY Core generates on its trained 20 fps clock and that cannot move, so
527
- // takes are RETIMED inbound (bridge → app, retimeMotion) and frame numbers
528
- // are CONVERTED outbound (app → bridge, toArdyFrame). Nothing between the
529
- // boundaries may mix the clocks.
530
- export const TIMELINE_FPS = 24;
531
- // The motion bridge uses the Studio's fixed 24 fps production clock.
532
- export const ARDY_FPS = 24;
533
- export const toArdyFrame = (frame) => Math.round((frame * ARDY_FPS) / TIMELINE_FPS);
534
-
535
- // Outbound converters: timeline-frame entries → strictly-ascending bridge
536
- // frames. Rounding can land two timeline frames on one bridge frame; the
537
- // first wins — the bridge refuses non-ascending lists outright.
538
- /** Where a placed pose lands, in TIMELINE frames, for a clip of `clipFrames`. */
539
- export function posePlacementFrame(placement, clipFrames, playheadFrame) {
540
- const last = Math.max(0, clipFrames - 1);
541
- if (placement === "end") return last;
542
- if (placement === "middle") return Math.round(last / 2);
543
- if (placement === "playhead") return Math.max(0, Math.min(last, playheadFrame));
544
- return 0;
545
- }
546
-
547
- export function toArdyFrameEntries(entries) {
548
- const out = [];
549
- for (const entry of entries) {
550
- const frame = toArdyFrame(entry.frame);
551
- if (out.length && frame <= out[out.length - 1].frame) continue;
552
- out.push({ ...entry, frame });
553
- }
554
- return out;
555
- }
556
-
557
- export function toArdySegments(segments) {
558
- const out = [];
559
- for (const segment of segments) {
560
- const startFrame = toArdyFrame(segment.startFrame);
561
- const endFrame = toArdyFrame(segment.endFrame);
562
- if (endFrame <= startFrame) continue; // a zero-length rounding remnant covers nothing
563
- out.push({ ...segment, startFrame, endFrame });
564
- }
565
- return out;
566
- }
567
-
568
- export const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × TIMELINE_FPS frames
569
- export const DEFAULT_PLAYBACK_SPEED = 1;
570
- export const BRIDGE_RECHECK_MS = 3000;
571
- export const ARDY_PROMPT_HORIZON_FRAMES = 2 * TIMELINE_FPS; // core model horizon: 2 seconds, counted on the timeline clock
572
- export const MAX_WAYPOINTS = 32; // ARDY bridge contract: a root path holds 2..32 distinct waypoint frames
573
- export const ARDY_PROMPT_MAX = 500; // bridge contract: prompt must be non-empty, capped at 500 chars
574
- export const ARDY_DURATION_MIN = 1; // the UI works in whole seconds; the bridge floor is 0.15 s
575
- export const ARDY_DURATION_MAX = 1200; // bridge contract: duration capped at 1200 s
576
- export const ARDY_SEED_MAX = 2 ** 31 - 1; // bridge contract: optional seed, integer in 0..2**31-1
577
- // Scheduled inpainting (contract C3): how hard a regeneration holds onto the
578
- // take that is already loaded. 0 turns preserving OFF entirely; 0.5 is the
579
- // paper's recommended setting. The app ships this RAW number and nothing else:
580
- // the strength -> denoising-schedule mapping (sigma_s = round(1000 * strength),
581
- // sigma_e = min(50, sigma_s), diffusion time 0..1000) lives on the box side, and
582
- // duplicating it here is exactly how the two would silently drift apart.
583
- export const ARDY_PRESERVE_DEFAULT = 0.5;
584
- export const DEFAULT_PROMPT_CLIPS = [];
585
-
586
- /** The prompt-block schedule: every authored block in frame order, with the gaps
587
- * between them filled by the base prompt so the bridge always receives one
588
- * contiguous 0..clipFrames sequence. Blocks shorter than 3 frames are dropped —
589
- * they cannot carry a generation.
590
- * Pure and shared on purpose: the request assembly decides which blocks count as
591
- * "edited", and the preserve panel has to name the SAME blocks. Re-deriving them
592
- * approximately beside the slider is how the UI starts promising a locality the
593
- * wire does not carry. */
594
- export function buildPromptSchedule(sourcePromptClips, clipFrames, prompt) {
595
- const segments = [];
596
- let cursor = 0;
597
- for (const clip of sourcePromptClips) {
598
- const startFrame = Math.max(cursor, Math.min(clipFrames, clip.startFrame));
599
- const endFrame = Math.max(startFrame, Math.min(clipFrames, clip.endFrame));
600
- if (startFrame > cursor) segments.push({ startFrame: cursor, endFrame: startFrame, prompt });
601
- if (endFrame - startFrame >= 3) segments.push({ startFrame, endFrame, prompt: clip.text.trim() || prompt });
602
- cursor = Math.max(cursor, endFrame);
603
- if (cursor >= clipFrames) break;
604
- }
605
- if (cursor < clipFrames) segments.push({ startFrame: cursor, endFrame: clipFrames, prompt });
606
- if (segments.length === 0) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
607
- return segments;
608
- }
609
-
610
- /** The DISTINCT ik track ids keyed inside the half-open timeline span
611
- * [startFrame, endFrame). `ikState.keys` is Map(frame -> Map(trackId -> pose)),
612
- * so a frame with several dragged effectors contributes all of them. */
613
- export function ikTracksInRange(ikState, ikFrames, startFrame, endFrame) {
614
- const tracks = new Set();
615
- for (const frame of ikFrames) {
616
- if (frame < startFrame || frame >= endFrame) continue;
617
- for (const trackId of ikState?.keys?.get(frame)?.keys() ?? []) tracks.add(trackId);
618
- }
619
- return [...tracks];
620
- }
621
-
622
- /* Which limb a keyed IK track frees once the preserve mask is grouped
623
- (contract C3v2). The single source of truth for track -> mask group is
624
- TRACK_GROUPS in tools/kimodo/preserve-mask.mjs; nothing under tools/ may be
625
- imported into src/, so the rule is transcribed here — as USER-FACING LABELS
626
- only, never as a second copy of the mapping the wire depends on:
627
- leftHand, leftElbow -> leftArm rightHand, rightElbow -> rightArm
628
- leftFoot, leftKnee -> leftLeg rightFoot, rightKnee -> rightLeg
629
- head, neck -> head spine, chest, leftShoulder,
630
- hips -> torso AND root rightShoulder -> torso
631
- The track ids are the same namespace on both sides (src/ardy/ik.js owns
632
- them), so the app ships whatever ids it has and the mask builder maps them.
633
- Torso and pelvis tracks are worded as the whole body: the pelvis carries the
634
- global transform as well as a bone, and naming a limb beside it would promise
635
- a locality the mask does not have. */
636
- export const PRESERVE_TRACK_LIMBS = {
637
- leftHand: "leftArm",
638
- leftElbow: "leftArm",
639
- rightHand: "rightArm",
640
- rightElbow: "rightArm",
641
- leftFoot: "leftLeg",
642
- leftKnee: "leftLeg",
643
- rightFoot: "rightLeg",
644
- rightKnee: "rightLeg",
645
- head: "head",
646
- neck: "head",
647
- spine: "body",
648
- chest: "body",
649
- leftShoulder: "body",
650
- rightShoulder: "body",
651
- hips: "body",
652
- };
653
- // A fixed reading order, so the same edit always reads back the same way.
654
- export const PRESERVE_LIMB_ORDER = ["head", "leftArm", "rightArm", "leftLeg", "rightLeg"];
655
- export const preserveLimbLabel = (limb) => ({
656
- head: ko("head", "머리"),
657
- leftArm: ko("left arm", "왼팔"),
658
- rightArm: ko("right arm", "오른팔"),
659
- leftLeg: ko("left leg", "왼발"),
660
- rightLeg: ko("right leg", "오른발"),
661
- }[limb]);
662
-
663
- /** One muted line naming what a grouped preserve run will regenerate, or "" when
664
- * no track can be attributed — then the request carries no `tracks` either and
665
- * the panel's existing whole-take wording is already the truth. */
666
- export function preserveTracksSummary(tracks) {
667
- const limbs = new Set();
668
- for (const trackId of tracks) {
669
- const limb = PRESERVE_TRACK_LIMBS[trackId];
670
- if (limb) limbs.add(limb);
671
- }
672
- if (limbs.size === 0) return "";
673
- if (limbs.has("body")) return ko("regenerates the whole body", "몸 전체를 다시 생성");
674
- const names = PRESERVE_LIMB_ORDER.filter((limb) => limbs.has(limb)).map(preserveLimbLabel);
675
- return isKo ? `${names.join("·")}만 다시 생성` : `regenerates ${names.join(" · ")} only`;
676
- }
677
-
678
- /* --------------------------- line editing (C6) ---------------------------
679
- * "Grab the joint's motion path and pull it; the joint follows the pulled path
680
- * exactly" (ProjFlow). The arithmetic lives in ./line-edit.js; what stays here
681
- * is the panel's own vocabulary and the one thing App owns that the pure module
682
- * cannot — the mapping from a track id to a HUMAN LABEL, which is read straight
683
- * out of ik.js's three tables so the picker can never drift from the pose
684
- * studio's own naming. An id in LINE_EDIT_TRACK_IDS with no entry in those
685
- * tables is a bug, and falling back to the raw id makes it visible instead of
686
- * silent. */
687
- export const LINE_EDIT_TRACK_OPTIONS = LINE_EDIT_TRACK_IDS.map((id) => ({
688
- value: id,
689
- label: [...IK_TRACKS, ...MID_TRACKS, ...FK_TRACKS].find((track) => track.id === id)?.label ?? id,
690
- }));
691
- /** The same human label, by id — what a version-strip chip says a refinement
692
- * touched ("Refine · Left Hand"). */
693
- export const lineTrackLabel = (id) => LINE_EDIT_TRACK_OPTIONS.find((option) => option.value === id)?.label ?? id;
694
- /** The default edited track: a wrist is the joint the paper's own demo draws
695
- * with, and the one whose screen trajectory a user can actually see. */
696
- export const LINE_EDIT_DEFAULT_TRACK = "leftHand";
697
- /** Draw a grab handle every Nth frame. Fewer would leave the curve looking
698
- * un-grabbable in the gaps; more turns a 200-frame range into a solid bead of
699
- * dots. The hit test tolerates CURVE_GRAB_RADIUS_PX regardless, so the handles
700
- * are an affordance rather than the actual geometry. */
701
- export const LINE_CURVE_MARKER_STRIDE = 4;
702
- /** The shortest curve with anything draggable in it: both ends are hard-pinned
703
- * to the original trajectory, so a range needs more than 2 x PINNED_CURVE_ENDS
704
- * frames before the middle can move at all. */
705
- export const MIN_CURVE_POINTS = PINNED_CURVE_ENDS * 2 + 1;
706
- /** How long a released drag has to sit still before it costs a preview. Long
707
- * enough that pull-pull-pull is one request instead of three, short enough that
708
- * the loop still feels like it answers the gesture — a preview round trip is
709
- * ~1 s on the warm service, so 150 ms is noise against it. */
710
- export const LINE_PREVIEW_DEBOUNCE_MS = 150;
711
- /** How often the line-edit capability is re-probed while the bridge is up and
712
- * the answer is still no.
713
- *
714
- * The bridge answers `capabilities.lineEdit` from a LAZY ssh probe of the
715
- * ProjFlow box behind a 5 s cache, so the very first /ardy/health of a session
716
- * can land on a cold cache and come back false — a box that is perfectly fine
717
- * two seconds later. Asking once per bridge-up transition therefore made the
718
- * whole mode dead until a reload (first boot broken, second boot fine, which is
719
- * exactly how it was reported). A capability is an ADVERTISEMENT, so it is
720
- * allowed to arrive late; what is not allowed is to assume it. 4 s sits just
721
- * under the bridge's own 5 s TTL, so a retry can actually see a new answer
722
- * rather than re-reading the same cached false. */
723
- export const LINE_CAPABILITY_RETRY_MS = 4000;
724
- /** Why a validateLineEdit code refused, said once, in the user's language.
725
- * Keyed by the pure module's stable codes so the copy and the check can never
726
- * disagree about which field was wrong. */
727
- export const LINE_EDIT_REFUSALS = {
728
- sourceMotion: ["The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"],
729
- track: ["Pick a joint to edit first", "먼저 편집할 관절을 고르세요"],
730
- frameRange: ["The frame range must sit inside the clip and span at least 2 frames", "프레임 구간은 클립 안에 있어야 하고 최소 2프레임이어야 해요"],
731
- points: ["Pull a longer stretch of the path — a single point is not a path", "궤적을 좀 더 길게 잡아당겨 주세요 — 점 하나는 경로가 아니에요"],
732
- camera: ["The camera could not be captured — nudge the view and try again", "카메라를 캡처하지 못했어요 — 뷰를 조금 움직인 뒤 다시 시도하세요"],
733
- prompt: ["The motion prompt is not usable for a line edit", "모션 프롬프트를 라인 편집에 쓸 수 없어요"],
734
- pins: ["The pinned moments could not be sent — try clearing them and pinning again", "찍은 순간을 보낼 수 없어요 — 지우고 다시 찍어 보세요"],
735
- shape: ["The line edit could not be assembled", "라인 편집을 구성하지 못했어요"],
736
- };
737
- /** Why curveToPoints2d refused. Same shape, different stage: these are about
738
- * the CURVE, before there is a payload to validate. */
739
- export const LINE_CURVE_REFUSALS = {
740
- empty: ["This joint is not visible over the chosen frames — frame it in view first", "선택한 구간에서 이 관절이 보이지 않아요 — 먼저 화면 안에 들어오도록 잡아 주세요"],
741
- offscreen: ["The pulled path leaves the frame — keep it inside the dashed border", "잡아당긴 궤적이 화면을 벗어났어요 — 점선 테두리 안에 두세요"],
742
- };
743
-
744
- // Named ingest failures, in both locales. A reason the user cannot act on is
745
- // not a message: each line says what was wrong with THIS source.
746
- export const MULTIMODEL_REASONS = {
747
- "url-empty": ["Enter a video URL first", "영상 URL을 먼저 입력하세요"],
748
- "url-protocol-relative": ["Use a full https:// address", "https:// 로 시작하는 전체 주소를 쓰세요"],
749
- "url-scheme-unsupported": ["Only http(s) or a local /path is accepted", "http(s) 또는 로컬 /경로만 사용할 수 있어요"],
750
- "url-platform-page": [
751
- "YouTube/Vimeo pages are players, not video files, and block browser downloads. Start the dev bridge to use the URL directly, or download the clip and use Choose video.",
752
- "유튜브·비메오 주소는 영상 파일이 아니라 플레이어 페이지이고 브라우저 다운로드를 막습니다. 개발 브리지를 켜면 URL을 그대로 쓸 수 있고, 아니면 영상을 내려받아 '영상 선택'을 쓰세요.",
753
- ],
754
- "url-malformed": ["That address could not be parsed", "주소를 해석할 수 없어요"],
755
- "fetch-failed": ["The host refused a browser download (CORS or offline)", "호스트가 브라우저 다운로드를 거부했어요(CORS 또는 오프라인)"],
756
- "not-a-video": ["That address did not return a video (check the path)", "그 주소는 영상을 반환하지 않았어요(경로를 확인하세요)"],
757
- "decode-failed": ["This browser cannot decode that video", "이 브라우저가 디코딩할 수 없는 영상이에요"],
758
- "duration-unreadable": ["The clip length could not be read", "클립 길이를 읽지 못했어요"],
759
- "dimensions-unreadable": ["The frame size could not be read", "프레임 크기를 읽지 못했어요"],
760
- "probe-timeout": ["The video never reported metadata", "영상이 메타데이터를 보내지 않았어요"],
761
- "fetch-unavailable": ["This browser cannot download files", "이 브라우저는 파일을 내려받을 수 없어요"],
762
- "footage-load-timeout": ["The clip never became seekable", "클립이 탐색 가능한 상태가 되지 않았어요"],
763
- "seek-timeout": ["Seeking inside the clip stalled", "클립 내부 탐색이 멈췄어요"],
764
- "pose-runtime-unavailable": ["The pose engine is missing from this build", "이 빌드에 포즈 엔진이 없어요"],
765
- "pose-model-download-failed": ["The pose model could not be downloaded (offline?)", "포즈 모델을 내려받지 못했어요(오프라인인가요?)"],
766
- "pose-engine-download-failed": ["The pose engine could not be downloaded (offline?)", "포즈 엔진을 내려받지 못했어요(오프라인인가요?)"],
767
- "pose-engine-init-failed": ["The pose engine failed to start on this device", "이 기기에서 포즈 엔진을 시작하지 못했어요"],
768
- "rest-unavailable": ["The rig rest data (/ardy/cskel27-rest.json) could not be loaded", "리그 레스트 데이터(/ardy/cskel27-rest.json)를 불러오지 못했어요"],
769
- "no-person-found": ["No person was detected in the footage", "영상에서 사람을 감지하지 못했어요"],
770
- "no-person-in-photo": ["No person was detected in that photograph", "사진에서 사람을 감지하지 못했어요"],
771
- "image-load-timeout": ["The photograph never finished decoding", "사진 디코딩이 끝나지 않았어요"],
772
- "pose-partly-occluded": ["Shoulders and hips must both be visible in the photograph", "사진에 어깨와 골반이 모두 보여야 해요"],
773
- "no-usable-pose": ["A person was seen, but no stable pose could be fitted", "사람은 보였지만 안정적인 포즈를 만들지 못했어요"],
774
- "rig-not-loaded": ["Subject 1's rig is not loaded yet", "인물 1 리그가 아직 로드되지 않았어요"],
775
- "bridge-unreachable": ["The dev bridge did not answer", "개발 브리지가 응답하지 않아요"],
776
- "bridge-footage-incomplete": ["The bridge stream ended without footage", "브리지 전송이 영상 없이 끝났어요"],
777
- "footage-url-invalid": ["That address could not be used for a bridge download", "이 주소는 브리지 다운로드에 쓸 수 없어요"],
778
- "footage-probe-failed": ["The bridge could not read that page's video info", "브리지가 그 페이지의 영상 정보를 읽지 못했어요"],
779
- "footage-live-unsupported": ["Live streams have no fixed length and cannot be ingested", "라이브 스트림은 길이가 없어 인제스트할 수 없어요"],
780
- "footage-too-long": ["That video is over 15 minutes — trim or pick a shorter one", "15분이 넘는 영상이에요 — 잘라내거나 더 짧은 걸 골라 주세요"],
781
- "footage-download-failed": ["The bridge could not download that video", "브리지가 영상을 내려받지 못했어요"],
782
- "footage-normalize-failed": ["The bridge could not convert that video for extraction", "브리지가 영상을 추출용으로 변환하지 못했어요"],
783
- "footage-timeout": ["The bridge download took too long and was stopped", "브리지 다운로드가 너무 오래 걸려 중단됐어요"],
784
- "bridge-extract-incomplete": ["The bridge stream ended without a take", "브리지 전송이 테이크 없이 끝났어요"],
785
- "extract-host-missing": ["The bridge has no GPU box configured (CCLAY_EXTRACT_HOST)", "브리지에 GPU 박스가 설정돼 있지 않아요(CCLAY_EXTRACT_HOST)"],
786
- "extract-bridge-required": ["GVHMR extraction requires the local GPU bridge", "GVHMR 추출에는 로컬 GPU 브리지가 필요해요"],
787
- "extract-backend-unsupported": ["This bridge is not configured for GVHMR", "이 브리지가 GVHMR로 설정되지 않았어요"],
788
- "extract-upload-failed": ["The footage could not be copied to the GPU box", "영상을 GPU 박스로 복사하지 못했어요"],
789
- "extract-upload-too-large": ["That clip is too large to upload for extraction (300 MB cap)", "추출 업로드 한도(300MB)를 넘는 영상이에요"],
790
- "extract-upload-empty": ["No video bytes arrived at the bridge", "브리지에 영상 데이터가 도착하지 않았어요"],
791
- "extract-footage-unknown": ["The bridge no longer holds that download — re-ingest the URL", "브리지에 그 다운로드가 더 이상 없어요 — URL을 다시 넣어 주세요"],
792
- "extract-run-failed": ["GVHMR failed on the GPU box (see the bridge log)", "GPU 박스에서 GVHMR 실행이 실패했어요(브리지 로그 확인)"],
793
- "extract-no-person": ["The GPU box tracked no person in that footage", "GPU 박스가 영상에서 사람을 추적하지 못했어요"],
794
- "extract-convert-failed": ["The extracted take could not be converted for the timeline", "추출된 테이크를 타임라인용으로 변환하지 못했어요"],
795
- "extract-timeout": ["GPU extraction took too long and was stopped", "GPU 추출이 너무 오래 걸려 중단됐어요"],
796
- };
797
-
798
- // Extraction samples and bakes straight onto the production clock — an
799
- // extracted take never touches ARDY, so it never needs retiming either.
800
- export const MULTIMODEL_SAMPLE_FPS = TIMELINE_FPS;
14
+ import {
15
+ PRESETS,
16
+ editorFrameForSubject,
17
+ } from "./stage-startup-defaults.js";
18
+
19
+ export {
20
+ PRESETS,
21
+ SHOT_ASPECT_PRESETS,
22
+ characterModelUrl,
23
+ CHARACTER_MODEL_LABELS,
24
+ nextCharacterId,
25
+ DEMO_MOTION_URL,
26
+ DEMO_MOTION_PROMPT,
27
+ OBJECT_DELETE_UNDO_MS,
28
+ ASSET_DELETE_UNDO_MS,
29
+ CLAY,
30
+ CLAY_B,
31
+ CLAY_X,
32
+ defaultCharacterTint,
33
+ editorFrameForSubject,
34
+ DEFAULT_SUBJECT,
35
+ DEFAULT_SUBJECT2,
36
+ DEFAULT_ENVIRONMENT,
37
+ DEFAULT_CAMERA_POSITION,
38
+ REST_BONES,
39
+ GIZMO_HOTKEYS,
40
+ WORKSPACE_LAYOUT_KEY,
41
+ DEFAULT_WORKSPACE_LAYOUT,
42
+ loadWorkspaceLayout,
43
+ loadSceneStartup,
44
+ } from "./stage-startup-defaults.js";
45
+
46
+ export {
47
+ RIG_HIERARCHY_FOCUS,
48
+ HIERARCHY_INSPECTOR_TITLES,
49
+ POSE_LABELS_KO,
50
+ CAMERA_MOVE_LABELS_KO,
51
+ SHOT_SIZE_LABELS_KO,
52
+ SHOT_LEVEL_LABELS_KO,
53
+ SCENE_RENDERER_LABELS_KO,
54
+ SCENE_OBJECT_NAME_LABELS_KO,
55
+ poseLabelKo,
56
+ cameraMoveLabelKo,
57
+ sceneRendererLabelKo,
58
+ sceneObjectNameDisplayKo,
59
+ viewShortKo,
60
+ slateLineKo,
61
+ moveSequenceSlateKo,
62
+ hierarchyIdForIkFocus,
63
+ } from "./stage-labels-ko.js";
64
+
65
+ export * from "./stage-attach-frames.js";
66
+
67
+ export { Character, MotionTrails } from "./stage-characters.jsx";
68
+
69
+ export {
70
+ ShotRig,
71
+ MoveRig,
72
+ FollowCamRig,
73
+ ShotLookApplier,
74
+ CameraGlide,
75
+ EditorCamSeed,
76
+ RenderLoopController,
77
+ ContextLossGuard,
78
+ ViewportLayoutInvalidator,
79
+ } from "./stage-canvas-rigs.jsx";
80
+
81
+ export {
82
+ KeyLightPuck,
83
+ ShotCameraGhost,
84
+ CameraRailScenePreview,
85
+ CRANE_AXES,
86
+ CRANE_ARROW_LEN,
87
+ CRANE_GIZMO_SCALE,
88
+ prepareRailBend,
89
+ GizmoLabel,
90
+ ObjectPathHandles,
91
+ CraneHandles,
92
+ ShotPathPreview,
93
+ } from "./stage-editor-gizmos.jsx";
94
+
95
+ export {
96
+ CAPTURE_W,
97
+ CAPTURE_H,
98
+ MCP_CAPTURE_W,
99
+ MCP_CAPTURE_H,
100
+ MCP_CAPTURE_SAMPLE_STEP,
101
+ CAPTURE_FOG_NEAR,
102
+ CAPTURE_FOG_FAR,
103
+ CaptureRig,
104
+ captureMcpFrame,
105
+ } from "./stage-capture.jsx";
106
+ export {
107
+ TIMELINE_FPS,
108
+ ARDY_FPS,
109
+ toArdyFrame,
110
+ posePlacementFrame,
111
+ toArdyFrameEntries,
112
+ toArdySegments,
113
+ DEFAULT_DURATION_S,
114
+ DEFAULT_PLAYBACK_SPEED,
115
+ BRIDGE_RECHECK_MS,
116
+ ARDY_PROMPT_HORIZON_FRAMES,
117
+ MAX_WAYPOINTS,
118
+ ARDY_PROMPT_MAX,
119
+ ARDY_DURATION_MIN,
120
+ ARDY_DURATION_MAX,
121
+ ARDY_SEED_MAX,
122
+ ARDY_PRESERVE_DEFAULT,
123
+ DEFAULT_PROMPT_CLIPS,
124
+ buildPromptSchedule,
125
+ ikTracksInRange,
126
+ PRESERVE_TRACK_LIMBS,
127
+ PRESERVE_LIMB_ORDER,
128
+ preserveLimbLabel,
129
+ preserveTracksSummary,
130
+ LINE_EDIT_TRACK_OPTIONS,
131
+ lineTrackLabel,
132
+ LINE_EDIT_DEFAULT_TRACK,
133
+ LINE_CURVE_MARKER_STRIDE,
134
+ MIN_CURVE_POINTS,
135
+ LINE_PREVIEW_DEBOUNCE_MS,
136
+ LINE_CAPABILITY_RETRY_MS,
137
+ LINE_EDIT_REFUSALS,
138
+ LINE_CURVE_REFUSALS,
139
+ MULTIMODEL_REASONS,
140
+ MULTIMODEL_SAMPLE_FPS,
141
+ } from "./ardy-bridge-contract.js";
801
142
 
802
143
  /* ------------------------------------------------------------------ 3D --- */
803
144
 
804
- // Memoized: unchanged cast members skip re-rendering on every playhead tick.
805
- export const Character = memo(function Character({ url, position, rot, tint, pose, scale = 1, onRig, pickId, partColoursEnabled = false, partColoursMode = "shaded" }) {
806
- const fbx = useFBX(url);
807
- const model = useMemo(() => {
808
- const clone = SkeletonUtils.clone(fbx);
809
- // Mixamo exports centimetres; `scale` is the FILMED person's stature.
810
- // THE INVARIANT: extraction divided this take's root travel by that
811
- // stature, so the clip and the scale must travel together — apply one
812
- // without the other and every stride overshoots by the same factor and
813
- // the feet skate. It belongs HERE, on the world transform, and never
814
- // inside rig space: playback.js derives prep.scale from the bind pose,
815
- // so a scaled bone hierarchy would be measured as a different rig.
816
- clone.scale.setScalar(0.01 * scale);
817
- // The joint shells (Alpha_Joints / Beta_Joints — elbows, knees, the
818
- // exoskeleton bands) render in a darkened shade of the body tint so
819
- // the segments read as separate pieces.
820
- // The shipped clay is intentionally pale, but the stage deck is pale too.
821
- // Lift neither with emissive nor a second light; a small value drop keeps
822
- // the same authored hue while restoring silhouette contrast in the editor.
823
- const bodyTint = new THREE.Color(tint).multiplyScalar(0.9);
824
- const jointTint = bodyTint.clone().multiplyScalar(0.62);
825
- clone.traverse((child) => {
826
- if (child.isMesh) {
827
- // warm clay reads as a maquette; cold grey reads as a broken render
828
- child.material = new THREE.MeshStandardMaterial({
829
- color: /_Joints$/i.test(child.name) ? jointTint : bodyTint,
830
- // A slightly tighter highlight gives the clay enough form to read
831
- // at blocking distance without turning it into shiny plastic.
832
- roughness: 0.66,
833
- metalness: 0,
834
- envMapIntensity: 0.35,
835
- });
836
- child.frustumCulled = false;
837
- // The subject's own shadow is what plants it on the deck; a blocking
838
- // frame without one leaves the figure floating over flat colour.
839
- child.castShadow = true;
840
- child.receiveShadow = true;
841
- }
842
- });
843
- if (partColoursEnabled) applyPartColours(clone, partColoursMode);
844
- addFacingMarks(clone);
845
- // Stamp the bind pose while the rig is still untouched: the pose effect
846
- // below runs immediately after and would otherwise be baked into "rest".
847
- primeBindPose(clone);
848
- return clone;
849
- }, [fbx, tint, partColoursEnabled, partColoursMode]);
850
-
851
- // A new stature (a fresh extraction on this character) must not rebuild the
852
- // clone — that would drop the rig the playback effects hold. Only the world
853
- // transform moves; the same invariant as above applies.
854
- useEffect(() => {
855
- model.scale.setScalar(0.01 * scale);
856
- }, [model, scale]);
857
-
858
- useEffect(() => {
859
- // During playback the pose prop is null and the motion drives the rig;
860
- // this effect can flush AFTER the playback effect in a later commit
861
- // (measured: playback at t, pose reset at t+1.8ms), and resetting to
862
- // REST then would clobber the animation back to the bind pose. So a
863
- // null pose means "playback owns the rig" — do not touch it.
864
- if (!pose) return;
865
- // every joint is reset first, otherwise switching presets leaves stale limbs
866
- applyPose(model, { ...REST_BONES, ...pose.bones });
867
- // A measured pose (photo, bottled take frame) carries its hips height;
868
- // rotations alone would leave a crouch floating at standing height.
869
- applyHipsOffset(model, pose.rootY ?? 0);
870
- }, [model, pose]);
871
-
872
- const onRigRef = useRef(onRig);
873
- onRigRef.current = onRig;
874
- // Report on MODEL change only — a per-render callback identity change
875
- // must not re-run this effect.
876
- useEffect(() => {
877
- onRigRef.current?.(model);
878
- }, [model]);
879
-
880
- return (
881
- // characterPick makes the body a first-class click target: the Scene
882
- // picker walks up from any hit mesh, finds the tag, and routes the
883
- // selection to the hierarchy so the Inspector owns the controls.
884
- <group position={position} rotation={[0, (rot * Math.PI) / 180, 0]} userData={pickId ? { characterPick: pickId } : undefined}>
885
- <primitive object={model} />
886
- </group>
887
- );
888
- });
889
-
890
- /** Selection marker for the picked cast member: a Unity-style XYZ tripod
891
- * plus a ground ring at the feet. X/Z arrows also drag the character on the
892
- * floor (Y is display-only — characters stand on the deck). */
893
- export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
894
- useEffect(() => {
895
- const cam = camRef.current;
896
- if (!cam) return;
897
- // A preset is applied once per (preset, nonce), not once per mount. The
898
- // Canvas has no Suspense boundary of its own, so a cast member whose FBX
899
- // is still downloading suspends the whole scene graph; when it resolves,
900
- // React remounts the siblings — this rig included, on a fresh camera
901
- // object — and a per-mount seed would snap the shot back to the preset,
902
- // discarding a frame_shot or a manual move that landed in the meantime
903
- // (#86 on slow CI runners). Both refs live in App so they survive the
904
- // remount: the stamp says whether this preset was already applied, the
905
- // position ref (with look.current) says where the camera last was.
906
- const stamp = `${preset}:${nonce}`;
907
- if (appliedPresetRef && appliedPresetRef.current === stamp && lastPosRef?.current) {
908
- const last = lastPosRef.current;
909
- cam.position.set(last.x, last.y, last.z);
910
- cam.rotation.order = "YXZ";
911
- cam.rotation.set(look.current.pitch, look.current.yaw, 0);
912
- return;
913
- }
914
- if (appliedPresetRef) appliedPresetRef.current = stamp;
915
- const p = PRESETS[preset];
916
- const az = (p.azimuth * Math.PI) / 180;
917
- const el = (p.elevation * Math.PI) / 180;
918
- const aim =
919
- p.aimMid && showB ? { x: (charA.x + charB.x) / 2, z: (charA.z + charB.z) / 2 } : { x: charA.x, z: charA.z };
920
- const horizontal = p.distance * Math.cos(el);
921
- cam.position.set(
922
- aim.x + horizontal * Math.sin(az),
923
- p.height ?? Math.max(p.targetY + p.distance * Math.sin(el), 0.15),
924
- aim.z + horizontal * Math.cos(az),
925
- );
926
- const angles = aimAt(cam.position, { x: aim.x, y: p.targetY, z: aim.z });
927
- look.current.yaw = angles.yaw;
928
- look.current.pitch = angles.pitch;
929
- // eslint-disable-next-line react-hooks/exhaustive-deps
930
- }, [preset, nonce]);
931
-
932
-
933
- useEffect(() => {
934
- const cam = camRef.current;
935
- if (!cam) return;
936
- cam.fov = fovDeg;
937
- cam.updateProjectionMatrix();
938
- }, [fovDeg, camRef]);
939
-
940
- // A blocking tool must not claim a framing it is not holding, so the subject's
941
- // feet and eyes are projected into the frame every tick.
942
- const probe = useRef(new THREE.Vector3());
943
- const lastMetrics = useRef({ x: NaN, y: NaN, z: NaN, visible: null });
944
- useFrame(() => {
945
- const cam = camRef.current;
946
- if (!cam) return;
947
- let visible = false;
948
- for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
949
- // During motion playback the probe follows the played root, so the
950
- // "subject out of frame" caption stays honest.
951
- probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
952
- if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
953
- }
954
- const previous = lastMetrics.current;
955
- if (
956
- Math.abs(previous.x - cam.position.x) +
957
- Math.abs(previous.y - cam.position.y) +
958
- Math.abs(previous.z - cam.position.z) >
959
- 1e-4 ||
960
- previous.visible !== visible
961
- ) {
962
- lastMetrics.current = {
963
- x: cam.position.x,
964
- y: cam.position.y,
965
- z: cam.position.z,
966
- visible,
967
- };
968
- onMetrics(cam.position, visible);
969
- }
970
- });
971
-
972
- return null;
973
- }
974
-
975
- /** Plays an authored A→B camera move by driving the shot camera exactly the
976
- way a fly-drag does — position on the camera, orientation through the look
977
- ref — so ShotRig's metrics, the slate, and the inset all stay honest for
978
- free. A right-drag interrupts the dolly: the user always outranks it.
979
-
980
- Two frame sources can drive the move. Standalone preview advances authored
981
- frames at production cadence; follow mode reads the timeline playhead, so the camera and
982
- the character motion are two views of the same time axis — playing or
983
- scrubbing frame 72 at 24 fps puts the camera exactly 3 s into its move. */
984
- export function MoveRig({ playing, following, followFrame, fps, keys, shots, scene, camRef, look, isInterrupted, onDone }) {
985
- const invalidate = useThree((state) => state.invalidate);
986
- const preview = useRef({ frame: 0, finished: false, notified: false });
987
- const handlers = useRef({ isInterrupted, onDone });
988
- handlers.current = { isInterrupted, onDone };
989
- // The follow branch only re-applies when its time changes; in demand mode
990
- // each apply needs one more frame so ShotRig's metrics see the new pose.
991
- const appliedFrame = useRef(null);
992
- useEffect(() => {
993
- // Arming or reshaping the move must schedule a frame by hand: this
994
- // component owns no three objects, so in demand mode nothing else will.
995
- appliedFrame.current = null;
996
- if (following) invalidate();
997
- }, [keys, shots, scene, fps, following, invalidate]);
998
- useEffect(() => {
999
- // A paused scrub changes the playhead without starting the render loop.
1000
- if (following && !playing) invalidate();
1001
- }, [followFrame, following, playing, invalidate]);
1002
- useEffect(() => {
1003
- if (!playing || keys.length < 1) return undefined;
1004
- const firstFrame = keys[0].frame;
1005
- const lastFrame = keys[keys.length - 1].frame;
1006
- const spanFrames = Math.max(lastFrame - firstFrame, 1);
1007
- const spanSeconds = Math.max(spanFrames / Math.max(fps, 1), 0.1);
1008
- const tickMs = (spanSeconds * 1000) / spanFrames;
1009
- let tick = 0;
1010
- preview.current = { frame: firstFrame, finished: false, notified: false };
1011
- invalidate();
1012
- const timer = window.setInterval(() => {
1013
- if (preview.current.finished) return;
1014
- if (handlers.current.isInterrupted?.()) {
1015
- preview.current.finished = true;
1016
- invalidate();
1017
- return;
1018
- }
1019
- tick += 1;
1020
- preview.current = {
1021
- frame: firstFrame + (lastFrame - firstFrame) * Math.min(tick / spanFrames, 1),
1022
- finished: tick >= spanFrames,
1023
- notified: false,
1024
- };
1025
- invalidate();
1026
- }, tickMs);
1027
- return () => window.clearInterval(timer);
1028
- }, [playing, keys, fps, invalidate]);
1029
- useFrame(() => {
1030
- if ((playing && keys.length < 1) || (!playing && following && !shots.some((shot) => shot.cameraKeys.length))) return;
1031
- const cam = camRef.current;
1032
- if (!cam) return;
1033
- const apply = (frame) => {
1034
- // Timeline/PlayView sampling follows the editorial strips. Standalone
1035
- // preview stays scoped to the selected strip's keys. Entering a Shot
1036
- // applies its authored camera immediately (a hard cut). Leaving into a
1037
- // gap returns null and deliberately applies nothing: the last physical
1038
- // camera pose stays put and FlyControls regain ownership, so we do not
1039
- // invent a second cut to an arbitrary "free camera" preset.
1040
- const timelineShot = following ? shotAtFrame(shots, frame) : null;
1041
- const sampledShot = timelineShot
1042
- ? { ...timelineShot, camera: { mode: "keys" } }
1043
- : following ? null : { camera: { mode: "keys" }, cameraKeys: keys };
1044
- const f = sampleAt(scene, sampledShot, frame).camera;
1045
- if (!f) return null;
1046
- cam.position.set(f.pos.x, f.pos.y, f.pos.z);
1047
- look.current.yaw = f.yaw;
1048
- look.current.pitch = f.pitch;
1049
- if (Math.abs(cam.fov - f.fovDeg) > 1e-3) {
1050
- cam.fov = f.fovDeg;
1051
- cam.updateProjectionMatrix();
1052
- }
1053
- return f;
1054
- };
1055
- if (playing) {
1056
- const f = apply(preview.current.frame);
1057
- if (preview.current.finished && !preview.current.notified) {
1058
- preview.current.notified = true;
1059
- handlers.current.onDone(f ? f.fovDeg : cam.fov);
1060
- }
1061
- return;
1062
- }
1063
- if (!following) return;
1064
- if (isInterrupted?.()) return; // manual viewport framing owns the paused camera
1065
- if (appliedFrame.current === followFrame) return;
1066
- appliedFrame.current = followFrame;
1067
- apply(followFrame);
1068
- invalidate();
1069
- });
1070
- return null;
1071
- }
1072
-
1073
- /**
1074
- * Applies the precomputed follow-camera track to the shot camera. The track
1075
- * is derived offline from the subject trajectory (camera-follow.js), so this
1076
- * rig only samples it at the playhead — scrub, play, PlayView and Record all
1077
- * replay the identical deterministic move. Null-rendering, so every apply
1078
- * must invalidate() by hand in demand mode, like MoveRig.
1079
- */
1080
- export function FollowCamRig({ enabled, frame, scene, shot, camRef, look, isInterrupted }) {
1081
- const invalidate = useThree((state) => state.invalidate);
1082
- const applied = useRef(null);
1083
- useEffect(() => {
1084
- applied.current = null;
1085
- if (enabled) invalidate();
1086
- }, [scene, shot, enabled, invalidate]);
1087
- useEffect(() => {
1088
- if (enabled) invalidate();
1089
- }, [frame, enabled, invalidate]);
1090
- useFrame(() => {
1091
- if (!enabled) return;
1092
- if (isInterrupted?.()) return; // the user is flying; yield until released
1093
- const cam = camRef.current;
1094
- if (!cam) return;
1095
- const sample = sampleAt(scene, shot, frame).camera;
1096
- if (!sample || applied.current === frame) return;
1097
- applied.current = frame;
1098
- cam.position.set(sample.pos.x, sample.pos.y, sample.pos.z);
1099
- look.current.yaw = sample.yaw;
1100
- look.current.pitch = sample.pitch;
1101
- invalidate();
1102
- });
1103
- return null;
1104
- }
1105
-
1106
- /**
1107
- * Applies the shot camera's yaw/pitch from its look ref every frame. Before
1108
- * the camera split, FlyControls owned the shot camera and performed this
1109
- * apply as a side effect of flying; with the fly controls bound to the
1110
- * editor camera, the recording camera still needs its aim (ShotRig, follow,
1111
- * frame-selection) reflected in its actual rotation — the preview and the
1112
- * capture read the camera object, not the ref.
1113
- */
1114
- export function ShotLookApplier({ camRef, look }) {
1115
- useFrame(() => {
1116
- const cam = camRef.current;
1117
- if (!cam) return;
1118
- if (cam.rotation.order !== "YXZ") cam.rotation.order = "YXZ";
1119
- if (cam.rotation.x !== look.current.pitch || cam.rotation.y !== look.current.yaw || cam.rotation.z !== 0) {
1120
- cam.rotation.set(look.current.pitch, look.current.yaw, 0);
1121
- }
1122
- });
1123
- return null;
1124
- }
1125
-
1126
- /**
1127
- * Seeds the editor camera once, inside the Canvas root — App-level effects
1128
- * run before R3F mounts its children, so the ref is still null there. The
1129
- * seed frames both the subject and the shot camera: the first editor frame
1130
- * must read as "the set with the camera in it".
1131
- */
1132
- /**
1133
- * A short editor-camera glide: 260 ms ease toward a target orientation (and
1134
- * optionally a position), instead of the teleport that made "look at the
1135
- * light" and F-framing feel like a cut. Any user press cancels it — the
1136
- * operator always outranks the tour guide.
1137
- */
1138
- export function CameraGlide({ glide, camRef, lookRef, onDone }) {
1139
- const { gl } = useThree();
1140
- const invalidate = useThree((state) => state.invalidate);
1141
- const animRef = useRef(null);
1142
- useEffect(() => {
1143
- if (!glide) {
1144
- animRef.current = null;
1145
- return undefined;
1146
- }
1147
- const cam = camRef.current;
1148
- if (!cam) return undefined;
1149
- const fromPos = cam.position.clone();
1150
- const toPos = glide.position ? new THREE.Vector3(glide.position.x, glide.position.y, glide.position.z) : fromPos.clone();
1151
- const angles = aimAt(toPos, glide.target);
1152
- const from = { yaw: lookRef.current.yaw, pitch: lookRef.current.pitch };
1153
- let dYaw = angles.yaw - from.yaw;
1154
- while (dYaw > Math.PI) dYaw -= 2 * Math.PI;
1155
- while (dYaw < -Math.PI) dYaw += 2 * Math.PI;
1156
- // elapsed accumulates render-loop deltas: the glide needs no wall clock
1157
- animRef.current = { fromPos, toPos, from, dYaw, dPitch: angles.pitch - from.pitch, elapsed: 0 };
1158
- const cancel = () => {
1159
- animRef.current = null;
1160
- onDone?.();
1161
- };
1162
- gl.domElement.addEventListener("pointerdown", cancel, true);
1163
- invalidate();
1164
- return () => gl.domElement.removeEventListener("pointerdown", cancel, true);
1165
- // camRef/lookRef are stable refs; onDone identity is per-glide noise
1166
- // eslint-disable-next-line react-hooks/exhaustive-deps
1167
- }, [glide, gl, invalidate]);
1168
- useFrame((_, delta) => {
1169
- const anim = animRef.current;
1170
- const cam = camRef.current;
1171
- if (!anim || !cam) return;
1172
- anim.elapsed += Math.min(delta, 0.1) * 1000;
1173
- const t = Math.min(1, anim.elapsed / 260);
1174
- const eased = 1 - (1 - t) ** 3;
1175
- cam.position.lerpVectors(anim.fromPos, anim.toPos, eased);
1176
- const yaw = anim.from.yaw + anim.dYaw * eased;
1177
- const pitch = anim.from.pitch + anim.dPitch * eased;
1178
- lookRef.current = { yaw, pitch };
1179
- cam.rotation.order = "YXZ";
1180
- cam.rotation.set(pitch, yaw, 0);
1181
- if (t >= 1) {
1182
- animRef.current = null;
1183
- onDone?.();
1184
- } else {
1185
- invalidate();
1186
- }
1187
- });
1188
- return null;
1189
- }
1190
-
1191
- export function EditorCamSeed({ camRef, lookRef, shotCamRef, subject }) {
1192
- const seeded = useRef(false);
1193
- const invalidate = useThree((state) => state.invalidate);
1194
- useEffect(() => {
1195
- if (seeded.current) return;
1196
- const cam = camRef.current;
1197
- if (!cam) return;
1198
- seeded.current = true;
1199
- // The selected character owns the first read. The shot camera remains a
1200
- // useful reference in the inset; mixing its position into this seed pushed
1201
- // the editor eye several metres back and made the mannequin look tiny.
1202
- const frame = editorFrameForSubject(subject);
1203
- const { target, position } = frame;
1204
- cam.position.set(position.x, position.y, position.z);
1205
- const angles = aimAt(cam.position, target);
1206
- lookRef.current = { yaw: angles.yaw, pitch: angles.pitch };
1207
- cam.rotation.order = "YXZ";
1208
- cam.rotation.set(angles.pitch, angles.yaw, 0);
1209
- invalidate();
1210
- });
1211
- return null;
1212
- }
1213
-
1214
- /**
1215
- * The key light drawn as a grabbable sun in the editor view: a warm core with
1216
- * rays, on GIZMO_LAYER so preview, capture and PlayView never see it. A line
1217
- * drops to the floor so its height reads against the stage, like the crane's.
1218
- * The sun's own body is the drag surface — a grab moves it freely on the
1219
- * camera-facing plane (the cursor and the sun stay glued), and the gizmo's
1220
- * arrows (via ObjectGizmo) stay available for single-axis precision. Its
1221
- * colour follows the dial, and while selected a ray points at the stage so
1222
- * the light's direction reads at a glance.
1223
- */
1224
- export function KeyLightPuck({ keyLight, selected, visible, paneRef, camRef, onSelect, onChange, onDragEnd }) {
1225
- const { gl } = useThree();
1226
- const groupRef = useRef(null);
1227
- const dragRef = useRef(null);
1228
- const stateRef = useRef(null);
1229
- const [dragKind, setDragKind] = useState(null);
1230
- const invalidate = useThree((state) => state.invalidate);
1231
- stateRef.current = { keyLight, visible, onSelect, onChange, onDragEnd };
1232
- useEffect(() => {
1233
- groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
1234
- invalidate();
1235
- }, [visible, selected, keyLight, dragKind, invalidate]);
1236
- useEffect(() => {
1237
- if (!visible) return undefined;
1238
- const raycaster = new THREE.Raycaster();
1239
- raycaster.layers.set(GIZMO_LAYER);
1240
- const rayFrom = (event) => {
1241
- const pane = paneRef.current;
1242
- const camera = camRef.current;
1243
- if (!pane || !camera) return null;
1244
- const bounds = pane.getBoundingClientRect();
1245
- if (bounds.width < 2 || bounds.height < 2) return null;
1246
- const ndc = new THREE.Vector2(
1247
- ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
1248
- -((event.clientY - bounds.top) / bounds.height) * 2 + 1,
1249
- );
1250
- if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
1251
- camera.updateMatrixWorld();
1252
- raycaster.setFromCamera(ndc, camera);
1253
- return raycaster;
1254
- };
1255
- const onDown = (event) => {
1256
- const s = stateRef.current;
1257
- if (event.button !== 0 || event.altKey) return;
1258
- if (!groupRef.current || !rayFrom(event)) return;
1259
- const hit = raycaster.intersectObjects(groupRef.current.children, true)
1260
- .find((entry) => {
1261
- for (let node = entry.object; node; node = node.parent) if (node.userData?.keyLightPick) return true;
1262
- return false;
1263
- });
1264
- if (!hit) return;
1265
- event.stopImmediatePropagation();
1266
- event.preventDefault();
1267
- s.onSelect?.();
1268
- const start = { ...s.keyLight };
1269
- const origin = new THREE.Vector3(start.x, start.y, start.z);
1270
- // free move on the camera-facing plane through the sun: the cursor
1271
- // and the sun stay glued from any viewing angle — no degenerate
1272
- // floor-plane geometry when the sun is viewed from below
1273
- const facing = new THREE.Vector3();
1274
- camRef.current.getWorldDirection(facing);
1275
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, origin);
1276
- const hitStart = new THREE.Vector3();
1277
- if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
1278
- dragRef.current = { start, plane, hitStart, moved: false };
1279
- setDragKind("move");
1280
- invalidate();
1281
- };
1282
- const onMove = (event) => {
1283
- const drag = dragRef.current;
1284
- if (!drag || !rayFrom(event)) return;
1285
- const world = new THREE.Vector3();
1286
- if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
1287
- drag.moved = true;
1288
- stateRef.current.onChange?.({
1289
- x: drag.start.x + (world.x - drag.hitStart.x),
1290
- y: drag.start.y + (world.y - drag.hitStart.y),
1291
- z: drag.start.z + (world.z - drag.hitStart.z),
1292
- }, { dragging: true });
1293
- invalidate();
1294
- };
1295
- const onUp = () => {
1296
- const drag = dragRef.current;
1297
- if (!drag) return;
1298
- dragRef.current = null;
1299
- setDragKind(null);
1300
- if (drag.moved) stateRef.current.onDragEnd?.();
1301
- invalidate();
1302
- };
1303
- const el = gl.domElement;
1304
- el.addEventListener("pointerdown", onDown);
1305
- window.addEventListener("pointermove", onMove, true);
1306
- window.addEventListener("pointerup", onUp, true);
1307
- return () => {
1308
- el.removeEventListener("pointerdown", onDown);
1309
- window.removeEventListener("pointermove", onMove, true);
1310
- window.removeEventListener("pointerup", onUp, true);
1311
- };
1312
- // paneRef/camRef are stable refs; handlers read live state through stateRef
1313
- // eslint-disable-next-line react-hooks/exhaustive-deps
1314
- }, [visible, gl, invalidate]);
1315
- const drop = useMemo(() => new Float32Array([0, 0, 0, 0, -keyLight.y, 0]), [keyLight.y]);
1316
- // the sun dims with its own dial, so the stage's mood reads off the puck
1317
- const glow = Math.max(0.18, Math.min(1, keyLight.intensity / 1.12));
1318
- const sunColor = useMemo(
1319
- () => new THREE.Color("#5c5040").lerp(new THREE.Color(selected ? "#ffcf5e" : "#f2b544"), glow).getStyle(),
1320
- [selected, glow],
1321
- );
1322
- if (!visible) return null;
1323
- return (
1324
- <group ref={groupRef} position={[keyLight.x, keyLight.y, keyLight.z]}>
1325
- <mesh userData={{ keyLightPick: true }}>
1326
- <sphereGeometry args={[0.16, 20, 16]} />
1327
- <meshBasicMaterial color={sunColor} toneMapped={false} />
1328
- </mesh>
1329
- {/* rays: eight short spokes so it reads as a sun, not a ball */}
1330
- {Array.from({ length: 8 }, (_, i) => {
1331
- const angle = (i / 8) * Math.PI * 2;
1332
- return (
1333
- <mesh key={i} userData={{ keyLightPick: true }} position={[Math.cos(angle) * 0.27, Math.sin(angle) * 0.27, 0]} rotation={[0, 0, angle + Math.PI / 2]}>
1334
- <cylinderGeometry args={[0.014, 0.014, 0.1, 6]} />
1335
- <meshBasicMaterial color={sunColor} toneMapped={false} />
1336
- </mesh>
1337
- );
1338
- })}
1339
- {/* the real grab surface: generous, invisible, around the whole sun */}
1340
- <mesh userData={{ keyLightPick: true }}>
1341
- <sphereGeometry args={[0.42, 12, 10]} />
1342
- <meshBasicMaterial visible={false} />
1343
- </mesh>
1344
- <lineSegments>
1345
- <bufferGeometry>
1346
- <bufferAttribute attach="attributes-position" array={drop} count={2} itemSize={3} />
1347
- </bufferGeometry>
1348
- <lineBasicMaterial color="#f2b544" transparent opacity={selected ? 0.6 : 0.25} />
1349
- </lineSegments>
1350
- {/* where the light points: a dashed ray toward the stage centre */}
1351
- {selected && (
1352
- <Line
1353
- points={[[0, 0, 0], [-keyLight.x * 0.86, -keyLight.y * 0.86, -keyLight.z * 0.86]]}
1354
- color="#ffb454"
1355
- lineWidth={1.6}
1356
- dashed
1357
- dashSize={0.32}
1358
- gapSize={0.22}
1359
- transparent
1360
- opacity={0.45}
1361
- />
1362
- )}
1363
- {/* live readout while the sun is being dragged */}
1364
- {dragKind && (
1365
- <GizmoLabel
1366
- position={[0, 0.5, 0]}
1367
- text={`x ${keyLight.x.toFixed(1)} y ${keyLight.y.toFixed(1)} z ${keyLight.z.toFixed(1)}`}
1368
- camRef={camRef}
1369
- />
1370
- )}
1371
- </group>
1372
- );
1373
- }
1374
-
1375
- /**
1376
- * The shot camera drawn as an object in the editor view: a body and a short
1377
- * frustum wireframe on GIZMO_LAYER, synced from the live camera every frame.
1378
- * Preview, capture and PlayView draws all drop GIZMO_LAYER, so the ghost can
1379
- * never reach a recorded frame.
1380
- */
1381
- export function ShotCameraGhost({ camRef, fovDeg, aspect, visible, selected }) {
1382
- const groupRef = useRef(null);
1383
- const invalidate = useThree((state) => state.invalidate);
1384
- const frustum = useMemo(() => {
1385
- const depth = 0.62;
1386
- const h = Math.tan(THREE.MathUtils.degToRad(fovDeg) / 2) * depth;
1387
- const w = h * aspect;
1388
- const corners = [[-w, h], [w, h], [w, -h], [-w, -h]];
1389
- const points = [];
1390
- for (const [x, y] of corners) points.push(0, 0, 0, x, y, -depth);
1391
- for (let i = 0; i < 4; i++) {
1392
- const [ax, ay] = corners[i];
1393
- const [bx, by] = corners[(i + 1) % 4];
1394
- points.push(ax, ay, -depth, bx, by, -depth);
1395
- }
1396
- return new Float32Array(points);
1397
- }, [fovDeg, aspect]);
1398
- useEffect(() => {
1399
- groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
1400
- invalidate();
1401
- }, [visible, frustum, selected, invalidate]);
1402
- useFrame(() => {
1403
- const cam = camRef.current;
1404
- const group = groupRef.current;
1405
- if (!cam || !group) return;
1406
- group.position.copy(cam.position);
1407
- group.quaternion.copy(cam.quaternion);
1408
- });
1409
- if (!visible) return null;
1410
- return (
1411
- <group ref={groupRef}>
1412
- <mesh userData={{ shotCameraPick: true }} position={[0, 0, 0.06]}>
1413
- <boxGeometry args={[0.15, 0.11, 0.2]} />
1414
- <meshBasicMaterial color={selected ? "#ffb454" : "#3b6ea5"} />
1415
- </mesh>
1416
- {/* the lens: a short cone opening toward the view direction */}
1417
- <mesh userData={{ shotCameraPick: true }} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, -0.075]}>
1418
- <coneGeometry args={[0.052, 0.09, 20, 1, true]} />
1419
- <meshBasicMaterial color={selected ? "#e09a3e" : "#2f5a8a"} side={THREE.DoubleSide} />
1420
- </mesh>
1421
- <lineSegments key={`${fovDeg}:${aspect}`}>
1422
- <bufferGeometry>
1423
- <bufferAttribute attach="attributes-position" array={frustum} count={frustum.length / 3} itemSize={3} />
1424
- </bufferGeometry>
1425
- <lineBasicMaterial color={selected ? "#ffb454" : "#6f9cc9"} transparent opacity={0.85} />
1426
- </lineSegments>
1427
- </group>
1428
- );
1429
- }
1430
-
1431
- export function RenderLoopController({ stageRef }) {
1432
- const frameloop = useThree((state) => state.frameloop);
1433
- const setFrameloop = useThree((state) => state.setFrameloop);
1434
- const invalidate = useThree((state) => state.invalidate);
1435
- useEffect(() => {
1436
- const apply = (next) => {
1437
- setFrameloop(next ? "always" : "demand");
1438
- if (next) invalidate();
1439
- };
1440
- const onActivity = (event) => apply(event.detail);
1441
- window.addEventListener(RENDER_ACTIVITY_EVENT, onActivity);
1442
- return () => window.removeEventListener(RENDER_ACTIVITY_EVENT, onActivity);
1443
- }, [invalidate, setFrameloop]);
1444
- useEffect(() => {
1445
- if (stageRef.current) stageRef.current.dataset.actualRenderLoop = frameloop;
1446
- }, [frameloop, stageRef]);
1447
- return null;
1448
- }
1449
-
1450
- /** GPU resets — sleep/wake, a driver restart, VRAM pressure from an export —
1451
- * kill the WebGL context, and without listeners the stage goes black and
1452
- * stays black with no message. preventDefault on webglcontextlost is the
1453
- * documented opt-in for browser-driven restoration; on restore, kick the
1454
- * demand loop so the first frame actually paints. */
1455
- export function ContextLossGuard({ onLostChange }) {
1456
- const gl = useThree((state) => state.gl);
1457
- const invalidate = useThree((state) => state.invalidate);
1458
- useEffect(() => {
1459
- const canvas = gl.domElement;
1460
- const onLost = (event) => {
1461
- event.preventDefault();
1462
- onLostChange(true);
1463
- };
1464
- const onRestored = () => {
1465
- onLostChange(false);
1466
- invalidate();
1467
- };
1468
- canvas.addEventListener("webglcontextlost", onLost);
1469
- canvas.addEventListener("webglcontextrestored", onRestored);
1470
- return () => {
1471
- canvas.removeEventListener("webglcontextlost", onLost);
1472
- canvas.removeEventListener("webglcontextrestored", onRestored);
1473
- };
1474
- }, [gl, invalidate, onLostChange]);
1475
- return null;
1476
- }
1477
-
1478
- export function ViewportLayoutInvalidator({ insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom }) {
1479
- const invalidate = useThree((state) => state.invalidate);
1480
- useEffect(() => {
1481
- // The pane frame is regular DOM while its picture is a scissored WebGL
1482
- // viewport. In demand mode a drag can commit its final DOM position just
1483
- // after the continuous loop stops, leaving the picture at the previous
1484
- // coordinates. Redraw now and once more after layout has settled.
1485
- invalidate();
1486
- const frame = requestAnimationFrame(invalidate);
1487
- return () => cancelAnimationFrame(frame);
1488
- }, [invalidate, insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom]);
1489
- return null;
1490
- }
1491
-
1492
- /** The authored root path on the set floor while path editing is on: numbered
1493
- pins connected in walk order. Lives on the gizmo layer, so the shot camera
1494
- shows it but exports never do (the bird's-eye board draws its own copy). */
1495
- /**
1496
- * The drawn camera rail in the 3D Scene view: editor furniture on the gizmo
1497
- * layer, so PlayView, the ink prepass and exports never see it.
1498
- */
1499
- export function CameraRailScenePreview({ points, cumLen, length, crane }) {
1500
- const rootRef = useRef(null);
1501
- const lines = useMemo(() => {
1502
- if (!points || points.length < 2) return null;
1503
- const floor = points.map((point) => [point.x, 0.03, point.z]);
1504
- // A craned rail shows WHERE THE LENS RIDES: the same path lifted along
1505
- // the crane's start→end heights by arc progress, with the floor line
1506
- // kept as the track's ground projection.
1507
- const lifted = crane && cumLen && length > 1e-9
1508
- ? points.map((point, i) => [point.x, craneHeightAt(crane, cumLen[i] / length), point.z])
1509
- : null;
1510
- return { floor, lifted };
1511
- }, [points, cumLen, length, crane]);
1512
- useEffect(() => {
1513
- rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
1514
- });
1515
- if (!lines) return null;
1516
- // drei's Line renders screen-space fat lines: a WebGL <line> is always
1517
- // 1px, which reads as a hairline on dense displays.
1518
- return (
1519
- <group ref={rootRef}>
1520
- <Line points={lines.floor} color="#a78bfa" lineWidth={2} transparent opacity={lines.lifted ? 0.35 : 0.8} depthWrite={false} />
1521
- {lines.lifted && (
1522
- <Line points={lines.lifted} color="#a78bfa" lineWidth={2.5} transparent opacity={0.9} depthWrite={false} />
1523
- )}
1524
- </group>
1525
- );
1526
- }
1527
-
1528
-
1529
- /**
1530
- * The crane's height marks as grabbable dots on the lifted curve. Click a
1531
- * dot to select it; the selected dot grows a 3-axis gizmo — Y sets the
1532
- * mark's height, X/Z bend the camera path itself by moving the drawn rail
1533
- * control point nearest the mark. A free drag on the dot rides a
1534
- * camera-facing vertical plane (height, plus arc slide for interior marks),
1535
- * Shift-drag slides along the arc at a fixed height, double-click the curve
1536
- * adds a mark and Delete removes a non-endpoint. Dots live on GIZMO_LAYER, so the
1537
- * recording never sees them.
1538
- */
1539
- export const CRANE_AXES = [
1540
- { axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
1541
- { axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
1542
- { axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
1543
- ];
1544
- export const CRANE_ARROW_LEN = 0.5;
1545
- export const CRANE_GIZMO_SCALE = 0.16; // metres of gizmo per metre of camera distance
1546
-
1547
- /**
1548
- * Prepare an x/z rail bend for one crane mark: a copy of the drawn control
1549
- * points (with one inserted at the mark's base when none is close enough to
1550
- * anchor the bend) plus a per-point weight — a cosine bump centred on the
1551
- * anchor that fades to zero by the neighbouring crane marks, so dragging one
1552
- * mark can never tow the marks beside it. For an interior mark both rail
1553
- * ends are pinned outright.
1554
- */
1555
- export function prepareRailBend(controlPoints, base, marks, markIndex) {
1556
- const controls = (controlPoints ?? []).map((entry) => ({ ...entry }));
1557
- if (controls.length < 2) return null;
1558
- // nearest control point / segment to the mark's base
1559
- let nearestIdx = 0;
1560
- let nearestD = Infinity;
1561
- for (let i = 0; i < controls.length; i += 1) {
1562
- const d = Math.hypot(controls[i].x - base.x, controls[i].z - base.z);
1563
- if (d < nearestD) {
1564
- nearestD = d;
1565
- nearestIdx = i;
1566
- }
1567
- }
1568
- const interior = markIndex > 0 && markIndex < marks.length - 1;
1569
- let anchorIdx = nearestIdx;
1570
- if (interior && nearestD > 0.25) {
1571
- // no control point near the mark: bend needs a vertex to pull, so one
1572
- // is seeded at the base, on the segment the base projects onto
1573
- let segmentIdx = 0;
1574
- let segmentD = Infinity;
1575
- for (let i = 0; i < controls.length - 1; i += 1) {
1576
- const ax = controls[i].x;
1577
- const az = controls[i].z;
1578
- const bx = controls[i + 1].x;
1579
- const bz = controls[i + 1].z;
1580
- const lenSq = Math.max((bx - ax) ** 2 + (bz - az) ** 2, 1e-9);
1581
- const u = Math.max(0, Math.min(1, ((base.x - ax) * (bx - ax) + (base.z - az) * (bz - az)) / lenSq));
1582
- const d = Math.hypot(ax + (bx - ax) * u - base.x, az + (bz - az) * u - base.z);
1583
- if (d < segmentD) {
1584
- segmentD = d;
1585
- segmentIdx = i;
1586
- }
1587
- }
1588
- controls.splice(segmentIdx + 1, 0, { x: base.x, z: base.z });
1589
- anchorIdx = segmentIdx + 1;
1590
- }
1591
- // normalized arc parameter of each control point along the control polygon
1592
- const ts = [0];
1593
- for (let i = 1; i < controls.length; i += 1) {
1594
- ts.push(ts[i - 1] + Math.hypot(controls[i].x - controls[i - 1].x, controls[i].z - controls[i - 1].z));
1595
- }
1596
- const total = Math.max(ts[ts.length - 1], 1e-9);
1597
- for (let i = 0; i < ts.length; i += 1) ts[i] /= total;
1598
- // the bump reaches exactly to the neighbouring crane marks
1599
- const t0 = ts[anchorIdx];
1600
- const previous = markIndex > 0 ? marks[markIndex - 1].t : null;
1601
- const next = markIndex < marks.length - 1 ? marks[markIndex + 1].t : null;
1602
- const radius = Math.max(0.15, Math.min(previous != null ? t0 - previous : 1, next != null ? next - t0 : 1));
1603
- const weights = ts.map((t, i) => {
1604
- if (interior && (i === 0 || i === ts.length - 1)) return 0;
1605
- const d = Math.abs(t - t0);
1606
- return d >= radius ? 0 : 0.5 * (1 + Math.cos((Math.PI * d) / radius));
1607
- });
1608
- return { startControls: controls, weights, t0, radius };
1609
- }
1610
-
1611
- /**
1612
- * A floating readout beside a drag — billboarded, sized to the screen, on
1613
- * GIZMO_LAYER so it is editor furniture the recording never sees.
1614
- */
1615
- export function GizmoLabel({ position, text, camRef }) {
1616
- const ref = useRef(null);
1617
- useFrame(() => {
1618
- const cam = camRef?.current;
1619
- const node = ref.current;
1620
- if (!cam || !node) return;
1621
- node.quaternion.copy(cam.getWorldQuaternion(new THREE.Quaternion()));
1622
- const world = node.getWorldPosition(new THREE.Vector3());
1623
- node.scale.setScalar(Math.max(0.4, cam.getWorldPosition(new THREE.Vector3()).distanceTo(world) * 0.11));
1624
- node.traverse((child) => child.layers?.set(GIZMO_LAYER));
1625
- });
1626
- return (
1627
- <Text
1628
- ref={ref}
1629
- position={position}
1630
- fontSize={0.19}
1631
- color="#ffd27a"
1632
- outlineWidth={0.014}
1633
- outlineColor="#241a05"
1634
- anchorX="center"
1635
- anchorY="bottom"
1636
- renderOrder={1000}
1637
- material-depthTest={false}
1638
- material-transparent
1639
- >
1640
- {text}
1641
- </Text>
1642
- );
1643
- }
1644
-
1645
- /**
1646
- * A scene object's travel path in the 3D view: the route as a line, its points
1647
- * as grabbable dots, and a 3-axis gizmo on the selected dot. The grammar is
1648
- * the crane's on purpose — Top-View draws the floor route, the scene lifts and
1649
- * nudges the individual points, so a plane can climb along its own path.
1650
- */
1651
- export function ObjectPathHandles({ path, selectedIndex, enabled, paneRef, camRef, onSelect, onChangePoints, onDragStart, onDragEnd }) {
1652
- const { gl } = useThree();
1653
- const invalidate = useThree((state) => state.invalidate);
1654
- const groupRef = useRef(null);
1655
- const gizmoRef = useRef(null);
1656
- const dragRef = useRef(null);
1657
- const stateRef = useRef(null);
1658
- const [dragging, setDragging] = useState(false);
1659
- stateRef.current = { path, selectedIndex, enabled, onSelect, onChangePoints, onDragStart, onDragEnd };
1660
- useFrame(() => {
1661
- const gizmo = gizmoRef.current;
1662
- const camera = camRef.current;
1663
- if (!gizmo || !camera) return;
1664
- const world = gizmo.getWorldPosition(new THREE.Vector3());
1665
- gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
1666
- });
1667
- useEffect(() => {
1668
- groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
1669
- invalidate();
1670
- }, [path, selectedIndex, enabled, dragging, invalidate]);
1671
- useEffect(() => {
1672
- if (!enabled) return undefined;
1673
- const raycaster = new THREE.Raycaster();
1674
- raycaster.layers.set(GIZMO_LAYER);
1675
- const rayFrom = (event) => {
1676
- const pane = paneRef.current;
1677
- const camera = camRef.current;
1678
- if (!pane || !camera) return null;
1679
- const bounds = pane.getBoundingClientRect();
1680
- if (bounds.width < 2 || bounds.height < 2) return null;
1681
- const ndc = new THREE.Vector2(
1682
- ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
1683
- -((event.clientY - bounds.top) / bounds.height) * 2 + 1,
1684
- );
1685
- if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
1686
- camera.updateMatrixWorld();
1687
- raycaster.setFromCamera(ndc, camera);
1688
- return raycaster;
1689
- };
1690
- const onDown = (event) => {
1691
- const s = stateRef.current;
1692
- if (!s.enabled || !s.path || event.button !== 0) return;
1693
- if (!groupRef.current || !rayFrom(event)) return;
1694
- const hits = raycaster.intersectObjects(groupRef.current.children, true);
1695
- // The axis gizmo outranks the dots, exactly like the crane's.
1696
- const axisHit = hits.find((entry) => entry.object.userData?.pathAxis);
1697
- if (axisHit && s.selectedIndex != null && s.path.points[s.selectedIndex]) {
1698
- const axis = axisHit.object.userData.pathAxis;
1699
- const point = s.path.points[s.selectedIndex];
1700
- const origin = new THREE.Vector3(point.x, point.y, point.z);
1701
- const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
1702
- const eye = new THREE.Vector3();
1703
- camRef.current.getWorldDirection(eye);
1704
- const normal = dir.clone().cross(eye).cross(dir);
1705
- if (normal.lengthSq() < 1e-6) return;
1706
- normal.normalize();
1707
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
1708
- const hitStart = new THREE.Vector3();
1709
- if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
1710
- event.stopImmediatePropagation();
1711
- event.preventDefault();
1712
- dragRef.current = { axis, index: s.selectedIndex, dir: dir.clone(), plane, hitStart, start: { ...point }, recorded: false };
1713
- setDragging(true);
1714
- invalidate();
1715
- return;
1716
- }
1717
- const hit = hits.find((entry) => entry.object.userData?.pathIndex !== undefined);
1718
- if (!hit) return;
1719
- event.stopImmediatePropagation();
1720
- event.preventDefault();
1721
- const index = hit.object.userData.pathIndex;
1722
- s.onSelect(index);
1723
- // A press on the dot itself moves it freely on the camera plane —
1724
- // the sun puck's grammar, so a point can be nudged without aiming.
1725
- const point = s.path.points[index];
1726
- const origin = new THREE.Vector3(point.x, point.y, point.z);
1727
- const facing = new THREE.Vector3();
1728
- camRef.current.getWorldDirection(facing);
1729
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, origin);
1730
- const hitStart = new THREE.Vector3();
1731
- if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
1732
- dragRef.current = { axis: null, index, plane, hitStart, start: { ...point }, recorded: false };
1733
- setDragging(true);
1734
- invalidate();
1735
- };
1736
- const onMove = (event) => {
1737
- const s = stateRef.current;
1738
- const drag = dragRef.current;
1739
- if (!drag || !s.enabled || !s.path) return;
1740
- if (!rayFrom(event)) return;
1741
- const world = new THREE.Vector3();
1742
- if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
1743
- if (!drag.recorded) {
1744
- s.onDragStart?.();
1745
- drag.recorded = true;
1746
- }
1747
- const points = s.path.points.map((entry) => ({ ...entry }));
1748
- if (drag.axis) {
1749
- const travel = world.clone().sub(drag.hitStart).dot(drag.dir);
1750
- const axis = drag.axis;
1751
- points[drag.index][axis] = drag.start[axis] + travel;
1752
- } else {
1753
- points[drag.index] = {
1754
- x: drag.start.x + (world.x - drag.hitStart.x),
1755
- y: drag.start.y + (world.y - drag.hitStart.y),
1756
- z: drag.start.z + (world.z - drag.hitStart.z),
1757
- };
1758
- }
1759
- if (points[drag.index].y < 0) points[drag.index].y = 0;
1760
- s.onChangePoints(points, { dragging: true });
1761
- invalidate();
1762
- };
1763
- const onUp = () => {
1764
- const drag = dragRef.current;
1765
- dragRef.current = null;
1766
- setDragging(false);
1767
- if (drag?.recorded) stateRef.current.onDragEnd?.();
1768
- invalidate();
1769
- };
1770
- const paneScreen = (world) => {
1771
- const pane = paneRef.current?.getBoundingClientRect();
1772
- if (!pane) return null;
1773
- const projected = world.project(camRef.current);
1774
- return projected.z > 1 ? null : {
1775
- x: pane.left + ((projected.x + 1) / 2) * pane.width,
1776
- y: pane.top + ((1 - projected.y) / 2) * pane.height,
1777
- };
1778
- };
1779
- // Double-click the route to drop a point mid-path — the crane curve's
1780
- // gesture, so one habit covers both. The new point lands ON the line,
1781
- // so adding it never changes where the object goes; it only gives the
1782
- // next drag something to grab in the middle.
1783
- const onDouble = (event) => {
1784
- const s = stateRef.current;
1785
- if (!s.enabled || !s.path || !camRef.current) return;
1786
- const points = s.path.points;
1787
- if (points.length >= MAX_PATH_POINTS) return;
1788
- camRef.current.updateMatrixWorld();
1789
- let best = null;
1790
- for (let i = 0; i < points.length - 1; i += 1) {
1791
- const a = paneScreen(new THREE.Vector3(points[i].x, points[i].y ?? 0, points[i].z));
1792
- const b = paneScreen(new THREE.Vector3(points[i + 1].x, points[i + 1].y ?? 0, points[i + 1].z));
1793
- if (!a || !b) continue;
1794
- const dx = b.x - a.x;
1795
- const dy = b.y - a.y;
1796
- const lenSq = dx * dx + dy * dy;
1797
- const t = lenSq < 1e-6 ? 0 : Math.min(1, Math.max(0, ((event.clientX - a.x) * dx + (event.clientY - a.y) * dy) / lenSq));
1798
- const d = Math.hypot(a.x + dx * t - event.clientX, a.y + dy * t - event.clientY);
1799
- if (!best || d < best.d) best = { d, index: i, t };
1800
- }
1801
- if (!best || best.d > 14) return;
1802
- // Refuse a point that would sit on top of a neighbour: a zero-length
1803
- // segment is not a handle, it is a duplicate the schema would drop.
1804
- if (best.t < 0.02 || best.t > 0.98) return;
1805
- event.stopImmediatePropagation();
1806
- event.preventDefault();
1807
- const a = points[best.index];
1808
- const b = points[best.index + 1];
1809
- const inserted = {
1810
- x: a.x + (b.x - a.x) * best.t,
1811
- y: (a.y ?? 0) + ((b.y ?? 0) - (a.y ?? 0)) * best.t,
1812
- z: a.z + (b.z - a.z) * best.t,
1813
- };
1814
- const next = [...points.slice(0, best.index + 1), inserted, ...points.slice(best.index + 1)];
1815
- s.onDragStart?.();
1816
- s.onChangePoints(next);
1817
- s.onDragEnd?.();
1818
- s.onSelect(best.index + 1);
1819
- invalidate();
1820
- };
1821
- // Delete removes the selected point, never the object: while a point is
1822
- // selected this handler owns the key, and the scene-object delete
1823
- // handler stands down (it checks the same selection).
1824
- const onKey = (event) => {
1825
- const s = stateRef.current;
1826
- if (!s.enabled || !s.path || s.selectedIndex == null) return;
1827
- if (event.key !== "Delete" && event.key !== "Backspace") return;
1828
- if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return;
1829
- event.preventDefault();
1830
- event.stopImmediatePropagation();
1831
- const remaining = s.path.points.filter((_, index) => index !== s.selectedIndex);
1832
- // Two points are the least a route can be; the last removal clears
1833
- // the whole path instead of leaving a stub that cannot be walked.
1834
- s.onDragStart?.();
1835
- s.onChangePoints(remaining.length >= 2 ? remaining : null);
1836
- s.onDragEnd?.();
1837
- s.onSelect(null);
1838
- invalidate();
1839
- };
1840
- const el = gl.domElement;
1841
- el.addEventListener("pointerdown", onDown);
1842
- window.addEventListener("pointermove", onMove, true);
1843
- window.addEventListener("pointerup", onUp, true);
1844
- el.addEventListener("dblclick", onDouble, true);
1845
- window.addEventListener("keydown", onKey, true);
1846
- return () => {
1847
- el.removeEventListener("pointerdown", onDown);
1848
- window.removeEventListener("pointermove", onMove, true);
1849
- window.removeEventListener("pointerup", onUp, true);
1850
- el.removeEventListener("dblclick", onDouble, true);
1851
- window.removeEventListener("keydown", onKey, true);
1852
- };
1853
- // paneRef/camRef are stable; handlers read live state through stateRef
1854
- // eslint-disable-next-line react-hooks/exhaustive-deps
1855
- }, [enabled, gl, invalidate]);
1856
- const linePoints = useMemo(
1857
- () => (path?.points ?? []).map((point) => [point.x, point.y + 0.02, point.z]),
1858
- [path],
1859
- );
1860
- if (!enabled || !path || linePoints.length < 2) return null;
1861
- const selected = selectedIndex != null ? path.points[selectedIndex] : null;
1862
- return (
1863
- <group ref={groupRef}>
1864
- <Line points={linePoints} color="#6fcf97" lineWidth={2.5} transparent opacity={0.9} />
1865
- {selected && (
1866
- <group ref={gizmoRef} position={[selected.x, selected.y, selected.z]} renderOrder={999}>
1867
- {CRANE_AXES.map(({ axis, dir, color }) => {
1868
- const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
1869
- return (
1870
- <group key={axis} quaternion={quat}>
1871
- <mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
1872
- <cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
1873
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
1874
- </mesh>
1875
- <mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
1876
- <coneGeometry args={[0.05, 0.14, 14]} />
1877
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
1878
- </mesh>
1879
- <mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ pathAxis: axis }}>
1880
- <cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
1881
- <meshBasicMaterial visible={false} />
1882
- </mesh>
1883
- </group>
1884
- );
1885
- })}
1886
- </group>
1887
- )}
1888
- {path.points.map((point, index) => (
1889
- <group key={`${index}:${path.points.length}`} position={[point.x, point.y, point.z]}>
1890
- <mesh userData={{ pathIndex: index }} renderOrder={998}>
1891
- <sphereGeometry args={[0.075, 14, 10]} />
1892
- <meshBasicMaterial color={index === selectedIndex ? "#ffb454" : "#6fcf97"} depthTest={false} transparent opacity={0.95} />
1893
- </mesh>
1894
- <mesh userData={{ pathIndex: index }}>
1895
- <sphereGeometry args={[0.22, 10, 8]} />
1896
- <meshBasicMaterial visible={false} />
1897
- </mesh>
1898
- </group>
1899
- ))}
1900
- {dragging && selected && (
1901
- <GizmoLabel
1902
- position={[selected.x, selected.y + 0.42, selected.z]}
1903
- text={`x ${selected.x.toFixed(1)} y ${selected.y.toFixed(1)} z ${selected.z.toFixed(1)}`}
1904
- camRef={camRef}
1905
- />
1906
- )}
1907
- </group>
1908
- );
1909
- }
1910
-
1911
- export function CraneHandles({ rail, crane, controlPoints, selectedIndex, enabled, paneRef, camRef, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd }) {
1912
- const { gl } = useThree();
1913
- const invalidate = useThree((state) => state.invalidate);
1914
- const groupRef = useRef(null);
1915
- const gizmoRef = useRef(null);
1916
- const dragRef = useRef(null);
1917
- const stateRef = useRef(null);
1918
- // What the pointer is doing right now, for the in-scene feedback layer:
1919
- // height-ish drags float a metre readout, a bend drag lights up the arc
1920
- // span the bump will actually deform.
1921
- const [dragInfo, setDragInfo] = useState(null);
1922
- stateRef.current = { rail, crane, controlPoints, selectedIndex, enabled, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd };
1923
- // Constant on-screen size for the axis gizmo: it is UI, not set dressing.
1924
- useFrame(() => {
1925
- const gizmo = gizmoRef.current;
1926
- const camera = camRef.current;
1927
- if (!gizmo || !camera) return;
1928
- const world = gizmo.getWorldPosition(new THREE.Vector3());
1929
- gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
1930
- });
1931
- useEffect(() => {
1932
- groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
1933
- });
1934
- useEffect(() => {
1935
- if (!enabled) return undefined;
1936
- const raycaster = new THREE.Raycaster();
1937
- raycaster.layers.set(GIZMO_LAYER);
1938
- const rayFrom = (event) => {
1939
- const pane = paneRef.current;
1940
- const camera = camRef.current;
1941
- if (!pane || !camera) return null;
1942
- const bounds = pane.getBoundingClientRect();
1943
- if (bounds.width < 2 || bounds.height < 2) return null;
1944
- const ndc = new THREE.Vector2(
1945
- ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
1946
- -((event.clientY - bounds.top) / bounds.height) * 2 + 1,
1947
- );
1948
- if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
1949
- camera.updateMatrixWorld();
1950
- raycaster.setFromCamera(ndc, camera);
1951
- return raycaster;
1952
- };
1953
- const paneScreen = (world) => {
1954
- const pane = paneRef.current.getBoundingClientRect();
1955
- const projected = world.project(camRef.current);
1956
- return projected.z > 1 ? null : {
1957
- x: pane.left + ((projected.x + 1) / 2) * pane.width,
1958
- y: pane.top + ((1 - projected.y) / 2) * pane.height,
1959
- };
1960
- };
1961
- const onDown = (event) => {
1962
- const s = stateRef.current;
1963
- if (!s.enabled || !s.crane || event.button !== 0) return;
1964
- if (!groupRef.current || !rayFrom(event)) return;
1965
- const hits = raycaster.intersectObjects(groupRef.current.children, true);
1966
- // The axis gizmo outranks the dots: its arrows sit on the selected
1967
- // dot, and a press on an arrow is a directed move, not a reselect.
1968
- const axisHit = hits.find((entry) => entry.object.userData?.craneAxis);
1969
- if (axisHit && s.selectedIndex != null && s.crane.points[s.selectedIndex]) {
1970
- const axis = axisHit.object.userData.craneAxis;
1971
- const point = s.crane.points[s.selectedIndex];
1972
- const base = railPoint(s.rail, point.t * s.rail.length);
1973
- const origin = new THREE.Vector3(base.x, point.height, base.z);
1974
- const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
1975
- const eye = new THREE.Vector3();
1976
- camRef.current.getWorldDirection(eye);
1977
- // slide plane: contains the axis and faces the camera as squarely
1978
- // as it can — the same construction the object gizmo uses
1979
- const normal = dir.clone().cross(eye).cross(dir);
1980
- if (normal.lengthSq() < 1e-6) return;
1981
- normal.normalize();
1982
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
1983
- const hitStart = new THREE.Vector3();
1984
- if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
1985
- // x/z bend the rail: a weighted bump around the mark's base, so
1986
- // the path deforms locally and the marks beside it stay put.
1987
- const bend = axis === "y" ? null : prepareRailBend(s.controlPoints, base, s.crane.points, s.selectedIndex);
1988
- if (axis !== "y" && !bend) return;
1989
- event.stopImmediatePropagation();
1990
- event.preventDefault();
1991
- dragRef.current = {
1992
- axis,
1993
- index: s.selectedIndex,
1994
- dir: dir.clone(),
1995
- plane,
1996
- hitStart,
1997
- startHeight: point.height,
1998
- startControls: bend?.startControls ?? null,
1999
- bendWeights: bend?.weights ?? null,
2000
- recorded: false,
2001
- };
2002
- setDragInfo(axis === "y" ? { kind: "height" } : { kind: "bend", t0: bend.t0, radius: bend.radius });
2003
- invalidate();
2004
- return;
2005
- }
2006
- const hit = hits.find((entry) => entry.object.userData?.craneIndex !== undefined);
2007
- if (!hit) return;
2008
- event.stopImmediatePropagation();
2009
- event.preventDefault();
2010
- const index = hit.object.userData.craneIndex;
2011
- s.onSelect(index);
2012
- dragRef.current = { index, slide: event.shiftKey, recorded: false };
2013
- setDragInfo({ kind: event.shiftKey ? "slide" : "free" });
2014
- invalidate();
2015
- };
2016
- const onMove = (event) => {
2017
- const s = stateRef.current;
2018
- const drag = dragRef.current;
2019
- if (!drag || !s.enabled || !s.crane || !s.rail) return;
2020
- if (!rayFrom(event)) return;
2021
- const points = s.crane.points;
2022
- const point = points[drag.index];
2023
- if (!point) return;
2024
- if (!drag.recorded) {
2025
- s.onDragStart?.();
2026
- drag.recorded = true;
2027
- }
2028
- if (drag.axis) {
2029
- const world = new THREE.Vector3();
2030
- if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
2031
- const travel = world.sub(drag.hitStart).dot(drag.dir);
2032
- if (drag.axis === "y") {
2033
- const height = Math.max(0.1, Math.min(12, drag.startHeight + travel));
2034
- s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, height } : entry)), { dragging: true });
2035
- } else if (drag.startControls && drag.bendWeights && s.onChangeRail) {
2036
- const dx = drag.axis === "x" ? travel : 0;
2037
- const dz = drag.axis === "z" ? travel : 0;
2038
- const moved = drag.startControls.map((entry, i) => (drag.bendWeights[i] > 0
2039
- ? { ...entry, x: entry.x + dx * drag.bendWeights[i], z: entry.z + dz * drag.bendWeights[i] }
2040
- : entry));
2041
- s.onChangeRail(moved, { dragging: true });
2042
- }
2043
- invalidate();
2044
- return;
2045
- }
2046
- if (drag.slide && drag.index > 0 && drag.index < points.length - 1) {
2047
- // slide along the arc: ray onto the horizontal plane at the mark
2048
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -point.height);
2049
- const world = new THREE.Vector3();
2050
- if (!raycaster.ray.intersectPlane(plane, world)) return;
2051
- let bestI = 0;
2052
- let bestD = Infinity;
2053
- for (let i = 0; i < s.rail.points.length; i += 1) {
2054
- const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
2055
- if (d < bestD) {
2056
- bestD = d;
2057
- bestI = i;
2058
- }
2059
- }
2060
- const t = Math.max(
2061
- points[drag.index - 1].t + 0.02,
2062
- Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
2063
- );
2064
- s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t } : entry)), { dragging: true });
2065
- } else {
2066
- // free move: ray onto the camera-facing vertical plane at the base.
2067
- // Its y sets the height; its x/z re-projects an interior mark onto
2068
- // the nearest rail arc position, so one drag steers both axes — the
2069
- // rail is the only x/z a crane mark can occupy.
2070
- const base = railPoint(s.rail, point.t * s.rail.length);
2071
- const camera = camRef.current;
2072
- const facing = new THREE.Vector3();
2073
- camera.getWorldDirection(facing);
2074
- facing.y = 0;
2075
- if (facing.lengthSq() < 1e-6) return; // looking straight down: height is unreadable
2076
- facing.normalize();
2077
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, new THREE.Vector3(base.x, 0, base.z));
2078
- const world = new THREE.Vector3();
2079
- if (!raycaster.ray.intersectPlane(plane, world)) return;
2080
- const height = Math.max(0.1, Math.min(12, world.y));
2081
- let t = point.t;
2082
- if (drag.index > 0 && drag.index < points.length - 1) {
2083
- let bestI = 0;
2084
- let bestD = Infinity;
2085
- for (let i = 0; i < s.rail.points.length; i += 1) {
2086
- const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
2087
- if (d < bestD) {
2088
- bestD = d;
2089
- bestI = i;
2090
- }
2091
- }
2092
- t = Math.max(
2093
- points[drag.index - 1].t + 0.02,
2094
- Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
2095
- );
2096
- }
2097
- s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t, height } : entry)), { dragging: true });
2098
- }
2099
- invalidate();
2100
- };
2101
- const onUp = () => {
2102
- if (dragRef.current?.recorded) stateRef.current.onDragEnd?.();
2103
- dragRef.current = null;
2104
- setDragInfo(null);
2105
- };
2106
- const onDouble = (event) => {
2107
- const s = stateRef.current;
2108
- if (!s.enabled || !s.crane || !s.rail || !camRef.current) return;
2109
- camRef.current.updateMatrixWorld();
2110
- let best = null;
2111
- for (let i = 0; i < s.rail.points.length; i += 1) {
2112
- const t = s.rail.cumLen[i] / s.rail.length;
2113
- const screen = paneScreen(new THREE.Vector3(s.rail.points[i].x, craneHeightAt(s.crane, t), s.rail.points[i].z));
2114
- if (!screen) continue;
2115
- const d = Math.hypot(screen.x - event.clientX, screen.y - event.clientY);
2116
- if (!best || d < best.d) best = { d, t };
2117
- }
2118
- if (!best || best.d > 14) return;
2119
- if (s.crane.points.length >= 8) return;
2120
- if (s.crane.points.some((entry) => Math.abs(entry.t - best.t) < 0.03)) return;
2121
- event.stopImmediatePropagation();
2122
- event.preventDefault();
2123
- const added = [...s.crane.points, { t: best.t, height: craneHeightAt(s.crane, best.t) }].sort((a, b) => a.t - b.t);
2124
- s.onChangePoints(added);
2125
- s.onSelect(added.findIndex((entry) => entry.t === best.t));
2126
- invalidate();
2127
- };
2128
- const onKey = (event) => {
2129
- const s = stateRef.current;
2130
- if (!s.enabled || !s.crane) return;
2131
- if (event.key !== "Delete" && event.key !== "Backspace") return;
2132
- if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return;
2133
- const index = s.selectedIndex;
2134
- if (index == null || index <= 0 || index >= s.crane.points.length - 1) return;
2135
- event.preventDefault();
2136
- s.onChangePoints(s.crane.points.filter((_, i) => i !== index));
2137
- s.onSelect(null);
2138
- invalidate();
2139
- };
2140
- const el = gl.domElement;
2141
- el.addEventListener("pointerdown", onDown, true);
2142
- window.addEventListener("pointermove", onMove, true);
2143
- window.addEventListener("pointerup", onUp, true);
2144
- el.addEventListener("dblclick", onDouble, true);
2145
- window.addEventListener("keydown", onKey);
2146
- return () => {
2147
- el.removeEventListener("pointerdown", onDown, true);
2148
- window.removeEventListener("pointermove", onMove, true);
2149
- window.removeEventListener("pointerup", onUp, true);
2150
- el.removeEventListener("dblclick", onDouble, true);
2151
- window.removeEventListener("keydown", onKey);
2152
- };
2153
- // paneRef/camRef are stable refs; handlers read live state through stateRef
2154
- // eslint-disable-next-line react-hooks/exhaustive-deps
2155
- }, [enabled, gl, invalidate]);
2156
- if (!enabled || !crane || !rail) return null;
2157
- const selectedPoint = selectedIndex != null ? crane.points[selectedIndex] : null;
2158
- const selectedBase = selectedPoint ? railPoint(rail, selectedPoint.t * rail.length) : null;
2159
- return (
2160
- <group ref={groupRef}>
2161
- {selectedPoint && (
2162
- <group ref={gizmoRef} position={[selectedBase.x, selectedPoint.height, selectedBase.z]} renderOrder={999}>
2163
- {CRANE_AXES.map(({ axis, dir, color }) => {
2164
- const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
2165
- return (
2166
- <group key={axis} quaternion={quat}>
2167
- <mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
2168
- <cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
2169
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
2170
- </mesh>
2171
- <mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
2172
- <coneGeometry args={[0.05, 0.14, 14]} />
2173
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
2174
- </mesh>
2175
- <mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ craneAxis: axis }}>
2176
- <cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
2177
- <meshBasicMaterial visible={false} />
2178
- </mesh>
2179
- </group>
2180
- );
2181
- })}
2182
- </group>
2183
- )}
2184
- {crane.points.map((point, index) => {
2185
- const base = railPoint(rail, point.t * rail.length);
2186
- return (
2187
- <group key={`${index}:${crane.points.length}`} position={[base.x, point.height, base.z]}>
2188
- <mesh userData={{ craneIndex: index }} renderOrder={998}>
2189
- <sphereGeometry args={[0.085, 16, 12]} />
2190
- <meshBasicMaterial color={index === selectedIndex ? "#ffb454" : "#a78bfa"} depthTest={false} transparent opacity={0.95} />
2191
- </mesh>
2192
- {/* the real click target: a dot is a dozen pixels from a few
2193
- metres back, so an invisible halo carries the press */}
2194
- <mesh userData={{ craneIndex: index }}>
2195
- <sphereGeometry args={[0.24, 10, 8]} />
2196
- <meshBasicMaterial visible={false} />
2197
- </mesh>
2198
- </group>
2199
- );
2200
- })}
2201
- {/* live readout while a drag changes the mark's height */}
2202
- {dragInfo && dragInfo.kind !== "bend" && selectedPoint && (
2203
- <GizmoLabel
2204
- position={[selectedBase.x, selectedPoint.height + 0.28, selectedBase.z]}
2205
- text={dragInfo.kind === "slide" ? `${Math.round(selectedPoint.t * 100)}%` : `${selectedPoint.height.toFixed(1)} m`}
2206
- camRef={camRef}
2207
- />
2208
- )}
2209
- {/* the span a bend drag will actually deform, lit on the floor path */}
2210
- {dragInfo?.kind === "bend" && (() => {
2211
- const span = [];
2212
- for (let i = 0; i < rail.points.length; i += 1) {
2213
- const t = rail.cumLen[i] / rail.length;
2214
- if (t >= dragInfo.t0 - dragInfo.radius && t <= dragInfo.t0 + dragInfo.radius) {
2215
- span.push([rail.points[i].x, 0.035, rail.points[i].z]);
2216
- }
2217
- }
2218
- if (span.length < 2) return null;
2219
- return <Line points={span} color="#ffb454" lineWidth={4} transparent opacity={0.9} />;
2220
- })()}
2221
- </group>
2222
- );
2223
- }
2224
-
2225
- export function ShotPathPreview({ waypoints, start, activeWaypointId }) {
2226
- const rootRef = useRef(null);
2227
- const line = useMemo(() => {
2228
- if (!waypoints.length) return null;
2229
- const points = [{ x: start.x, z: start.z }, ...waypoints];
2230
- const positions = new Float32Array(points.length * 3);
2231
- points.forEach((point, i) => {
2232
- positions[i * 3] = point.x;
2233
- positions[i * 3 + 1] = 0.03;
2234
- positions[i * 3 + 2] = point.z;
2235
- });
2236
- const geometry = new THREE.BufferGeometry();
2237
- geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
2238
- return geometry;
2239
- }, [start.x, start.z, waypoints]);
2240
- useEffect(() => {
2241
- // Every render: drei's Text meshes appear asynchronously, and each new
2242
- // node must land on the gizmo layer before the next capture.
2243
- rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
2244
- });
2245
- if (!waypoints.length) return null;
2246
- return (
2247
- <group ref={rootRef}>
2248
- {line && (
2249
- <line geometry={line}>
2250
- <lineBasicMaterial color="#4e9fb3" transparent opacity={0.8} depthWrite={false} />
2251
- </line>
2252
- )}
2253
- {waypoints.map((waypoint, i) => {
2254
- const active = waypoint.id === activeWaypointId;
2255
- const color = active ? "#ffd76c" : "#4e9fb3";
2256
- return (
2257
- <group key={waypoint.id} position={[waypoint.x, 0.03, waypoint.z]}>
2258
- <mesh rotation={[-Math.PI / 2, 0, 0]}>
2259
- <ringGeometry args={[0.13, 0.19, 28]} />
2260
- <meshBasicMaterial color={color} depthWrite={false} />
2261
- </mesh>
2262
- <Text
2263
- position={[0, 0.02, 0.34]}
2264
- rotation={[-Math.PI / 2, 0, 0]}
2265
- fontSize={0.24}
2266
- color={color}
2267
- anchorX="center"
2268
- anchorY="middle"
2269
- outlineWidth={0.03}
2270
- outlineColor="#1c1a17"
2271
- outlineOpacity={0.85}
2272
- >
2273
- {String(i + 1)}
2274
- </Text>
2275
- </group>
2276
- );
2277
- })}
2278
- </group>
2279
- );
2280
- }
2281
-
2282
- // How far the deck runs in an EXPORTED frame. The viewport fades it out at
2283
- // 18..54 m to keep the working view clean; a blocking frame needs the far edge
2284
- // to survive, so the falloff starts later and ends further away, letting the
2285
- // floor resolve into a horizon instead of dissolving into the background.
2286
- //
2287
- // Both values must stay inside the shot camera's far plane (100 m) — geometry
2288
- // past it is clipped outright, so a fog range that ended beyond it would cut
2289
- // the deck off mid-fade and take the horizon with it.
2290
- export const CAPTURE_FOG_NEAR = 55;
2291
- export const CAPTURE_FOG_FAR = 95;
2292
-
2293
- /** Offscreen aspect-aware read-back, always from the shot camera. */
2294
- export function CaptureRig({ apiRef, camRef, width = CAPTURE_W, height = CAPTURE_H }) {
2295
- const { gl, scene } = useThree();
2296
- useEffect(() => {
2297
- // Each attempt owns its target independently of the live output size.
2298
- // Resizing the editor cannot dispose an in-flight export's resources.
2299
- function createExportCapture({ width, height }) {
2300
- const target = new THREE.WebGLRenderTarget(width, height, {
2301
- colorSpace: THREE.SRGBColorSpace,
2302
- samples: 4,
2303
- });
2304
- const buffer = new Uint8Array(width * height * 4);
2305
- return {
2306
- scene,
2307
- dispose: () => target.dispose(),
2308
- render() {
2309
- const source = camRef.current;
2310
- if (!source) return null;
2311
- const cam = source.clone();
2312
- // the transform gizmo is UI: it never reaches an exported frame
2313
- cam.layers.disable(GIZMO_LAYER);
2314
- // QA hook: the layer mask the export camera actually renders
2315
- // with — the browser suite asserts GIZMO_LAYER (the gizmo AND
2316
- // the selection cage) is never in it.
2317
- window.__captureCameraMask = cam.layers.mask;
2318
- cam.aspect = width / height;
2319
- cam.updateProjectionMatrix();
2320
- const previous = gl.getRenderTarget();
2321
- // The viewport's fog dissolves the deck into the background by ~54 m
2322
- // so the working view has no horizon to distract from blocking. An
2323
- // exported frame wants the opposite: the horizon IS the vanishing
2324
- // point, and without it the floor has no far edge to read depth
2325
- // against. Push the falloff back for this draw only, then restore
2326
- // it so the viewport is untouched.
2327
- const fog = scene.fog;
2328
- const fogNear = fog?.near;
2329
- const fogFar = fog?.far;
2330
- if (fog) {
2331
- fog.near = CAPTURE_FOG_NEAR;
2332
- fog.far = CAPTURE_FOG_FAR;
2333
- }
2334
- try {
2335
- gl.setRenderTarget(target);
2336
- gl.render(scene, cam);
2337
- gl.readRenderTargetPixels(target, 0, 0, width, height, buffer);
2338
- } finally {
2339
- gl.setRenderTarget(previous);
2340
- if (fog) {
2341
- fog.near = fogNear;
2342
- fog.far = fogFar;
2343
- }
2344
- }
2345
- return buffer;
2346
- },
2347
- };
2348
- }
2349
- const api = { ...createExportCapture({ width, height }), createExportCapture };
2350
- apiRef.current = api;
2351
- if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) {
2352
- window.__cozyclayMcpCaptureReady = true;
2353
- window.dispatchEvent(new Event("cozyclay:mcp-capture-ready"));
2354
- }
2355
- // QA hook: run one real export render and report what the capture
2356
- // camera saw — the layer mask plus an amber scan of the output
2357
- // frame (the selection cage's warm tone). Lets the suite prove the
2358
- // deliverable frame stays free of editor furniture without driving
2359
- // the whole generation form.
2360
- window.__captureFrame = () => {
2361
- const buffer = apiRef.current?.render() ?? null;
2362
- if (!buffer) return null;
2363
- let amber = 0;
2364
- for (let i = 0; i < buffer.length; i += 4) {
2365
- const r = buffer[i];
2366
- const g = buffer[i + 1];
2367
- const b = buffer[i + 2];
2368
- if (r >= 180 && g >= 165 && g - b >= 35) amber++;
2369
- }
2370
- return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
2371
- };
2372
- return () => {
2373
- if (apiRef.current === api) apiRef.current = null;
2374
- if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) window.__cozyclayMcpCaptureReady = false;
2375
- api.dispose();
2376
- };
2377
- }, [gl, scene, camRef, apiRef, width, height]);
2378
- return null;
2379
- }
2380
-
2381
- export async function captureMcpFrame({ capture, camera, characters, activeCharacterId, objects, rigs, readAuthoredState, partColours = null }) {
2382
- if (!capture || !camera) throw new Error("No renderable shot camera is available for capture_frame.");
2383
- const authoredStateBefore = JSON.stringify(readAuthoredState());
2384
- const buffer = capture.render();
2385
- if (!buffer) throw new Error("No renderable shot camera is available for capture_frame.");
2386
- let nonBlackPixels = 0;
2387
- for (let offset = 0; offset < buffer.length; offset += 4) {
2388
- if (buffer[offset] > 5 || buffer[offset + 1] > 5 || buffer[offset + 2] > 5) nonBlackPixels += 1;
2389
- }
2390
- if (nonBlackPixels === 0) throw new Error("capture_frame rejected a black rendered frame.");
2391
- camera.updateMatrixWorld();
2392
- const raycaster = new THREE.Raycaster();
2393
- const roots = [];
2394
- capture.scene.traverse((node) => {
2395
- const id = node.userData?.sceneObjectId;
2396
- if (typeof id === "string" && objects.some((object) => object.id === id)) roots.push({ id, root: node });
2397
- });
2398
- const assertions = characters.map((character) => {
2399
- const rig = rigs[character.id];
2400
- if (!rig || character.hidden) return { id: character.id, renderable: false, behindCameraPlane: null, fartherAlongCameraForward: null, distanceToFloor: null, occludedBy: null, visiblePixelCount: 0 };
2401
- rig.updateWorldMatrix(true, true);
2402
- const bounds = new THREE.Box3().setFromObject(rig);
2403
- const center = bounds.getCenter(new THREE.Vector3());
2404
- const cameraSpace = camera.worldToLocal(center.clone());
2405
- const behindCameraPlane = cameraSpace.z >= 0;
2406
- const distanceToFloor = Math.max(0, bounds.min.y);
2407
- if (behindCameraPlane || bounds.isEmpty()) {
2408
- return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy: null, visiblePixelCount: 0 };
2409
- }
2410
- const corners = [
2411
- new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
2412
- new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
2413
- new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
2414
- new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
2415
- ].map((corner) => corner.project(camera));
2416
- const minX = Math.max(0, Math.floor(((Math.min(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
2417
- const maxX = Math.min(MCP_CAPTURE_W - 1, Math.ceil(((Math.max(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
2418
- const minY = Math.max(0, Math.floor(((1 - Math.max(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
2419
- const maxY = Math.min(MCP_CAPTURE_H - 1, Math.ceil(((1 - Math.min(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
2420
- const occluders = new Map();
2421
- let visiblePixelCount = 0;
2422
- for (let y = minY; y <= maxY; y += MCP_CAPTURE_SAMPLE_STEP) {
2423
- for (let x = minX; x <= maxX; x += MCP_CAPTURE_SAMPLE_STEP) {
2424
- raycaster.setFromCamera({ x: (x / MCP_CAPTURE_W) * 2 - 1, y: 1 - (y / MCP_CAPTURE_H) * 2 }, camera);
2425
- const characterHit = raycaster.intersectObject(rig, true)[0];
2426
- if (!characterHit) continue;
2427
- const objectHit = raycaster.intersectObjects(roots.map((entry) => entry.root), true)[0];
2428
- const pixels = MCP_CAPTURE_SAMPLE_STEP * MCP_CAPTURE_SAMPLE_STEP;
2429
- if (!objectHit || objectHit.distance >= characterHit.distance - 1e-4) {
2430
- visiblePixelCount += pixels;
2431
- continue;
2432
- }
2433
- const occluder = roots.find((entry) => {
2434
- let node = objectHit.object;
2435
- while (node) {
2436
- if (node === entry.root) return true;
2437
- node = node.parent;
2438
- }
2439
- return false;
2440
- });
2441
- if (occluder) occluders.set(occluder.id, (occluders.get(occluder.id) ?? 0) + pixels);
2442
- }
2443
- }
2444
- const occludedBy = [...occluders.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null;
2445
- return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy, visiblePixelCount };
2446
- });
2447
- const active = assertions.find((entry) => entry.id === activeCharacterId) ?? assertions[0];
2448
- const authoredStateAfter = JSON.stringify(readAuthoredState());
2449
- const canvas = document.createElement("canvas");
2450
- canvas.width = MCP_CAPTURE_W;
2451
- canvas.height = MCP_CAPTURE_H;
2452
- const context = canvas.getContext("2d");
2453
- if (!context) throw new Error("capture_frame could not encode the rendered image.");
2454
- const image = context.createImageData(MCP_CAPTURE_W, MCP_CAPTURE_H);
2455
- for (let row = 0; row < MCP_CAPTURE_H; row += 1) {
2456
- image.data.set(buffer.subarray((MCP_CAPTURE_H - 1 - row) * MCP_CAPTURE_W * 4, (MCP_CAPTURE_H - row) * MCP_CAPTURE_W * 4), row * MCP_CAPTURE_W * 4);
2457
- }
2458
- context.putImageData(image, 0, 0);
2459
- const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
2460
- if (!blob) throw new Error("capture_frame could not compress the rendered image.");
2461
- const bytes = new Uint8Array(await blob.arrayBuffer());
2462
- let binary = "";
2463
- for (const byte of bytes) binary += String.fromCharCode(byte);
2464
- return {
2465
- width: MCP_CAPTURE_W,
2466
- height: MCP_CAPTURE_H,
2467
- mimeType: "image/png",
2468
- encoding: "base64",
2469
- byteSize: bytes.length,
2470
- data: btoa(binary),
2471
- partColours,
2472
- authoredStateBefore,
2473
- authoredStateAfter,
2474
- assertions: {
2475
- renderable: true,
2476
- blackFrame: false,
2477
- nonBlackPixels,
2478
- behindCameraPlane: active?.behindCameraPlane ?? null,
2479
- fartherAlongCameraForward: active?.fartherAlongCameraForward ?? null,
2480
- distanceToFloor: active?.distanceToFloor ?? null,
2481
- occludedBy: active?.occludedBy ?? null,
2482
- visiblePixelCount: active?.visiblePixelCount ?? 0,
2483
- characters: assertions,
2484
- },
2485
- };
2486
- }
2487
-
2488
145
  /**
2489
146
  * Anything that accepts a dropped picture. Returns the handlers to spread onto
2490
147
  * an element and whether a drag is currently over it, so the target can say so
@@ -2587,276 +244,3 @@ export function useStageFilesDrop({ onImages, onMeshes, onRejected } = {}) {
2587
244
  },
2588
245
  };
2589
246
  }
2590
-
2591
- /* ----------------------------------------------------------------- app --- */
2592
-
2593
- // Unity's tool keys. They are free because camera movement now lives behind a
2594
- // held right button. (docs/unity-reference.md §9.2)
2595
- export const GIZMO_HOTKEYS = { KeyW: "move", KeyE: "rotate", KeyR: "scale" };
2596
-
2597
- export const WORKSPACE_LAYOUT_KEY = "cozyclay.workspace-layout.v1";
2598
- export const DEFAULT_WORKSPACE_LAYOUT = Object.freeze({
2599
- hierarchyWidth: 300,
2600
- sidebarWidth: 380,
2601
- timelineHeight: 224,
2602
- insetWidth: 310,
2603
- insetHeight: 310,
2604
- insetCollapsed: false,
2605
- planZoom: 1,
2606
- });
2607
-
2608
- export function loadWorkspaceLayout() {
2609
- // The landing-page playground is a fixed, chrome-less layout: the plan
2610
- // inset starts folded and nothing the visitor drags is written back.
2611
- if (isPlaygroundEmbed(globalThis.location?.search)) return { ...DEFAULT_WORKSPACE_LAYOUT, insetCollapsed: true };
2612
- try {
2613
- const saved = JSON.parse(localStorage.getItem(WORKSPACE_LAYOUT_KEY) || "null");
2614
- return saved ? { ...DEFAULT_WORKSPACE_LAYOUT, ...saved } : { ...DEFAULT_WORKSPACE_LAYOUT };
2615
- } catch {
2616
- return { ...DEFAULT_WORKSPACE_LAYOUT };
2617
- }
2618
- }
2619
-
2620
- /** Load the Scene envelope once. Legacy single-scene objects are migrated by
2621
- * scenes.js; App only supplies the familiar starter set for a truly new room. */
2622
- export function loadSceneStartup() {
2623
- const defaults = () => DEFAULT_SCENE_OBJECTS.map((object) => ({ ...object, footprint: { ...object.footprint } }));
2624
- // The landing-page playground never reads or writes the visitor's saved
2625
- // scenes: it opens the preset the page fetched (or an empty room if that
2626
- // fetch failed) and saving stays off for the whole session.
2627
- if (isPlaygroundEmbed(globalThis.location?.search)) {
2628
- const preset = takePlaygroundProject();
2629
- const document = preset?.document ?? createSceneDocument();
2630
- if (!preset?.document) document.scenes[0].objects = defaults();
2631
- return { document, saveBlocked: true, error: null, startupCreatedScene: false, toast: null };
2632
- }
2633
- try {
2634
- const result = loadSceneDocumentFromStorage(localStorage);
2635
- if (result.status === "future") {
2636
- const document = createSceneDocument();
2637
- document.scenes[0].objects = defaults();
2638
- return {
2639
- document,
2640
- saveBlocked: true,
2641
- error: null,
2642
- toast: ko("Saved scenes were written by a newer CozyClay — they have been left untouched and this session will not save", "저장된 장면은 더 최신 CozyClay에서 만들어졌어요. 이 세션에서는 건드리지 않고 저장도 하지 않습니다."),
2643
- };
2644
- }
2645
- const document = result.document;
2646
- if ((result.status === "absent" || result.status === "corrupt") && document.scenes[0].objects.length === 0) {
2647
- document.scenes[0].objects = defaults();
2648
- }
2649
- return {
2650
- document,
2651
- saveBlocked: false,
2652
- error: null,
2653
- // The startup scene is authored silently; without this flag the
2654
- // funnel's "scene created" step never fires for a brand-new room.
2655
- startupCreatedScene: result.status === "absent" || result.status === "corrupt",
2656
- toast: result.status === "corrupt"
2657
- ? ko(`Saved scenes were unreadable — starting fresh; the old data is kept under ${SCENES_QUARANTINE_KEY}`, `저장된 장면을 읽을 수 없어 새로 시작합니다. 기존 데이터는 ${SCENES_QUARANTINE_KEY}에 보관했어요.`)
2658
- : result.dropped > 0
2659
- ? (isKo ? `저장된 장면 ${result.dropped}개를 복원하지 못했어요` : `${result.dropped} saved scene(s) could not be restored`)
2660
- : null,
2661
- };
2662
- } catch {
2663
- const document = createSceneDocument();
2664
- document.scenes[0].objects = defaults();
2665
- return { document, saveBlocked: false, error: null, startupCreatedScene: true, toast: null };
2666
- }
2667
- }
2668
-
2669
- /* ------------------------- IK-mode motion trails --------------------------
2670
- * World-space trajectory polylines of the loaded take: the root (hips) path
2671
- * always, plus the focused IK effector's end-point trail. The whole clip is
2672
- * drawn as a faint line; while a grab is active the falloff window is
2673
- * re-drawn on top as a bright highlight. Grabbing any point of a line starts
2674
- * a drag on a camera-facing plane through the grab point; the caller deforms
2675
- * the take (motion-trail.js falloff math) so the preview updates live. */
2676
- export const MotionTrails = memo(function MotionTrails({ motion, rig = null, baseY, charScale, ikFocus, falloffFrames, playheadFrame, pendingEdit, enabled, visible = true, onDragStart, onDragPreview, onDragEnd }) {
2677
- const { camera, gl, invalidate } = useThree();
2678
- const [drag, setDrag] = useState(null);
2679
- const callbacksRef = useRef({ onDragStart, onDragPreview, onDragEnd });
2680
- callbacksRef.current = { onDragStart, onDragPreview, onDragEnd };
2681
- const toTriples = (flat) => {
2682
- if (!flat) return null;
2683
- const out = [];
2684
- for (let index = 0; index + 2 < flat.length; index += 3) out.push([flat[index], flat[index + 1], flat[index + 2]]);
2685
- return out;
2686
- };
2687
- // Every trail track (root + IK endpoints + head) in its handle colour,
2688
- // sampled where playback renders each bone on this rig (not the clip's
2689
- // own joints, which sit 4-15 cm off the body), so draw and pick agree.
2690
- const tracks = useMemo(
2691
- () => TRAIL_TRACKS.map((track) => {
2692
- const flat = jointTrailPoints(motion, track.joint, { baseY, scale: charScale, rig });
2693
- return flat ? { ...track, flat, points: toTriples(flat) } : null;
2694
- }).filter(Boolean),
2695
- // eslint-disable-next-line react-hooks/exhaustive-deps
2696
- [motion, rig, baseY, charScale],
2697
- );
2698
- const trackById = (id) => tracks.find((track) => track.id === id) ?? null;
2699
- // The falloff window rides whichever line is being (or was last) grabbed.
2700
- const highlight = drag ?? pendingEdit;
2701
- const highlightPoints = useMemo(() => {
2702
- if (!highlight || !motion?.frames) return null;
2703
- const flat = trackById(highlight.track)?.flat ?? trackById("hips")?.flat;
2704
- if (!flat) return null;
2705
- const { startFrame, endFrame } = trailEditRange(motion.frames, highlight.grabFrame, falloffFrames);
2706
- const out = [];
2707
- for (let frame = startFrame; frame < endFrame; frame += 1) {
2708
- out.push([flat[frame * 3], flat[frame * 3 + 1], flat[frame * 3 + 2]]);
2709
- }
2710
- return out.length > 1 ? out : null;
2711
- // eslint-disable-next-line react-hooks/exhaustive-deps
2712
- }, [highlight, motion, tracks, falloffFrames]);
2713
-
2714
- // Manual capture-phase picking instead of r3f pointer handlers: handlers on
2715
- // a drei Line make react-three-fiber raycast the fat-line shader geometry on
2716
- // EVERY pointermove for hover bookkeeping, which is exactly the IK-mode lag.
2717
- // A single pointerdown listener that measures point-to-ray distance against
2718
- // the cached trail arrays costs nothing while the mouse merely moves.
2719
- const pickRef = useRef(null);
2720
- pickRef.current = { tracks, enabled, falloffFrames, playheadFrame };
2721
- // Line2 instances for in-place geometry rewrites during a drag.
2722
- const lineRefs = useRef({});
2723
- const highlightRef = useRef(null);
2724
- useEffect(() => {
2725
- const dom = gl.domElement;
2726
- const raycaster = new THREE.Raycaster();
2727
- const hit = new THREE.Vector3();
2728
- const down = (event) => {
2729
- const pick = pickRef.current;
2730
- if (!pick?.enabled || event.button !== 0) return;
2731
- const rect = dom.getBoundingClientRect();
2732
- const ndc = new THREE.Vector2(
2733
- ((event.clientX - rect.left) / rect.width) * 2 - 1,
2734
- -((event.clientY - rect.top) / rect.height) * 2 + 1,
2735
- );
2736
- raycaster.setFromCamera(ndc, camera);
2737
- const { origin, direction } = raycaster.ray;
2738
- const picked = pickTrailPoint({
2739
- tracks: pick.tracks,
2740
- playheadFrame: pick.playheadFrame,
2741
- falloffFrames: pick.falloffFrames,
2742
- rayOrigin: origin,
2743
- rayDirection: direction,
2744
- maxDistance: 0.2,
2745
- });
2746
- if (!picked) return;
2747
- const { track, grabFrame } = picked;
2748
- // The grab wins over the camera controls listening in the bubble phase.
2749
- event.stopPropagation();
2750
- event.preventDefault();
2751
- const flat = pick.tracks.find((candidate) => candidate.id === track)?.flat;
2752
- if (!flat) return;
2753
- const start = new THREE.Vector3(flat[grabFrame * 3], flat[grabFrame * 3 + 1], flat[grabFrame * 3 + 2]);
2754
- const normal = camera.getWorldDirection(new THREE.Vector3()).negate();
2755
- const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, start);
2756
- setDrag({ track, grabFrame });
2757
- callbacksRef.current.onDragStart?.({ track, grabFrame });
2758
- // The drag never touches React state: per rAF the grabbed line and the
2759
- // falloff highlight are rewritten in place on their Line2 geometries,
2760
- // and onDragPreview poses the rig imperatively. Re-rendering the whole
2761
- // app per pointermove is exactly the drag lag this avoids; the real
2762
- // commit (setMotion + pending edit) happens once, on pointerup.
2763
- const frames = flat.length / 3;
2764
- const { startFrame, endFrame } = trailEditRange(frames, grabFrame, pickRef.current.falloffFrames);
2765
- const deformedFlat = new Float32Array(flat);
2766
- const windowFlat = new Float32Array((endFrame - startFrame) * 3);
2767
- let lastDelta = null;
2768
- let queued = null;
2769
- let rafId = 0;
2770
- const applyPreview = (delta) => {
2771
- for (let frame = startFrame; frame < endFrame; frame += 1) {
2772
- const weight = falloffWeight(frame - grabFrame, pickRef.current.falloffFrames);
2773
- deformedFlat[frame * 3] = flat[frame * 3] + delta.x * weight;
2774
- deformedFlat[frame * 3 + 1] = flat[frame * 3 + 1] + delta.y * weight;
2775
- deformedFlat[frame * 3 + 2] = flat[frame * 3 + 2] + delta.z * weight;
2776
- }
2777
- windowFlat.set(deformedFlat.subarray(startFrame * 3, endFrame * 3));
2778
- lineRefs.current[track]?.geometry?.setPositions(deformedFlat);
2779
- highlightRef.current?.geometry?.setPositions(windowFlat);
2780
- callbacksRef.current.onDragPreview?.({ track, grabFrame, delta });
2781
- invalidate();
2782
- };
2783
- const flush = () => {
2784
- rafId = 0;
2785
- if (queued) applyPreview(queued);
2786
- queued = null;
2787
- };
2788
- const move = (pointerEvent) => {
2789
- const moveRect = dom.getBoundingClientRect();
2790
- const moveNdc = new THREE.Vector2(
2791
- ((pointerEvent.clientX - moveRect.left) / moveRect.width) * 2 - 1,
2792
- -((pointerEvent.clientY - moveRect.top) / moveRect.height) * 2 + 1,
2793
- );
2794
- raycaster.setFromCamera(moveNdc, camera);
2795
- if (!raycaster.ray.intersectPlane(plane, hit)) return;
2796
- lastDelta = { x: hit.x - start.x, y: hit.y - start.y, z: hit.z - start.z };
2797
- queued = lastDelta;
2798
- if (!rafId) rafId = requestAnimationFrame(flush);
2799
- };
2800
- const up = () => {
2801
- window.removeEventListener("pointermove", move);
2802
- window.removeEventListener("pointerup", up);
2803
- if (rafId) cancelAnimationFrame(rafId);
2804
- if (queued) applyPreview(queued);
2805
- setDrag(null);
2806
- callbacksRef.current.onDragEnd?.({ track, grabFrame, delta: lastDelta });
2807
- };
2808
- window.addEventListener("pointermove", move);
2809
- window.addEventListener("pointerup", up);
2810
- };
2811
- dom.addEventListener("pointerdown", down, true);
2812
- return () => dom.removeEventListener("pointerdown", down, true);
2813
- // eslint-disable-next-line react-hooks/exhaustive-deps
2814
- }, [gl, camera]);
2815
-
2816
- if (!tracks.length) return null;
2817
- const focusTrack = TRAIL_EFFECTOR_JOINTS[ikFocus] ? tracks.find((track) => track.joint === TRAIL_EFFECTOR_JOINTS[ikFocus])?.id : null;
2818
- // depthTest off + high renderOrder: the trails read through the character
2819
- // and the floor instead of vanishing into them. Every part rides its own
2820
- // IK-handle colour; the focused part draws thicker.
2821
- return (
2822
- <group
2823
- visible={visible}
2824
- renderOrder={900}
2825
- ref={(group) => {
2826
- // QA-only escape hatch (same spirit as window.__cozyclay): lets
2827
- // headless perf probes toggle the trails without a rebuild.
2828
- if (typeof window !== "undefined") window.__cozyclayTrails = group;
2829
- }}
2830
- >
2831
- {tracks.map((track) => track.points.length > 1 && (
2832
- <Line
2833
- key={track.id}
2834
- ref={(line) => {
2835
- if (line) lineRefs.current[track.id] = line;
2836
- else delete lineRefs.current[track.id];
2837
- }}
2838
- points={track.points}
2839
- color={track.color}
2840
- lineWidth={track.id === focusTrack ? 4 : 2.5}
2841
- depthTest={false}
2842
- transparent
2843
- opacity={track.id === focusTrack ? 1 : 0.85}
2844
- />
2845
- ))}
2846
- {highlightPoints && (
2847
- <Line ref={highlightRef} points={highlightPoints} color={drag ? "#3dff7a" : "#ff8c42"} lineWidth={5} depthTest={false} />
2848
- )}
2849
- </group>
2850
- );
2851
- }, (previous, next) => (
2852
- previous.motion === next.motion &&
2853
- previous.rig === next.rig &&
2854
- previous.baseY === next.baseY &&
2855
- previous.charScale === next.charScale &&
2856
- previous.ikFocus === next.ikFocus &&
2857
- previous.falloffFrames === next.falloffFrames &&
2858
- previous.playheadFrame === next.playheadFrame &&
2859
- previous.pendingEdit === next.pendingEdit &&
2860
- previous.enabled === next.enabled &&
2861
- previous.visible === next.visible
2862
- ));