cozyclay 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +56 -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 +2 -3
  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-B41FmPYI.js +1 -0
  26. package/dist/assets/app-Do-Og5Pu.css +1 -0
  27. package/dist/assets/app-DwP-tl2x.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-BCWDJB1d.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-jqMhqL88.js} +1 -1
  34. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/sitemap.xml +1 -1
  38. package/dist/workflow/index.html +4 -4
  39. package/mcp/LIVE-PROTOCOL.md +23 -2
  40. package/mcp/README.md +37 -2
  41. package/mcp/ardy-prompts.mjs +1 -1
  42. package/mcp/image-file.mjs +132 -0
  43. package/mcp/live-hub.mjs +2 -2
  44. package/mcp/package.json +1 -0
  45. package/mcp/runtime/package-lock.json +7 -0
  46. package/mcp/runtime/package.json +1 -0
  47. package/mcp/server.mjs +0 -6
  48. package/mcp/tool-handlers.mjs +289 -87
  49. package/package.json +11 -2
  50. package/src/App.jsx +789 -263
  51. package/src/agent-image-client.js +94 -0
  52. package/src/analytics.js +47 -3
  53. package/src/app-stage.jsx +129 -2745
  54. package/src/ardy/auto-physics.js +6 -16
  55. package/src/ardy/collision-blockers.js +3 -7
  56. package/src/ardy/fix-collisions.js +5 -35
  57. package/src/ardy/ik-drag.js +86 -5
  58. package/src/ardy/ik-key-json.js +3 -1
  59. package/src/ardy/ik.js +15 -13
  60. package/src/ardy/motion-calibration.js +1 -1
  61. package/src/ardy/npz.js +0 -8
  62. package/src/ardy/physics-panel.jsx +3 -2
  63. package/src/ardy/physics-review.js +3 -2
  64. package/src/ardy/physics-support.js +1 -1
  65. package/src/ardy/physics-surface.js +1 -1
  66. package/src/ardy/platform-fit-panel.jsx +2 -1
  67. package/src/ardy/playback.js +1 -5
  68. package/src/ardy/range-pin.js +2 -2
  69. package/src/ardy/root-drop.js +0 -1
  70. package/src/ardy/timeline.css +184 -6
  71. package/src/ardy/timeline.jsx +113 -28
  72. package/src/ardy/to-cskel27.js +0 -2
  73. package/src/ardy/waypoints.js +0 -3
  74. package/src/ardy-bridge-contract.js +281 -0
  75. package/src/asset-pane.jsx +19 -9
  76. package/src/auto-color.js +1 -1
  77. package/src/burn-in.js +1 -1
  78. package/src/camera-block.js +1 -1
  79. package/src/camera-follow.js +75 -32
  80. package/src/camera-move.js +0 -6
  81. package/src/camera-tutorial.jsx +6 -7
  82. package/src/character-kind.js +79 -0
  83. package/src/command-bus.js +76 -17
  84. package/src/commands/cast.js +31 -7
  85. package/src/commands/elements/object.js +1 -1
  86. package/src/commands/elements/shot.js +1 -1
  87. package/src/commands/elements.js +1 -1
  88. package/src/commands/export.js +3 -3
  89. package/src/commands/motion.js +2 -2
  90. package/src/commands/objects.js +105 -10
  91. package/src/commands/project.js +15 -2
  92. package/src/commands/sculpt-images.js +53 -0
  93. package/src/commands/shot.js +88 -10
  94. package/src/commands/view.js +10 -4
  95. package/src/coplanar-depth.js +216 -0
  96. package/src/cuts.js +7 -6
  97. package/src/domains/cast.js +60 -43
  98. package/src/domains/motion/motion-intent-codec.js +50 -0
  99. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  100. package/src/domains/motion/motion-video-capture.js +664 -0
  101. package/src/domains/motion.js +253 -742
  102. package/src/domains/objects.js +106 -5
  103. package/src/domains/scenes.js +79 -28
  104. package/src/domains/shots.js +33 -7
  105. package/src/error-boundary.jsx +1 -1
  106. package/src/execution-telemetry.js +5 -5
  107. package/src/facing-marks.js +14 -1
  108. package/src/first-success-guide.jsx +35 -4
  109. package/src/gizmo-claim.js +3 -1
  110. package/src/grid-floor.jsx +1 -4
  111. package/src/hierarchy-model.js +48 -35
  112. package/src/hierarchy-panel.css +1 -0
  113. package/src/hierarchy-panel.jsx +54 -102
  114. package/src/history.js +0 -2
  115. package/src/i2v-motion-client.js +1 -4
  116. package/src/ime.js +6 -0
  117. package/src/keyframe-pack.js +15 -6
  118. package/src/line-edit.js +4 -4
  119. package/src/live-control.js +6 -6
  120. package/src/matte-editor.js +2 -8
  121. package/src/matte.js +0 -19
  122. package/src/mesh-graph-clone.js +16 -2
  123. package/src/mesh-sniff.js +0 -1
  124. package/src/monotone-cubic.js +40 -0
  125. package/src/motion-store.js +4 -4
  126. package/src/motion-trail.js +5 -11
  127. package/src/multimodel-ingest.js +1 -3
  128. package/src/object-catalog.jsx +7 -5
  129. package/src/object-gizmo.jsx +45 -31
  130. package/src/object-path.js +310 -67
  131. package/src/object-travel.js +224 -0
  132. package/src/otio.js +8 -3
  133. package/src/panels/CharacterTransformPanel.jsx +24 -6
  134. package/src/panels/ObjectTransformPanel.jsx +4 -3
  135. package/src/panels/PoseImagePanel.jsx +205 -0
  136. package/src/panels/PromptBlocksPanel.jsx +3 -1
  137. package/src/panels/ProxyFigurePanel.jsx +56 -0
  138. package/src/panels/ReferenceImageField.jsx +1 -1
  139. package/src/panels/RigControlPanel.jsx +24 -3
  140. package/src/panels/RigPanel.jsx +2 -1
  141. package/src/panels/SculptPanel.jsx +405 -0
  142. package/src/panels/SubjectBox.jsx +1 -1
  143. package/src/panels/SubjectsPanel.jsx +0 -2
  144. package/src/panels/VideoCapturePanel.jsx +2 -1
  145. package/src/panels/pose-image.css +42 -0
  146. package/src/panels/pose.css +3 -0
  147. package/src/panels/sculpt.css +162 -0
  148. package/src/plan-diagram.js +184 -0
  149. package/src/planview.jsx +127 -80
  150. package/src/playground.js +3 -5
  151. package/src/pose-fade.js +77 -0
  152. package/src/pose-gizmo.css +23 -0
  153. package/src/pose-gizmo.js +96 -0
  154. package/src/posestudio.jsx +134 -83
  155. package/src/project-browser.css +53 -0
  156. package/src/project-browser.jsx +49 -4
  157. package/src/project.js +55 -24
  158. package/src/props.jsx +177 -36
  159. package/src/proxy-figure.jsx +69 -0
  160. package/src/range-pin-panel.jsx +0 -1
  161. package/src/receipt-findings.js +20 -0
  162. package/src/render-passes.js +0 -1
  163. package/src/resource-status.jsx +1 -1
  164. package/src/root-path.js +48 -0
  165. package/src/route-owner.js +52 -0
  166. package/src/scene-asset-cache.js +1 -2
  167. package/src/scene-assets.js +12 -4
  168. package/src/scene-fbx.js +1 -1
  169. package/src/scene-mesh-cache.js +0 -1
  170. package/src/scene-objects.js +319 -19
  171. package/src/scenes.js +58 -9
  172. package/src/sculpt-blueprint.js +65 -0
  173. package/src/sculpt-compare.js +384 -0
  174. package/src/sculpt-geometry.js +141 -0
  175. package/src/sculpt-mode.js +168 -0
  176. package/src/sculpt-recipe.js +385 -0
  177. package/src/sculpt-run.js +351 -0
  178. package/src/sculpt-stage.jsx +221 -0
  179. package/src/settings-menu.jsx +1 -1
  180. package/src/shell/BoardSlot.jsx +341 -0
  181. package/src/shell/BottomDock.jsx +58 -14
  182. package/src/shell/DetailsSlot.jsx +26 -6
  183. package/src/shell/MenuBar.jsx +78 -6
  184. package/src/shell/OutlinerSlot.jsx +3 -1
  185. package/src/shell/TopBar.jsx +17 -3
  186. package/src/shell/ViewportToolbar.jsx +18 -3
  187. package/src/shell/dock.css +239 -0
  188. package/src/shell/glass-regions.css +3 -2
  189. package/src/shell/log-store.js +4 -4
  190. package/src/shell/mode.css +20 -0
  191. package/src/shell/studio-shell-context.js +4 -1
  192. package/src/shot-authoring.js +69 -6
  193. package/src/stable-items.js +1 -1
  194. package/src/stage-attach-frames.js +169 -0
  195. package/src/stage-canvas-rigs.jsx +402 -0
  196. package/src/stage-capture.jsx +216 -0
  197. package/src/stage-characters.jsx +302 -0
  198. package/src/stage-editor-gizmos.jsx +1229 -0
  199. package/src/stage-labels-ko.js +204 -0
  200. package/src/stage-startup-defaults.js +182 -0
  201. package/src/starter-scenes.js +1 -1
  202. package/src/storyboard.js +24 -5
  203. package/src/studio-actions.js +42 -9
  204. package/src/studio-agent-commands.js +186 -23
  205. package/src/studio-agent-context.js +11 -9
  206. package/src/studio-agent-motion.js +2 -1
  207. package/src/studio-agent-protocol.js +56 -23
  208. package/src/studio-app-binding.js +52 -23
  209. package/src/studio-contact-sheet.js +3 -3
  210. package/src/studio-elements.js +11 -2
  211. package/src/studio-geometry-facts.js +114 -0
  212. package/src/styles.css +92 -9
  213. package/src/ui-scrub.js +4 -4
  214. package/src/ui.jsx +6 -4
  215. package/src/use-asset-shelf.js +60 -0
  216. package/src/use-camera-tutorial.js +50 -0
  217. package/src/use-embed-bridge.js +12 -0
  218. package/src/use-first-success-prompt.js +7 -0
  219. package/src/use-i2v-capture-modal.js +15 -0
  220. package/src/use-sculpt-mode.js +95 -0
  221. package/src/workflow/AgentPanel.jsx +106 -18
  222. package/src/workflow/WorkflowBuilder.jsx +1 -1
  223. package/src/workflow/agent-client.js +113 -45
  224. package/src/workflow/agent-panel.css +35 -0
  225. package/src/workflow/attachment-image.js +1 -1
  226. package/src/workflow/canvas-commands.js +3 -3
  227. package/src/workflow/cozy-scene-node.js +3 -3
  228. package/src/workflow/local-workflow.js +2 -2
  229. package/src/workflow/motion-input.js +3 -3
  230. package/src/workflow/node-schema.js +1 -1
  231. package/src/workflow/scene-asset-sync.js +2 -9
  232. package/src/workflow/shot-prompt-node.js +1 -1
  233. package/src/workflow/video-contract.js +1 -1
  234. package/src/workflow/workflow-resources.js +0 -8
  235. package/src/workflow-send.js +87 -0
  236. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  237. package/tools/check-eslint.mjs +57 -0
  238. package/tools/check-knip.mjs +60 -0
  239. package/tools/check-loc.mjs +54 -0
  240. package/tools/demo-worker/api-client.mjs +7 -13
  241. package/tools/demo-worker/generate.mjs +2 -4
  242. package/tools/eslint-baseline.json +23 -0
  243. package/tools/kimodo/effector-constraints.mjs +4 -4
  244. package/tools/kimodo/generate.mjs +1 -1
  245. package/tools/knip-baseline.json +331 -0
  246. package/tools/loc-baseline.json +126 -0
  247. package/tools/process-supervisor.mjs +1 -1
  248. package/tools/projflow/replay.mjs +4 -4
  249. package/tools/projflow/runner.mjs +3 -3
  250. package/tools/qa/studio-control-count.mjs +2 -0
  251. package/tools/run-tests.mjs +94 -7
  252. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  253. package/dist/assets/app-BxBLC6GT.js +0 -4876
  254. package/dist/assets/app-CJdxvnrl.css +0 -1
  255. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  256. package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/planview.jsx CHANGED
@@ -4,9 +4,12 @@ import { Line, Text } from "@react-three/drei";
4
4
  import * as THREE from "three";
5
5
  import { aimAt } from "./controls.jsx";
6
6
  import { PLAN_LAYER } from "./dualview.jsx";
7
- import { objectSize } from "./scene-objects.js";
7
+ import { EMPTY_MARKER_SIZE, isEmptyObject, objectSize } from "./scene-objects.js";
8
8
  import { displayObjectLabel } from "./object-catalog.jsx";
9
9
  import { ko } from "./locale.js";
10
+ import { isProxyFigure } from "./scenes.js";
11
+ import { prepareRailBend } from "./camera-follow.js";
12
+ import { MARK_GAP, MAX_PATH_MARKS, MAX_PATH_POINTS, nearestPathFraction, pathCurve, pathMarkFractions, pathPointAtFraction } from "./object-path.js";
10
13
 
11
14
  const ROOM_LIMIT = 240; // stay on the open stage (matches scene-objects' clamp)
12
15
  const ACTOR_LIMIT = 4; // matches the Subject sliders' range
@@ -186,7 +189,33 @@ function CastRing({ color }) {
186
189
  );
187
190
  }
188
191
 
192
+ /** An Empty on the board: no footprint, just a small cross and a ring where
193
+ * its origin stands, so a group node can be found, selected and dragged like
194
+ * any other row of the plan. */
195
+ function EmptyPlanMarker({ object, selected, dragging, turning }) {
196
+ const half = EMPTY_MARKER_SIZE / 2;
197
+ const color = selected ? SELECTED_COLOR : PROP_LABEL_COLOR;
198
+ const rotation = (object.rot * Math.PI) / 180;
199
+ const cross = useMemo(() => [[[-half, 0.04, 0], [half, 0.04, 0]], [[0, 0.04, -half], [0, 0.04, half]]], [half]);
200
+ return (
201
+ <group position={[object.x, 0, object.z]} rotation={[0, rotation, 0]}>
202
+ {cross.map((points, index) => (
203
+ <Line key={index} points={points} color={color} lineWidth={selected ? 2 : 1.5} transparent opacity={selected ? 1 : 0.85} depthWrite={false} depthTest={false} renderOrder={12} />
204
+ ))}
205
+ <mesh position={[0, 0.036, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
206
+ <ringGeometry args={[half * 0.8, half * 0.95, 24]} />
207
+ <meshBasicMaterial color={color} transparent opacity={selected ? 0.9 : 0.6} depthWrite={false} depthTest={false} />
208
+ </mesh>
209
+ <group position={[0, 0, half + 0.35]} rotation={[0, -rotation, 0]}>
210
+ <PlanLabel text={displayObjectLabel(object.name).slice(0, 8)} color={color} offset={0} muted={!selected} />
211
+ </group>
212
+ {selected && <Puck color={SELECTED_COLOR} showBody={false} handleDist={half + 0.65} dragging={dragging} turning={turning} />}
213
+ </group>
214
+ );
215
+ }
216
+
189
217
  function SceneObjectFootprint({ object, selected, dragging, turning }) {
218
+ if (isEmptyObject(object)) return <EmptyPlanMarker object={object} selected={selected} dragging={dragging} turning={turning} />;
190
219
  const { width, height, depth } = objectSize(object);
191
220
  const objectLabel = displayObjectLabel(object.name).slice(0, 8);
192
221
  const handleDist = depth / 2 + 0.65;
@@ -303,11 +332,6 @@ function SubjectMovementGuide({ track }) {
303
332
  );
304
333
  }
305
334
 
306
- /**
307
- * The drawn camera rail (and the live stroke while drawing): a flat violet
308
- * line on the deck. Display only — authoring happens through the pointer
309
- * handlers, exactly like the pucks.
310
- */
311
335
  /**
312
336
  * A scene object's travel path on the plan board. The camera's rail owns
313
337
  * RAIL_COLOR; a prop's route gets its own green so the two never read as the
@@ -315,54 +339,41 @@ function SubjectMovementGuide({ track }) {
315
339
  */
316
340
  const OBJECT_PATH_COLOR = "#6fcf97";
317
341
  const OBJECT_PATH_SELECTED_COLOR = "#ffb454";
342
+ const OBJECT_PATH_LIT_COLOR = "#d4f7e2";
318
343
  // How near the floor pointer must be to grab a route point, in metres.
319
344
  const PATH_POINT_GRAB = 0.34;
320
- function ObjectPathLine({ points, selectedIndex = null }) {
321
- if (!points || points.length < 2) return null;
322
- const first = points[0];
323
- const last = points[points.length - 1];
345
+ function ObjectPathLine({ path, selectedIndex = null, hoverIndex = null, lit = false }) {
346
+ const curve = useMemo(() => (path?.points?.length > 1 ? pathCurve(path) : null), [path]);
347
+ const marks = useMemo(() => (path ? pathMarkFractions(path).map((t) => pathPointAtFraction(path, t)) : []), [path]);
348
+ if (!curve || curve.points.length < 2) return null;
324
349
  return (
325
350
  <group>
326
- <Line
327
- points={points.map((point) => [point.x, 0.028, point.z])}
328
- color={OBJECT_PATH_COLOR}
329
- lineWidth={3}
330
- transparent
331
- opacity={0.92}
332
- depthWrite={false}
333
- depthTest={false}
334
- renderOrder={9}
335
- />
336
- {/* Every point is a handle here, not just the ends: the route is
337
- drawn on this board, so it should also be editable on it. */}
338
- {points.map((point, index) => (
351
+ {/* The camera rail's own line: the curve the object travels, with the
352
+ same start disc, START label, heading arrow and end ring. Only the
353
+ colour is the prop's. */}
354
+ <CameraRailLine points={curve.points} color={lit ? OBJECT_PATH_LIT_COLOR : OBJECT_PATH_COLOR} />
355
+ {/* The marks, like the rail's crane dots: the ends plus any added by
356
+ double-clicking the line. These are what the board lets you grab. */}
357
+ {marks.map((point, index) => point && (
339
358
  <mesh
340
359
  key={index}
341
- position={[point.x, 0.036, point.z]}
360
+ position={[point.x, 0.042, point.z]}
342
361
  rotation={[-Math.PI / 2, 0, 0]}
343
- renderOrder={10}
362
+ renderOrder={12}
344
363
  >
345
- <circleGeometry args={[index === selectedIndex ? 0.13 : 0.1, 14]} />
364
+ <circleGeometry args={[index === selectedIndex ? 0.13 : index === hoverIndex ? 0.12 : 0.1, 14]} />
346
365
  <meshBasicMaterial
347
- color={index === selectedIndex ? OBJECT_PATH_SELECTED_COLOR : OBJECT_PATH_COLOR}
366
+ color={index === selectedIndex ? OBJECT_PATH_SELECTED_COLOR : index === hoverIndex ? "#d4f7e2" : "#2f8f5b"}
348
367
  depthWrite={false}
349
368
  depthTest={false}
350
369
  />
351
370
  </mesh>
352
371
  ))}
353
- <mesh position={[first.x, 0.038, first.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
354
- <circleGeometry args={[0.16, 18]} />
355
- <meshBasicMaterial color={OBJECT_PATH_COLOR} depthWrite={false} depthTest={false} />
356
- </mesh>
357
- <mesh position={[last.x, 0.038, last.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
358
- <ringGeometry args={[0.13, 0.19, 18]} />
359
- <meshBasicMaterial color={OBJECT_PATH_COLOR} depthWrite={false} depthTest={false} />
360
- </mesh>
361
372
  </group>
362
373
  );
363
374
  }
364
375
 
365
- function CameraRailLine({ points, live = false }) {
376
+ function CameraRailLine({ points, live = false, color = RAIL_COLOR }) {
366
377
  const directionGeometry = useMemo(() => {
367
378
  if (live || !points || points.length < 2) return null;
368
379
  const head = points[0];
@@ -394,22 +405,22 @@ function CameraRailLine({ points, live = false }) {
394
405
  const last = points[points.length - 1];
395
406
  return (
396
407
  <group>
397
- <Line points={points.map((point) => [point.x, 0.03, point.z])} color={RAIL_COLOR} lineWidth={live ? 2.5 : 3.5} transparent opacity={live ? 0.72 : 0.96} depthWrite={false} depthTest={false} renderOrder={9} />
408
+ <Line points={points.map((point) => [point.x, 0.03, point.z])} color={color} lineWidth={live ? 2.5 : 3.5} transparent opacity={live ? 0.72 : 0.96} depthWrite={false} depthTest={false} renderOrder={9} />
398
409
  {!live && (
399
410
  <>
400
411
  <mesh position={[first.x, 0.04, first.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
401
412
  <circleGeometry args={[0.22, 20]} />
402
- <meshBasicMaterial color={RAIL_COLOR} depthWrite={false} depthTest={false} />
413
+ <meshBasicMaterial color={color} depthWrite={false} depthTest={false} />
403
414
  </mesh>
404
415
  <mesh position={[first.x, 0.039, first.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
405
416
  <ringGeometry args={[0.27, 0.32, 20]} />
406
- <meshBasicMaterial color={RAIL_COLOR} transparent opacity={0.72} depthWrite={false} depthTest={false} />
417
+ <meshBasicMaterial color={color} transparent opacity={0.72} depthWrite={false} depthTest={false} />
407
418
  </mesh>
408
419
  <Text
409
420
  position={[first.x, 0.05, first.z + 0.48]}
410
421
  rotation={[-Math.PI / 2, 0, 0]}
411
422
  fontSize={0.24}
412
- color={RAIL_COLOR}
423
+ color={color}
413
424
  anchorX="center"
414
425
  anchorY="middle"
415
426
  outlineWidth={0.035}
@@ -422,12 +433,12 @@ function CameraRailLine({ points, live = false }) {
422
433
  </Text>
423
434
  {directionGeometry && (
424
435
  <mesh geometry={directionGeometry} renderOrder={11}>
425
- <meshBasicMaterial color={RAIL_COLOR} depthWrite={false} depthTest={false} side={THREE.DoubleSide} />
436
+ <meshBasicMaterial color={color} depthWrite={false} depthTest={false} side={THREE.DoubleSide} />
426
437
  </mesh>
427
438
  )}
428
439
  <mesh position={[last.x, 0.04, last.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
429
440
  <ringGeometry args={[0.1, 0.15, 16]} />
430
- <meshBasicMaterial color={RAIL_COLOR} depthWrite={false} depthTest={false} />
441
+ <meshBasicMaterial color={color} depthWrite={false} depthTest={false} />
431
442
  </mesh>
432
443
  </>
433
444
  )}
@@ -490,10 +501,18 @@ function WaypointPath({ waypoints, start, activeWaypointId }) {
490
501
  * reports moves that still hit it, so a fast drag off the edge silently strands
491
502
  * the puck.
492
503
  */
493
- export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, characters = [], onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, pathStart = null, waypoints, activeWaypointId, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects = [], selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, cameraRailPoints = null, railDraw = false, onRailStroke, pathDraw = false, onPathStroke, objectPathPoints = null, objectPathSelectedIndex = null, onObjectPathPointSelect, onObjectPathPointMove, onObjectPathPointInsert, onObjectPathGestureStart, onObjectPathGestureEnd, subjectTrack = null, onCameraChange, keyLight = null, minimal = false }) {
504
+ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, characters = [], characterPlacements = [], onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, pathStart = null, waypoints, activeWaypointId, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects = [], selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, cameraRailPoints = null, railDraw = false, onRailStroke, pathDraw = false, onPathStroke, objectPath = null, objectPathSelectedIndex = null, onObjectPathPointSelect, onObjectPathChange, onObjectPathMarkInsert, onObjectPathGestureStart, onObjectPathGestureEnd, subjectTrack = null, onCameraChange, keyLight = null, minimal = false }) {
505
+ characters = characters.map(entry => {
506
+ const placement = isProxyFigure(entry) && characterPlacements.find(view => view.id === entry.id);
507
+ return placement ? { ...entry, x: placement.position[0], z: placement.position[2], rot: placement.rot } : entry;
508
+ });
494
509
  const [drag, setDrag] = useState(null); // { id, mode }
495
510
  // live stroke while the rail is being drawn; world XZ, display only
496
511
  const [railStroke, setRailStroke] = useState(null);
512
+ // the route mark under the pointer, for the hover highlight
513
+ const [hoverMark, setHoverMark] = useState(null);
514
+ // The pointer is on the route where a double-click would still drop a dot.
515
+ const [hoverLine, setHoverLine] = useState(false);
497
516
  const rootRef = useRef();
498
517
  const camPos = useRef();
499
518
  const camRot = useRef();
@@ -520,8 +539,8 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
520
539
  // clears dragRef, so a dep that changes while dragging (charA.x does, on the
521
540
  // very first move) would kill the drag after one frame. Read live values
522
541
  // through a ref and keep the effect's deps stable.
523
- const latest = useRef({ objectPathPoints, objectPathSelectedIndex, onObjectPathPointSelect, onObjectPathPointMove, onObjectPathPointInsert, onObjectPathGestureStart, onObjectPathGestureEnd, characters, waypoints, onSelectWaypoint, onMoveWaypoint, onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke, pathDraw, onPathStroke, onCameraChange });
524
- latest.current = { objectPathPoints, objectPathSelectedIndex, onObjectPathPointSelect, onObjectPathPointMove, onObjectPathPointInsert, onObjectPathGestureStart, onObjectPathGestureEnd, characters, waypoints, onSelectWaypoint, onMoveWaypoint, onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke, pathDraw, onPathStroke, onCameraChange };
542
+ const latest = useRef({ objectPath, objectPathSelectedIndex, onObjectPathPointSelect, onObjectPathChange, onObjectPathMarkInsert, onObjectPathGestureStart, onObjectPathGestureEnd, characters, waypoints, onSelectWaypoint, onMoveWaypoint, onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke, pathDraw, onPathStroke, onCameraChange });
543
+ latest.current = { objectPath, objectPathSelectedIndex, onObjectPathPointSelect, onObjectPathChange, onObjectPathMarkInsert, onObjectPathGestureStart, onObjectPathGestureEnd, characters, waypoints, onSelectWaypoint, onMoveWaypoint, onMoveCharacter, onCharacterGestureStart, onWaypointGestureStart, onCameraGestureStart, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke, pathDraw, onPathStroke, onCameraChange };
525
544
 
526
545
  const targets = () => {
527
546
  const cast = latest.current.characters;
@@ -539,7 +558,9 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
539
558
  });
540
559
  });
541
560
  for (const object of latest.current.sceneObjects) {
542
- const { width, depth } = objectSize(object);
561
+ // An Empty has no footprint; it is picked by its marker's size.
562
+ const size = isEmptyObject(object) ? { width: EMPTY_MARKER_SIZE, depth: EMPTY_MARKER_SIZE } : objectSize(object);
563
+ const { width, depth } = size;
543
564
  out.push({
544
565
  id: `object:${object.id}`,
545
566
  objectId: object.id,
@@ -612,13 +633,15 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
612
633
  // "the puck doesn't move". Pick the NEAREST target instead.
613
634
  // A route's own points outrank pucks: they are smaller, they sit on
614
635
  // top of the deck, and a press near one is always meant for it.
615
- const pathPoints = latest.current.objectPathPoints;
616
- if (pathPoints && pathPoints.length >= 2) {
636
+ const route = latest.current.objectPath;
637
+ if (route && route.points?.length >= 2) {
617
638
  let nearest = null;
618
- for (let i = 0; i < pathPoints.length; i += 1) {
619
- const d = (p.x - pathPoints[i].x) ** 2 + (p.z - pathPoints[i].z) ** 2;
639
+ pathMarkFractions(route).forEach((t, i) => {
640
+ const at = pathPointAtFraction(route, t);
641
+ if (!at) return;
642
+ const d = (p.x - at.x) ** 2 + (p.z - at.z) ** 2;
620
643
  if (d < PATH_POINT_GRAB * PATH_POINT_GRAB && (!nearest || d < nearest.d)) nearest = { d, index: i };
621
- }
644
+ });
622
645
  if (nearest) return { id: `path:${nearest.index}`, mode: "pathPoint", origin: { pathIndex: nearest.index } };
623
646
  }
624
647
  const list = targets();
@@ -637,8 +660,6 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
637
660
 
638
661
  const onDown = (event) => {
639
662
  if (event.button !== 0) return;
640
- // Rail drawing owns the pointer: a stroke starts anywhere on the
641
- // deck, so puck picking would fight every stroke that crosses one.
642
663
  // Rail drawing and object-path drawing share one stroke gesture:
643
664
  // a stroke starts anywhere on the deck, so puck picking would
644
665
  // fight every stroke that crosses one.
@@ -670,8 +691,17 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
670
691
  // instantly (plan §6.4). Camera, character and waypoint grips
671
692
  // write separate state and must never open a scene transaction.
672
693
  if (grip.mode === "pathPoint") {
673
- // One undo entry per point drag, like every other plan gesture.
694
+ // One undo entry per mark drag, like every other plan gesture.
674
695
  latest.current.onObjectPathGestureStart?.();
696
+ // The bend this drag applies (the camera rail's own preparation):
697
+ // the route's points around the mark follow, fading out by the
698
+ // neighbouring marks.
699
+ const route = latest.current.objectPath;
700
+ const fractions = pathMarkFractions(route);
701
+ const base = pathPointAtFraction(route, fractions[grip.origin.pathIndex]);
702
+ const bend = base && prepareRailBend(route.points, base, fractions.map((t) => ({ t })), grip.origin.pathIndex, { maxControls: MAX_PATH_POINTS });
703
+ dragRef.current.bend = bend;
704
+ dragRef.current.pressed = { x: p.x, z: p.z };
675
705
  } else if (grip.origin.objectId) {
676
706
  dragRef.current.token = latest.current.onObjectMoveStart?.({
677
707
  owner: "plan",
@@ -702,11 +732,27 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
702
732
  }
703
733
  const p = toFloor(event);
704
734
  const grip = p && pick(p);
705
- host.style.cursor = !grip
706
- ? "default"
707
- : grip.mode === "turn"
708
- ? "ew-resize"
709
- : "grab";
735
+ const markIndex = grip?.mode === "pathPoint" ? grip.origin.pathIndex : null;
736
+ setHoverMark((current) => (current === markIndex ? current : markIndex));
737
+ // The route answers too: it brightens and the cursor says "copy" while a
738
+ // double-click here would still add a dot.
739
+ let addable = false;
740
+ const route = latest.current.objectPath;
741
+ if (!grip && p && route && route.points?.length >= 2) {
742
+ const fractions = pathMarkFractions(route);
743
+ if (fractions.length < MAX_PATH_MARKS) {
744
+ const best = nearestPathFraction(route, (point) => ({ x: point.x, y: point.z }), p.x, p.z);
745
+ addable = !!best && best.d <= PATH_POINT_GRAB && !fractions.some((entry) => Math.abs(entry - best.t) < MARK_GAP + 0.01);
746
+ }
747
+ }
748
+ setHoverLine((current) => (current === addable ? current : addable));
749
+ host.style.cursor = addable
750
+ ? "copy"
751
+ : !grip
752
+ ? "default"
753
+ : grip.mode === "turn"
754
+ ? "ew-resize"
755
+ : "grab";
710
756
  };
711
757
 
712
758
  const onMove = (event) => {
@@ -726,10 +772,18 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
726
772
  }
727
773
 
728
774
  if (grip.mode === "pathPoint") {
729
- latest.current.onObjectPathPointMove?.(grip.origin.pathIndex, {
730
- x: snap(p.x, ROOM_LIMIT),
731
- z: snap(p.z, ROOM_LIMIT),
775
+ if (!grip.bend) return;
776
+ // Floor only: a point's height is the scene's business, so y rides
777
+ // through untouched. The pull is snapped like every board move.
778
+ const dx = Math.round((p.x - grip.pressed.x) / 0.05) * 0.05;
779
+ const dz = Math.round((p.z - grip.pressed.z) / 0.05) * 0.05;
780
+ const moved = grip.bend.startControls.map((entry, i) => {
781
+ const weight = grip.bend.weights[i];
782
+ return weight > 0
783
+ ? { ...entry, x: THREE.MathUtils.clamp(entry.x + dx * weight, -ROOM_LIMIT, ROOM_LIMIT), z: THREE.MathUtils.clamp(entry.z + dz * weight, -ROOM_LIMIT, ROOM_LIMIT) }
784
+ : entry;
732
785
  });
786
+ latest.current.onObjectPathChange?.({ points: moved });
733
787
  return;
734
788
  }
735
789
 
@@ -831,27 +885,20 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
831
885
  // 3D scene uses, offered here because this is the board the route was
832
886
  // drawn on, so this is where a hand goes looking for it.
833
887
  const onDouble = (event) => {
834
- const points = latest.current.objectPathPoints;
835
- if (!points || points.length < 2) return;
888
+ const route = latest.current.objectPath;
889
+ if (!route || route.points?.length < 2) return;
836
890
  if (latest.current.railDraw || latest.current.pathDraw) return;
837
891
  const p = toFloor(event);
838
892
  if (!p) return;
839
- let best = null;
840
- for (let i = 0; i < points.length - 1; i += 1) {
841
- const a = points[i];
842
- const b = points[i + 1];
843
- const dx = b.x - a.x;
844
- const dz = b.z - a.z;
845
- const lenSq = dx * dx + dz * dz;
846
- const t = lenSq < 1e-9 ? 0 : Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq));
847
- const d = Math.hypot(a.x + dx * t - p.x, a.z + dz * t - p.z);
848
- if (!best || d < best.d) best = { d, index: i, t };
849
- }
850
- // Near the line, and not on top of a point that is already there.
851
- if (!best || best.d > PATH_POINT_GRAB || best.t < 0.04 || best.t > 0.96) return;
893
+ const fractions = pathMarkFractions(route);
894
+ if (fractions.length >= MAX_PATH_MARKS) return;
895
+ const best = nearestPathFraction(route, (point) => ({ x: point.x, y: point.z }), p.x, p.z);
896
+ // Near the line, and not on top of a mark that is already there.
897
+ if (!best || best.d > PATH_POINT_GRAB) return;
898
+ if (fractions.some((entry) => Math.abs(entry - best.t) < MARK_GAP + 0.01)) return;
852
899
  event.preventDefault();
853
900
  event.stopPropagation();
854
- latest.current.onObjectPathPointInsert?.(best.index, best.t);
901
+ latest.current.onObjectPathMarkInsert?.(best.t);
855
902
  };
856
903
 
857
904
  const onEscape = (event) => {
@@ -901,7 +948,7 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
901
948
  const puckId = listIndex === 0 ? "a" : listIndex === 1 ? "b" : `char:${entry.id}`;
902
949
  const color = listIndex === 0 ? SUBJECT_ONE_COLOR : SUBJECT_TWO_COLOR;
903
950
  return (
904
- <group key={entry.id} position={[entry.x, 0, entry.z]}>
951
+ <group key={entry.id} position={[entry.x, 0, entry.z]} userData={{ characterPuck: entry.id }}>
905
952
  <PlanLabel text={ko(`S${listIndex + 1}`, `인물 ${listIndex + 1}`)} color={color} offset={-(CAST_RING_OUTER + 0.3)} />
906
953
  <CastRing color={color} />
907
954
  <group rotation={[0, (entry.rot * Math.PI) / 180, 0]}>
@@ -931,7 +978,7 @@ export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, chara
931
978
  {railStroke && railStroke.length > 1 && <CameraRailLine points={railStroke} live />}
932
979
  {/* The selected object's travel path, drawn in its own colour so a
933
980
  prop's route never reads as the camera's rail. */}
934
- {!minimal && objectPathPoints && objectPathPoints.length > 1 && <ObjectPathLine points={objectPathPoints} selectedIndex={objectPathSelectedIndex} />}
981
+ {!minimal && objectPath && objectPath.points?.length > 1 && <ObjectPathLine path={objectPath} selectedIndex={objectPathSelectedIndex} hoverIndex={hoverMark} lit={hoverLine} />}
935
982
  {/* The sun on the floor plan: a gold disc + a stem toward the stage
936
983
  centre, so blocking can read where the light comes from without
937
984
  switching to the 3D scene. Not draggable here — the 3D puck owns
package/src/playground.js CHANGED
@@ -5,10 +5,10 @@
5
5
  // playground must never overwrite scenes they authored in the real studio.
6
6
 
7
7
  import { readProjectDocument } from "./project.js";
8
- import { SCENES_VERSION, migrateStageFrames } from "./scenes.js";
8
+ import { migrateScenesDocument as migrateToCurrentScenes } from "./scenes.js";
9
9
  import { resolveSceneParam } from "./starter-scenes.js";
10
10
 
11
- export const PLAYGROUND_EMBED = "playground";
11
+ const PLAYGROUND_EMBED = "playground";
12
12
  const PLAYGROUND_GLOBAL = "__cozyclayPlayground";
13
13
 
14
14
  export function isPlaygroundEmbed(search) {
@@ -42,9 +42,7 @@ export async function fetchPlaygroundProject(url) {
42
42
  const result = readProjectDocument(await response.text());
43
43
  if (!result.ok) return null;
44
44
  const source = result.project.scenesDocument;
45
- const document = Number.isInteger(source.version) && source.version < SCENES_VERSION
46
- ? { ...source, version: SCENES_VERSION, scenes: source.scenes.map((scene) => ({ ...scene, stage: migrateStageFrames(scene.stage) })) }
47
- : source;
45
+ const document = migrateToCurrentScenes(source);
48
46
  return { name: result.project.name, document };
49
47
  } catch {
50
48
  return null;
@@ -0,0 +1,77 @@
1
+ export const POSE_FADE_DEFAULT = 0.2;
2
+ export const POSE_FADE_STORAGE_KEY = "cozyclay.poseObjectOpacity";
3
+
4
+ export function readPoseFadeOpacity(storage = globalThis.localStorage) {
5
+ const value = Number(storage?.getItem?.(POSE_FADE_STORAGE_KEY));
6
+ return storage?.getItem?.(POSE_FADE_STORAGE_KEY) == null || !Number.isFinite(value)
7
+ ? POSE_FADE_DEFAULT
8
+ : Math.min(1, Math.max(0, value));
9
+ }
10
+
11
+ export function writePoseFadeOpacity(value, storage = globalThis.localStorage) {
12
+ try { storage?.setItem?.(POSE_FADE_STORAGE_KEY, String(value)); } catch { return; }
13
+ }
14
+
15
+ // Keyed by material: a material shared by several meshes is recorded once, so the restore is exact.
16
+ const originals = new WeakMap();
17
+ const noRaycast = () => {};
18
+
19
+ function materialsOf(mesh) {
20
+ return Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
21
+ }
22
+
23
+ // Image captures in Pose mode want the set as authored: the working fade is lifted while this is set.
24
+ let fadeSuspended = false;
25
+ function suspendPoseFade(suspended) {
26
+ fadeSuspended = suspended;
27
+ }
28
+
29
+ /** Runs `draw` with the working fade lifted. `sync` re-applies the fade to every prop; it runs
30
+ again afterwards because a demand frame loop may not render another frame on its own. */
31
+ export function withPoseFadeLifted(sync, draw) {
32
+ suspendPoseFade(true);
33
+ sync?.();
34
+ try {
35
+ return draw();
36
+ } finally {
37
+ suspendPoseFade(false);
38
+ sync?.();
39
+ }
40
+ }
41
+
42
+ /** `fade` is Pose mode's working fade (null = off; a faded prop also stops catching clicks);
43
+ `own` is the object's authored opacity (1 = solid). */
44
+ export function applyPoseFade(root, fade, own = 1) {
45
+ const workingFade = fadeSuspended || fade == null || fade >= 1 ? 1 : fade;
46
+ const opacity = workingFade * own;
47
+ const faded = opacity < 1;
48
+ const unpickable = workingFade < 1;
49
+ root.traverse((node) => {
50
+ if (!node.isMesh) return;
51
+ for (const material of materialsOf(node)) {
52
+ if (faded) {
53
+ if (!originals.has(material)) {
54
+ originals.set(material, { transparent: material.transparent, opacity: material.opacity, depthWrite: material.depthWrite });
55
+ }
56
+ const target = originals.get(material).opacity * opacity;
57
+ if (material.opacity !== target || !material.transparent || material.depthWrite) {
58
+ material.transparent = true;
59
+ material.opacity = target;
60
+ material.depthWrite = false;
61
+ material.needsUpdate = true;
62
+ }
63
+ } else if (originals.has(material)) {
64
+ Object.assign(material, originals.get(material));
65
+ originals.delete(material);
66
+ material.needsUpdate = true;
67
+ }
68
+ }
69
+ if (unpickable && node.raycast !== noRaycast) {
70
+ node.userData.poseFadeRaycast = node.raycast;
71
+ node.raycast = noRaycast;
72
+ } else if (!unpickable && node.raycast === noRaycast) {
73
+ node.raycast = node.userData.poseFadeRaycast;
74
+ delete node.userData.poseFadeRaycast;
75
+ }
76
+ });
77
+ }
@@ -0,0 +1,23 @@
1
+ .pose-rotation-readout {
2
+ transform: translate(10px, -100%);
3
+ display: grid;
4
+ gap: 3px;
5
+ padding: 5px 8px;
6
+ border-radius: 7px;
7
+ background: rgba(22, 22, 26, 0.72);
8
+ backdrop-filter: blur(6px);
9
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 4px 14px rgba(0, 0, 0, 0.18);
10
+ color: rgba(255, 255, 255, 0.86);
11
+ font: 500 11px/1.25 ui-sans-serif, system-ui, -apple-system, sans-serif;
12
+ font-variant-numeric: tabular-nums;
13
+ white-space: nowrap;
14
+ user-select: none;
15
+ }
16
+ .pose-rotation-axes { display: flex; gap: 9px; }
17
+ .pose-rotation-axes span { display: inline-flex; gap: 3px; opacity: 0.72; }
18
+ .pose-rotation-axes span.active { opacity: 1; }
19
+ .pose-rotation-readout i { font-style: normal; font-weight: 600; }
20
+ .pose-rotation-readout [data-axis="x"] i, .pose-rotation-delta[data-axis="x"] span { color: #ff6b5e; }
21
+ .pose-rotation-readout [data-axis="y"] i, .pose-rotation-delta[data-axis="y"] span { color: #7ee07a; }
22
+ .pose-rotation-readout [data-axis="z"] i, .pose-rotation-delta[data-axis="z"] span { color: #5c9dff; }
23
+ .pose-rotation-delta { display: flex; gap: 4px; font-size: 13px; font-weight: 600; color: #fff; }
@@ -0,0 +1,96 @@
1
+ import * as THREE from "three";
2
+
3
+ // Left side runs cool (blue arm, teal leg), right side warm (red arm, magenta leg); the pelvis is
4
+ // its own amber so the body's root reads apart from the spine above it.
5
+ export const HANDLE_COLORS = Object.freeze({
6
+ pelvis: "#ffb21e",
7
+ torso: "#2fd66b",
8
+ head: "#a37bff",
9
+ leftArm: "#3d8bff",
10
+ leftLeg: "#14c8e6",
11
+ rightArm: "#ff4d6a",
12
+ rightLeg: "#e44dff",
13
+ });
14
+ export const ROTATION_AXES = Object.freeze([
15
+ { axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff6b5e" },
16
+ { axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#7ee07a" },
17
+ { axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#5c9dff" },
18
+ ]);
19
+
20
+ const RING_BACK_OPACITY = 0.22;
21
+
22
+ // Fades the half of a ring that faces away from the camera (relative to the ring's centre), the way
23
+ // DCC rotate gizmos do, so the three rings stop crossing over the bone like a cage.
24
+ export function backFaceFade(material) {
25
+ material.onBeforeCompile = (shader) => {
26
+ shader.uniforms.ringBackOpacity = { value: RING_BACK_OPACITY };
27
+ shader.vertexShader = shader.vertexShader
28
+ .replace("#include <common>", "#include <common>\nvarying float vRingFront;")
29
+ .replace("#include <project_vertex>", `#include <project_vertex>
30
+ vec4 ringCentre = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
31
+ vRingFront = smoothstep(-0.02, 0.02, mvPosition.z - ringCentre.z);`);
32
+ shader.fragmentShader = shader.fragmentShader
33
+ .replace("#include <common>", "#include <common>\nvarying float vRingFront;\nuniform float ringBackOpacity;")
34
+ .replace("#include <opaque_fragment>", "#include <opaque_fragment>\ngl_FragColor.a *= mix(ringBackOpacity, 1.0, vRingFront);");
35
+ };
36
+ material.customProgramCacheKey = () => "pose-ring-backfade";
37
+ return material;
38
+ }
39
+
40
+ function handlePart(id = "") {
41
+ const match = /^(left|right)(.*)$/.exec(String(id));
42
+ if (match) return `${match[1]}${/knee|foot|leg|toe|hip/i.test(match[2]) ? "Leg" : "Arm"}`;
43
+ if (id === "hips") return "pelvis";
44
+ return id === "head" ? "head" : "torso";
45
+ }
46
+
47
+ export function handleColor(id) {
48
+ return HANDLE_COLORS[handlePart(id)];
49
+ }
50
+
51
+ const v0 = new THREE.Vector3();
52
+ const v1 = new THREE.Vector3();
53
+ const cross = new THREE.Vector3();
54
+
55
+ /** Signed angle (radians) that turns `start` into `current` about `axis`, both measured from `origin`. */
56
+ export function ringAngle(axis, origin, start, current) {
57
+ v0.subVectors(start, origin);
58
+ v1.subVectors(current, origin);
59
+ if (v0.lengthSq() < 1e-12 || v1.lengthSq() < 1e-12) return 0;
60
+ cross.crossVectors(v0, v1);
61
+ return Math.atan2(cross.dot(axis), v0.dot(v1));
62
+ }
63
+
64
+ const relative = new THREE.Quaternion();
65
+ const euler = new THREE.Euler();
66
+
67
+ /**
68
+ * Rotation away from the bind pose in the parent frame (the frame the rings turn in), as XYZ Euler
69
+ * degrees rounded to 0.1. The pose system applies a correction as `delta · bind`, so the delta is
70
+ * recovered as `current · bind⁻¹`.
71
+ */
72
+ export function rotationFromBindDeg(bindQuat, quat) {
73
+ relative.copy(bindQuat ?? relative.identity()).invert().premultiply(quat);
74
+ euler.setFromQuaternion(relative, "XYZ");
75
+ const round = (rad) => Math.round(THREE.MathUtils.radToDeg(rad) * 10) / 10 || 0;
76
+ return { x: round(euler.x), y: round(euler.y), z: round(euler.z) };
77
+ }
78
+
79
+ export function rotationReadout(deg, drag = null) {
80
+ const axes = ["x", "y", "z"].map((axis) => ({ axis, value: `${Math.round(deg[axis])}°`, active: drag?.axis === axis }));
81
+ if (!drag) return { delta: null, axes };
82
+ const value = THREE.MathUtils.radToDeg(drag.angle);
83
+ return { delta: { axis: drag.axis, value: `${value >= 0 ? "+" : "−"}${Math.abs(value).toFixed(1)}°` }, axes };
84
+ }
85
+
86
+ const bindCache = new WeakMap();
87
+
88
+ export function bindQuaternionOf(bone) {
89
+ if (bindCache.has(bone)) return bindCache.get(bone);
90
+ let node = bone;
91
+ while (node && !node.userData?.poseBind) node = node.parent;
92
+ const saved = node?.userData.poseBind.get(bone);
93
+ const quat = saved ? new THREE.Quaternion(saved.x, saved.y, saved.z, saved.w) : null;
94
+ bindCache.set(bone, quat);
95
+ return quat;
96
+ }