cozyclay 2.0.0 → 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 (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  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 +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  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 +801 -259
  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 +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  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 +43 -24
  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 +16 -3
  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 +89 -32
  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/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  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/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  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 +110 -20
  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/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
package/src/project.js CHANGED
@@ -16,8 +16,17 @@
16
16
 
17
17
  import { SCENES_VERSION } from "./scenes.js";
18
18
  import { ASSET_MAX_SOURCE_BYTES, assetIdForBytes, isAssetId, isImageAssetId, isMeshAssetId, meshIdForBytes, normalizeAsset, referencedAssetIds } from "./scene-assets.js";
19
+ import { ko } from "./locale.js";
19
20
 
20
- export const PROJECT_VERSION = 4;
21
+ export const PROJECT_VERSION = 5;
22
+ // A project is authored either as a storyboard (stills) or as animation
23
+ // (clips). v5 records the choice on the envelope; v2-v4 files are animation.
24
+ export const PREVIS_MODES = Object.freeze(["storyboard", "animation"]);
25
+ export const DEFAULT_PREVIS_MODE = "animation";
26
+
27
+ export function normalizePrevisMode(value) {
28
+ return PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE;
29
+ }
21
30
  export const PROJECT_EXTENSION = ".cclayproject";
22
31
  export const WORKFLOW_VERSION = 1;
23
32
  export const WORKFLOW_STORAGE_KEY = "cozyclay.workflow.v1";
@@ -32,8 +41,6 @@ export const PROJECT_MAX_RESOURCE_BYTES = 256 * 1024 * 1024;
32
41
  // A motion id is the SHA-256 of the raw NPZ bytes (see motion-resources.js).
33
42
  const MOTION_ID_PATTERN = /^[0-9a-f]{64}$/i;
34
43
 
35
- /* ---------------------------- workflow graph --------------------------- */
36
-
37
44
  function plainRecord(value) {
38
45
  if (!value || typeof value !== "object") return false;
39
46
  const prototype = Object.getPrototypeOf(value);
@@ -132,11 +139,14 @@ export function normalizeWorkflowGraph(value) {
132
139
  return { version: WORKFLOW_VERSION, nodes, edges };
133
140
  }
134
141
 
135
- /** Read the browser-local workflow draft used by the standalone canvas. */
136
- export function loadWorkflowGraph(storage = globalThis.localStorage) {
142
+ /** Read the browser-local workflow draft used by the standalone canvas.
143
+ * A corrupt draft reads as an empty graph unless `strict`, which rethrows the
144
+ * parse error so a caller about to write can refuse to overwrite it. */
145
+ export function loadWorkflowGraph(storage = globalThis.localStorage, { strict = false } = {}) {
137
146
  try {
138
147
  return normalizeWorkflowGraph(JSON.parse(storage?.getItem(WORKFLOW_STORAGE_KEY) || "null"));
139
- } catch {
148
+ } catch (error) {
149
+ if (strict) throw error;
140
150
  return createWorkflowGraph();
141
151
  }
142
152
  }
@@ -155,8 +165,6 @@ export function storeWorkflowGraph(graph, storage = globalThis.localStorage) {
155
165
  return normalized;
156
166
  }
157
167
 
158
- /* ------------------------------- envelope ------------------------------- */
159
-
160
168
  function asArrayBuffer(bytes) {
161
169
  if (bytes instanceof ArrayBuffer) return bytes;
162
170
  if (ArrayBuffer.isView(bytes)) return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
@@ -362,13 +370,13 @@ function readEmbeddedMotions(value, report) {
362
370
  }
363
371
 
364
372
  /**
365
- * Build the v4 envelope. Images are embedded when a scene or workflow
373
+ * Build the v5 envelope. Images are embedded when a scene or workflow
366
374
  * assetRef references them; motions are embedded as given (deduped by
367
375
  * motionId, first record wins) because the caller already knows which ones
368
376
  * the project uses. Throws `resources-too-large` when one motion or the whole
369
377
  * manifest exceeds its budget.
370
378
  */
371
- export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, assets, motions, savedAt, workflow }) {
379
+ export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, assets, motions, savedAt, workflow }) {
372
380
  const assetRecords = new Map();
373
381
  for (const record of Array.isArray(assets) ? assets : []) {
374
382
  const asset = embeddedAsset(record);
@@ -404,6 +412,7 @@ export function createProjectDocument({ scenesDocument, workspaceLayout, customP
404
412
  kind: "project",
405
413
  version: PROJECT_VERSION,
406
414
  name: typeof name === "string" && name.trim() ? name.trim() : "Untitled",
415
+ previsMode: normalizePrevisMode(previsMode),
407
416
  ...(Number.isFinite(savedAt) && savedAt > 0 ? { savedAt } : {}),
408
417
  scenes: scenesDocument,
409
418
  workspace: workspaceLayout ?? null,
@@ -413,12 +422,43 @@ export function createProjectDocument({ scenesDocument, workspaceLayout, customP
413
422
  };
414
423
  }
415
424
 
425
+ /** Convert a normalized storyboard project into a fresh animation envelope.
426
+ * The source remains untouched: still shot records are copied with their
427
+ * ranges, camera keys, captions, cast overrides and stylized asset references.
428
+ */
429
+ export function buildAnimationProjectFromStoryboard(project) {
430
+ const scenesDocument = structuredClone(project?.scenesDocument ?? { version: SCENES_VERSION, activeSceneId: null, scenes: [] });
431
+ scenesDocument.scenes = (scenesDocument.scenes ?? []).map((scene) => ({
432
+ ...scene,
433
+ shotDocument: scene.shotDocument && typeof scene.shotDocument === "object"
434
+ ? {
435
+ ...scene.shotDocument,
436
+ shots: Array.isArray(scene.shotDocument.shots)
437
+ ? scene.shotDocument.shots.map((shot) => ({ ...shot, ...(shot.kind === "still" ? { kind: "clip" } : {}) }))
438
+ : scene.shotDocument.shots,
439
+ }
440
+ : scene.shotDocument,
441
+ }));
442
+ return createProjectDocument({
443
+ scenesDocument,
444
+ workspaceLayout: structuredClone(project?.workspaceLayout ?? null),
445
+ customPoses: structuredClone(project?.customPoses ?? []),
446
+ name: `${project?.name || "Untitled"} - ${ko("Animation", "애니메이션")}`,
447
+ previsMode: "animation",
448
+ assets: project?.assets ?? [],
449
+ motions: project?.motions ?? [],
450
+ workflow: structuredClone(project?.workflow ?? createWorkflowGraph()),
451
+ savedAt: Date.now(),
452
+ });
453
+ }
454
+
416
455
  /**
417
456
  * Parse + validate a project file. Returns { ok, reason?, warnings?,
418
457
  * problems?, project? }. Invalid embedded records are skipped, each one
419
458
  * reported as a warning string and a `{ kind, id, code, message }` problem.
420
459
  * v2/v3 files carry images at the top-level `assets`; v4 moves everything
421
- * under `resources`. Older files are read as-is, never rewritten.
460
+ * under `resources`; v5 adds the top-level `previsMode` (absent or unknown
461
+ * reads as the default). Older files are read as-is, never rewritten.
422
462
  */
423
463
  export function readProjectDocument(raw) {
424
464
  let parsed;
@@ -452,6 +492,7 @@ export function readProjectDocument(raw) {
452
492
  problems,
453
493
  project: {
454
494
  name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : "Untitled",
495
+ previsMode: normalizePrevisMode(parsed.previsMode),
455
496
  savedAt: Number.isFinite(parsed.savedAt) && parsed.savedAt > 0 ? parsed.savedAt : null,
456
497
  scenesDocument: { version: scenes.version, activeSceneId: scenes.activeSceneId ?? null, scenes: scenes.scenes },
457
498
  workspaceLayout: parsed.workspace && typeof parsed.workspace === "object" ? parsed.workspace : null,
@@ -465,8 +506,6 @@ export function readProjectDocument(raw) {
465
506
  };
466
507
  }
467
508
 
468
- /* ------------------------- file system access --------------------------- */
469
-
470
509
  export function hasFileSystemAccess() {
471
510
  return typeof window !== "undefined" && typeof window.showSaveFilePicker === "function" && typeof window.showOpenFilePicker === "function";
472
511
  }
@@ -502,8 +541,6 @@ export async function readProjectFile(handle) {
502
541
  return { name: file.name.replace(/\.cclayproject$|\.json$/i, ""), text: await file.text(), savedAt: file.lastModified };
503
542
  }
504
543
 
505
- /* --------------------------- fallback (no FS API) ------------------------ */
506
-
507
544
  export function downloadProjectFallback(serialized, name) {
508
545
  const blob = new Blob([serialized], { type: "application/json" });
509
546
  const url = URL.createObjectURL(blob);
@@ -529,8 +566,6 @@ export function openProjectFallback() {
529
566
  });
530
567
  }
531
568
 
532
- /* ------------------------------- IDB handle ------------------------------ */
533
-
534
569
  function openIdb() {
535
570
  return new Promise((resolve, reject) => {
536
571
  const request = indexedDB.open(IDB_NAME, 1);
@@ -605,27 +640,23 @@ export function loadProjectSession(storage = globalThis.localStorage) {
605
640
  const value = JSON.parse(storage?.getItem(PROJECT_SESSION_KEY) || "null");
606
641
  if (!value || typeof value !== "object") return null;
607
642
  const name = typeof value.name === "string" ? value.name.trim() : "";
608
- return name ? { name, updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
643
+ return name ? { name, previsMode: normalizePrevisMode(value.previsMode), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
609
644
  } catch {
610
645
  return null;
611
646
  }
612
647
  }
613
648
 
614
- export function storeProjectSession(name, storage = globalThis.localStorage) {
649
+ export function storeProjectSession(name, previsMode = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
615
650
  const normalized = typeof name === "string" ? name.trim() : "";
616
651
  if (!normalized) return false;
617
652
  try {
618
- storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, updatedAt: Date.now() }));
653
+ storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(previsMode), updatedAt: Date.now() }));
619
654
  return true;
620
655
  } catch {
621
656
  return false;
622
657
  }
623
658
  }
624
659
 
625
- export function clearProjectSession(storage = globalThis.localStorage) {
626
- try { storage?.removeItem(PROJECT_SESSION_KEY); } catch { /* private mode */ }
627
- }
628
-
629
660
  async function idbSet(key, value) {
630
661
  try {
631
662
  const db = await openIdb();
package/src/props.jsx CHANGED
@@ -12,12 +12,16 @@
12
12
  import { useEffect, useMemo, useRef, useState } from "react";
13
13
  import { useFrame } from "@react-three/fiber";
14
14
  import { objectTransformAt } from "./object-path.js";
15
+ import { sceneObjectCarryMatrixAt, sceneObjectTravelMatrixAt, travelPose } from "./object-travel.js";
15
16
  import * as THREE from "three";
16
17
  import { GIZMO_LAYER } from "./dualview.jsx";
17
- import { CUTOUT_KIND, MESH_KIND } from "./scene-objects.js";
18
+ import { CUTOUT_KIND, MESH_KIND, EMPTY_KIND, EMPTY_MARKER_SIZE, SCULPT_KIND } from "./scene-objects.js";
19
+ import { buildSculptGroup, disposeSculptGroup } from "./sculpt-geometry.js";
18
20
  import { subscribeToAssetTexture } from "./scene-asset-cache.js";
19
21
  import { subscribeToMeshScene } from "./scene-mesh-cache.js";
20
22
  import { cloneMeshGraph } from "./mesh-graph-clone.js";
23
+ import { applyPoseFade } from "./pose-fade.js";
24
+ import { coplanarDepthRanks } from "./coplanar-depth.js";
21
25
 
22
26
  const CLAY_CAR = "#d98770";
23
27
  const CLAY_CAR_TOP = "#e49a84";
@@ -56,27 +60,22 @@ export function Car({ position = [0, 0, 0], rotY = 0, color = CLAY_CAR, topColor
56
60
  : { color: topColor, roughness: 0.5, metalness: 0.2 };
57
61
  return (
58
62
  <group position={position} rotation={[0, rotY, 0]}>
59
- {/* lower body */}
60
63
  <mesh position={[0, 0.62, 0]}>
61
64
  <boxGeometry args={[1.78, 0.62, 4.45]} />
62
65
  <meshStandardMaterial {...bodyMaterial} />
63
66
  </mesh>
64
- {/* cabin: centred over the wheelbase, not stacked on the tail */}
65
67
  <mesh position={[0, 1.12, -0.15]}>
66
68
  <boxGeometry args={[1.58, 0.5, 2.2]} />
67
69
  <meshStandardMaterial {...topMaterial} />
68
70
  </mesh>
69
- {/* greenhouse glass band */}
70
71
  <mesh position={[0, 1.14, -0.15]}>
71
72
  <boxGeometry args={[1.62, 0.3, 1.9]} />
72
73
  <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
73
74
  </mesh>
74
- {/* windshield slope */}
75
75
  <mesh position={[0, 1.05, 0.95]} rotation={[0.5, 0, 0]}>
76
76
  <boxGeometry args={[1.56, 0.42, 0.08]} />
77
77
  <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
78
78
  </mesh>
79
- {/* rear glass slope */}
80
79
  <mesh position={[0, 1.05, -1.25]} rotation={[-0.55, 0, 0]}>
81
80
  <boxGeometry args={[1.56, 0.42, 0.08]} />
82
81
  <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
@@ -98,7 +97,6 @@ export function SmallPlane({ position = [0, 0, 0], rotY = 0, autoColor = undefin
98
97
  autoColor ? autoFlat(autoColor) : { color, roughness, metalness };
99
98
  return (
100
99
  <group position={position} rotation={[0, rotY, 0]}>
101
- {/* fuselage and tapered nose */}
102
100
  <mesh position={[0, 0.76, 0]} rotation={[Math.PI / 2, 0, 0]}>
103
101
  <cylinderGeometry args={[0.23, 0.31, 2.75, 16]} />
104
102
  <meshStandardMaterial {...mat(CLAY_PLANE, 0.58, 0.18)} />
@@ -108,7 +106,6 @@ export function SmallPlane({ position = [0, 0, 0], rotY = 0, autoColor = undefin
108
106
  <meshStandardMaterial {...mat(CLAY_PLANE_TRIM, 0.52, 0.2)} />
109
107
  </mesh>
110
108
 
111
- {/* main wing and tail plane */}
112
109
  <mesh position={[0, 0.73, 0.15]}>
113
110
  <boxGeometry args={[3.4, 0.09, 0.58]} />
114
111
  <meshStandardMaterial {...mat(CLAY_PLANE, 0.62, 0.14)} />
@@ -122,13 +119,11 @@ export function SmallPlane({ position = [0, 0, 0], rotY = 0, autoColor = undefin
122
119
  <meshStandardMaterial {...mat(CLAY_PLANE, 0.62, 0.12)} />
123
120
  </mesh>
124
121
 
125
- {/* cockpit canopy */}
126
122
  <mesh position={[0, 1.02, 0.42]} scale={[0.62, 0.46, 0.9]}>
127
123
  <sphereGeometry args={[0.42, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2]} />
128
124
  <meshStandardMaterial color={CLAY_GLASS} roughness={0.16} metalness={0.55} />
129
125
  </mesh>
130
126
 
131
- {/* propeller hub and blades */}
132
127
  <group position={[0, 0.76, 1.86]}>
133
128
  <mesh rotation={[Math.PI / 2, 0, 0]}>
134
129
  <cylinderGeometry args={[0.11, 0.11, 0.2, 12]} />
@@ -140,7 +135,6 @@ export function SmallPlane({ position = [0, 0, 0], rotY = 0, autoColor = undefin
140
135
  </mesh>
141
136
  </group>
142
137
 
143
- {/* simple landing gear */}
144
138
  {[-0.55, 0.55].map((x) => (
145
139
  <group key={x} position={[x, 0.2, 0.15]}>
146
140
  <mesh position={[0, 0.24, 0]} rotation={[0, 0, x < 0 ? -0.35 : 0.35]}>
@@ -210,12 +204,20 @@ function autoFlat(hex) {
210
204
  return { color: hex, roughness: 1, metalness: 0, emissive: hex, emissiveIntensity: 0.4 };
211
205
  }
212
206
 
213
- function Primitive({ kind, color, autoColor }) {
207
+ // Parts of an assembly can share a plane with an earlier part (hood top and
208
+ // grille top). The depth test can't order them and the winner flips as the
209
+ // camera moves, so a ranked part is pulled toward the camera by `rank` steps
210
+ // (negative = nearer). Rank 0 gets nothing: those materials stay untouched.
211
+ function depthRankProps(rank) {
212
+ return rank > 0 ? { polygonOffset: true, polygonOffsetFactor: -rank, polygonOffsetUnits: -rank } : null;
213
+ }
214
+
215
+ function Primitive({ kind, color, autoColor, depthRank = 0 }) {
214
216
  const side = kind === "plane" ? THREE.DoubleSide : THREE.FrontSide;
215
217
  const material = autoColor ? (
216
- <meshStandardMaterial {...autoFlat(autoColor)} side={side} />
218
+ <meshStandardMaterial {...autoFlat(autoColor)} side={side} {...depthRankProps(depthRank)} />
217
219
  ) : (
218
- <meshStandardMaterial color={color} roughness={0.82} side={side} />
220
+ <meshStandardMaterial color={color} roughness={0.82} side={side} {...depthRankProps(depthRank)} />
219
221
  );
220
222
  if (kind === "sphere") {
221
223
  return (
@@ -311,8 +313,8 @@ function disposeOwnedMaterials(root) {
311
313
  *
312
314
  * Skinned graphs go through `cloneMeshGraph` so a Mixamo-as-statue keeps
313
315
  * its bind pose. Clay replaces materials on THIS clone only, so a second
314
- * instance of the same file can keep the textures from the disk. Auto-color
315
- * also clones: the cached graph is shared, and a viewport tint must not leak.
316
+ * instance of the same file can keep the textures from the disk. Every clone
317
+ * owns its materials, so auto-color tints and opacity fades never leak.
316
318
  */
317
319
  function instantiateMesh(source, object) {
318
320
  const root = cloneMeshGraph(source);
@@ -338,18 +340,16 @@ function instantiateMesh(source, object) {
338
340
  node.receiveShadow = true;
339
341
  if (clay) {
340
342
  const make = () => new THREE.MeshStandardMaterial({ color: clayColor, roughness: 0.9, metalness: 0 });
343
+ for (const material of Array.isArray(node.material) ? node.material : [node.material]) material?.dispose?.();
341
344
  node.material = Array.isArray(node.material) ? node.material.map(() => make()) : make();
342
345
  node.userData.clayOwned = true;
343
346
  return;
344
347
  }
345
348
  if (!object.autoColor || !node.material) return;
346
- const tint = (material) => {
347
- const next = material.clone();
348
- if (next.color) next.color.set(object.autoColor);
349
- return next;
350
- };
351
- node.material = Array.isArray(node.material) ? node.material.map(tint) : tint(node.material);
352
- node.userData.instanceOwned = true;
349
+ // cloneMeshGraph already gave this instance its own materials.
350
+ for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
351
+ if (material.color) material.color.set(object.autoColor);
352
+ }
353
353
  });
354
354
  return root;
355
355
  }
@@ -420,21 +420,92 @@ function Cutout({ object }) {
420
420
  );
421
421
  }
422
422
 
423
+ /**
424
+ * A sculpt: the clay parts its recipe describes, rebuilt only when the recipe
425
+ * or the look changes. Each part keeps its own clay colour; auto-colour paints
426
+ * the whole sculpt flat like any other prop, because it is one object.
427
+ */
428
+ function Sculpt({ object, depthRank = 0 }) {
429
+ const { recipe, autoColor } = object;
430
+ const group = useMemo(
431
+ () => buildSculptGroup(recipe, {
432
+ materialFor: (part) => new THREE.MeshStandardMaterial({
433
+ ...(autoColor ? autoFlat(autoColor) : { color: part.color, roughness: 0.82, metalness: 0 }),
434
+ ...depthRankProps(depthRank),
435
+ }),
436
+ }),
437
+ [recipe, autoColor, depthRank],
438
+ );
439
+ useEffect(() => () => disposeSculptGroup(group), [group]);
440
+ return <primitive object={group} />;
441
+ }
442
+
423
443
  const PRIMITIVE_KINDS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "plane"]);
424
444
 
425
- function SceneObjectContent({ object }) {
445
+ // X red, Y green, Z blue: the DCC convention, so a turned Empty reads its own
446
+ // orientation off the marker.
447
+ const EMPTY_AXES = [
448
+ { to: [1, 0, 0], color: "#e5594f" },
449
+ { to: [0, 1, 0], color: "#5fb95f" },
450
+ { to: [0, 0, 1], color: "#4f86e5" },
451
+ ];
452
+
453
+ /**
454
+ * An Empty's viewport presence: a three-axis cross and a pick volume, and
455
+ * nothing else. It is editor furniture, so every node sits on GIZMO_LAYER —
456
+ * the layer the shot camera, the preview card, PlayView, the ink prepass and
457
+ * the recorder all drop — and it casts no shadow because it has no mesh to
458
+ * cast one. The pick volume is invisible (never drawn) but still raycasts; the
459
+ * object picker finds it by `userData.emptyPick` on the gizmo-layer pass.
460
+ */
461
+ function EmptyMarker() {
462
+ const half = EMPTY_MARKER_SIZE / 2;
463
+ const axes = useMemo(() => {
464
+ const positions = [];
465
+ const colors = [];
466
+ const color = new THREE.Color();
467
+ for (const axis of EMPTY_AXES) {
468
+ positions.push(-axis.to[0] * half, -axis.to[1] * half, -axis.to[2] * half, axis.to[0] * half, axis.to[1] * half, axis.to[2] * half);
469
+ color.set(axis.color);
470
+ colors.push(color.r, color.g, color.b, color.r, color.g, color.b);
471
+ }
472
+ const geometry = new THREE.BufferGeometry();
473
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
474
+ geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
475
+ return geometry;
476
+ }, [half]);
477
+ useEffect(() => () => axes.dispose(), [axes]);
478
+ const onLayer = (node) => {
479
+ if (node) node.layers.set(GIZMO_LAYER);
480
+ };
481
+ return (
482
+ <group userData={{ emptyMarker: true }}>
483
+ <lineSegments ref={onLayer} geometry={axes} renderOrder={997} frustumCulled={false}>
484
+ <lineBasicMaterial vertexColors depthTest={false} depthWrite={false} transparent opacity={0.95} />
485
+ </lineSegments>
486
+ <mesh ref={onLayer} visible={false} userData={{ emptyPick: true }}>
487
+ <sphereGeometry args={[half * 1.25, 12, 8]} />
488
+ <meshBasicMaterial />
489
+ </mesh>
490
+ </group>
491
+ );
492
+ }
493
+
494
+ function SceneObjectContent({ object, depthRank = 0 }) {
426
495
  // `autoColor` is the viewport-only display color the auto-color mode stamps
427
496
  // onto the DISPLAYED object (App's displaySceneObjects); the authored
428
497
  // `color` is untouched underneath. Cutouts stay out: tinting a photo
429
498
  // standee destroys the one thing it is for. Imported meshes take the same
430
499
  // override as a cube — file materials are cloned and tinted per instance.
431
500
  const { renderer, color, autoColor } = object;
501
+ if (renderer === EMPTY_KIND) return <EmptyMarker />;
432
502
  if (renderer === CUTOUT_KIND) return <Cutout object={object} />;
433
503
  if (renderer === MESH_KIND) return <ImportedMesh object={object} />;
504
+ if (renderer === SCULPT_KIND) return <Sculpt object={object} depthRank={depthRank} />;
434
505
  if (renderer === "car") return <Car color={autoColor ?? color} autoColor={autoColor} />;
435
506
  if (renderer === "small-plane") return <SmallPlane autoColor={autoColor} />;
436
507
  if (renderer === "chair") return <Chair autoColor={autoColor} />;
437
- if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} autoColor={autoColor} />;
508
+ if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} autoColor={autoColor} depthRank={depthRank} />;
438
509
  return null;
439
510
  }
440
511
 
@@ -444,9 +515,12 @@ function SceneObjectContent({ object }) {
444
515
  * object instead of a selection.
445
516
  */
446
517
  function SelectionBox({ object }) {
447
- const height = Math.max(object.height ?? 1, 0.08);
448
- const width = object.footprint?.width ?? 1;
449
- const depth = object.footprint?.depth ?? 1;
518
+ // An Empty has no extent, so its cage is the marker's own cube, centred on
519
+ // the origin (a prop's cage stands on it).
520
+ const empty = object.renderer === EMPTY_KIND;
521
+ const height = empty ? EMPTY_MARKER_SIZE : Math.max(object.height ?? 1, 0.08);
522
+ const width = empty ? EMPTY_MARKER_SIZE : object.footprint?.width ?? 1;
523
+ const depth = empty ? EMPTY_MARKER_SIZE : object.footprint?.depth ?? 1;
450
524
  const edges = useMemo(
451
525
  () => new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 1.04, height * 1.04, depth * 1.04)),
452
526
  [width, height, depth],
@@ -463,7 +537,7 @@ function SelectionBox({ object }) {
463
537
  if (mesh) mesh.layers.set(GIZMO_LAYER);
464
538
  }}
465
539
  geometry={edges}
466
- position={[0, height / 2, 0]}
540
+ position={[0, empty ? 0 : height / 2, 0]}
467
541
  renderOrder={998}
468
542
  >
469
543
  <lineBasicMaterial color="#e7b557" transparent opacity={0.95} depthTest={false} depthWrite={false} />
@@ -482,7 +556,7 @@ const placeLocal = new THREE.Matrix4();
482
556
  const placeWorld = new THREE.Matrix4();
483
557
  const placeFrame = new THREE.Matrix4();
484
558
 
485
- function SceneObject({ object, selected, frameRef = null, take = null, attachFrameRef = null, registryRef = null }) {
559
+ function SceneObject({ object, selected, frameRef = null, take = null, attachFrameRef = null, registryRef = null, travelLookupRef = null, fadeOpacity = null, depthRank = 0 }) {
486
560
  const groupRef = useRef(null);
487
561
  const attach = object.attach ?? null;
488
562
  // An object on a travel path — or one CARRIED by a character — is placed
@@ -496,17 +570,27 @@ function SceneObject({ object, selected, frameRef = null, take = null, attachFra
496
570
  const group = groupRef.current;
497
571
  if (!group) return;
498
572
  const frame = frameRef?.current ?? 0;
499
- if (attach || object.path) {
573
+ // A record under a routed parent travels with it (object-travel.js), and
574
+ // so does a routed record itself: one answer from the authored records.
575
+ const travelled = frameRef && !attach && travelLookupRef?.current
576
+ ? sceneObjectTravelMatrixAt(travelLookupRef.current, object.id, frame, take ?? {}, placeWorld)
577
+ : null;
578
+ if (travelled) {
579
+ travelled.decompose(group.position, group.quaternion, group.scale);
580
+ } else if (attach || object.path) {
500
581
  // The authored numbers first. While attached they are the prop's LOCAL
501
582
  // transform in the attach frame; otherwise they are already world.
502
583
  placePos.set(object.x, object.y ?? 0, object.z);
503
- placeEuler.set((object.rotX ?? 0) * DEG, object.rot * DEG, (object.rotZ ?? 0) * DEG);
584
+ placeEuler.set((object.rotX ?? 0) * DEG, object.rot * DEG, (object.rotZ ?? 0) * DEG, "XYZ");
504
585
  placeScale.set(object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1);
505
586
  if (frameRef && object.path) {
506
587
  const at = objectTransformAt(object, frame, take ?? {});
507
588
  if (at) {
508
589
  placePos.set(at.x, at.y, at.z);
509
- if (at.rot !== null) placeEuler.y = at.rot * DEG;
590
+ // The same pose object-travel gives a routed record: yaw, then
591
+ // the body's own pitch, then its roll (+ the route's lean).
592
+ const pose = travelPose(object, at);
593
+ placeEuler.set(pose.rotX * DEG, pose.rot * DEG, pose.rotZ * DEG, "YXZ");
510
594
  }
511
595
  }
512
596
  // A missing rig (the character left the cast, or its model has not
@@ -530,8 +614,9 @@ function SceneObject({ object, selected, frameRef = null, take = null, attachFra
530
614
  // the store only knows the authored one — and while attached the authored
531
615
  // one is not even in world space. Harmless in normal use.
532
616
  if (typeof window !== "undefined") {
533
- (window.__cclayPropWorld ??= {})[object.id] = { x: group.position.x, y: group.position.y, z: group.position.z, frame };
617
+ (window.__cclayPropWorld ??= {})[object.id] = { x: group.position.x, y: group.position.y, z: group.position.z, quat: { x: group.quaternion.x, y: group.quaternion.y, z: group.quaternion.z, w: group.quaternion.w }, frame };
534
618
  }
619
+ applyPoseFade(group, fadeOpacity, object.opacity ?? 1);
535
620
  };
536
621
  useFrame(place);
537
622
  // Two things the App needs to reach imperatively, registered per prop: a
@@ -570,7 +655,7 @@ function SceneObject({ object, selected, frameRef = null, take = null, attachFra
570
655
  // the viewport picker walks up from a hit mesh to find this id
571
656
  userData={{ sceneObjectId: object.id }}
572
657
  >
573
- <SceneObjectContent object={object} />
658
+ <SceneObjectContent object={object} depthRank={depthRank} />
574
659
  {selected && <SelectionBox object={object} />}
575
660
  </group>
576
661
  );
@@ -584,8 +669,16 @@ function SceneObject({ object, selected, frameRef = null, take = null, attachFra
584
669
  * loop and would otherwise record props one frame stale; `worldRef` with a
585
670
  * "where is this prop" lookup, so a reparent converts from the transform on
586
671
  * screen instead of from a second computation of it. */
587
- export function SetProps({ objects = [], selectedId = null, frameRef = null, take = null, attachFrameRef = null, syncRef = null, worldRef = null }) {
672
+ export function SetProps({ objects = [], authoredObjects = null, selectedId = null, frameRef = null, take = null, attachFrameRef = null, syncRef = null, worldRef = null, fadeOpacity = null }) {
588
673
  const registryRef = useRef(null);
674
+ // The authored records by id: a child's travel is its routed parent's
675
+ // route against the parent's AUTHORED pose, which the display copies in
676
+ // `objects` no longer carry once they have been animated.
677
+ const travelLookupRef = useRef(null);
678
+ travelLookupRef.current = useMemo(() => (authoredObjects ? new Map(authoredObjects.map((object) => [object.id, object])) : null), [authoredObjects]);
679
+ // Coplanar-face ranks come from the AUTHORED records: a routed group moves
680
+ // rigidly, so what shares a plane at rest shares it on every frame.
681
+ const depthRanks = useMemo(() => coplanarDepthRanks(authoredObjects ?? objects), [authoredObjects, objects]);
589
682
  if (!registryRef.current) registryRef.current = new Map();
590
683
  if (syncRef) syncRef.current = () => { for (const entry of registryRef.current.values()) entry.place(); };
591
684
  if (worldRef) worldRef.current = (id, out) => registryRef.current.get(id)?.world(out) ?? null;
@@ -600,8 +693,56 @@ export function SetProps({ objects = [], selectedId = null, frameRef = null, tak
600
693
  take={take}
601
694
  attachFrameRef={attachFrameRef}
602
695
  registryRef={registryRef}
696
+ travelLookupRef={travelLookupRef}
697
+ fadeOpacity={fadeOpacity}
698
+ depthRank={depthRanks.get(object.id) ?? 0}
603
699
  />
604
700
  ))}
605
701
  </group>
606
702
  );
607
703
  }
704
+
705
+ const carryMatrix = new THREE.Matrix4();
706
+
707
+ /**
708
+ * A group that rides a scene object's travel: whatever is inside it (a
709
+ * character grouped under a car) is moved and turned with the object's route,
710
+ * on top of its own transform and animation. Placed imperatively from the
711
+ * frame ref for the same reason a routed prop is — the offscreen export
712
+ * advances frames without a re-render — and registered in `registryRef` so the
713
+ * recorder can place every carrier before it renders a frame.
714
+ *
715
+ * `objectsRef.current` holds the AUTHORED records by id; with no object, or an
716
+ * object that does not travel, the group stays at identity.
717
+ */
718
+ export function ObjectCarrier({ objectId = null, objectsRef, frameRef = null, take = null, registryRef = null, children }) {
719
+ const groupRef = useRef(null);
720
+ const place = () => {
721
+ const group = groupRef.current;
722
+ if (!group) return;
723
+ const carried = objectId && objectsRef?.current
724
+ ? sceneObjectCarryMatrixAt(objectsRef.current, objectId, frameRef?.current ?? 0, take ?? {}, carryMatrix)
725
+ : null;
726
+ if (carried) carried.decompose(group.position, group.quaternion, group.scale);
727
+ else {
728
+ group.position.set(0, 0, 0);
729
+ group.quaternion.identity();
730
+ group.scale.set(1, 1, 1);
731
+ }
732
+ };
733
+ // Ahead of every priority-0 placement: a prop held by a rider reads its
734
+ // bone's world matrix through this group, so the carry must land first or
735
+ // the prop trails the hand by one frame. Mount order alone does not hold
736
+ // (SetProps mounts before the cast; a rig remount moves to the back).
737
+ useFrame(place, -1);
738
+ const placeRef = useRef(place);
739
+ placeRef.current = place;
740
+ useEffect(() => {
741
+ if (!registryRef) return undefined;
742
+ const registry = registryRef.current;
743
+ const entry = () => placeRef.current();
744
+ registry.add(entry);
745
+ return () => { registry.delete(entry); };
746
+ }, [registryRef]);
747
+ return <group ref={groupRef}>{children}</group>;
748
+ }
@@ -0,0 +1,69 @@
1
+ import { memo, useEffect, useMemo } from "react";
2
+ import * as THREE from "three";
3
+ import { defaultCharacterTint } from "./app-stage.jsx";
4
+
5
+ const BODY_RADIUS = 0.22;
6
+ const BODY_HEIGHT = 1.45;
7
+ const HEAD_RADIUS = 0.12;
8
+
9
+ export function proxyYaw(rot, posture = "stand") {
10
+ return (rot * Math.PI) / 180 + (posture === "lie" ? Math.PI : 0);
11
+ }
12
+
13
+ export const ProxyFigure = memo(function ProxyFigure({
14
+ position,
15
+ rot,
16
+ tint,
17
+ scale = 1,
18
+ posture = "stand",
19
+ pickId,
20
+ selected,
21
+ }) {
22
+ const bodyHeight = posture === "sit" ? BODY_HEIGHT * 0.6 : BODY_HEIGHT;
23
+ const headY = bodyHeight + 0.13;
24
+ const model = useMemo(() => {
25
+ const bodyMaterial = new THREE.MeshStandardMaterial({
26
+ color: tint ?? defaultCharacterTint({ model: "proxy-figure" }, 0),
27
+ roughness: 0.66,
28
+ metalness: 0,
29
+ envMapIntensity: 0.35,
30
+ });
31
+ const group = new THREE.Group();
32
+ const body = new THREE.Mesh(new THREE.CapsuleGeometry(BODY_RADIUS, bodyHeight - BODY_RADIUS * 2, 12, 20), bodyMaterial);
33
+ body.position.y = bodyHeight / 2;
34
+ body.castShadow = true;
35
+ body.receiveShadow = true;
36
+ body.frustumCulled = false;
37
+ group.add(body);
38
+ const head = new THREE.Mesh(new THREE.SphereGeometry(HEAD_RADIUS, 16, 12), bodyMaterial);
39
+ head.position.y = headY;
40
+ head.castShadow = true;
41
+ head.receiveShadow = true;
42
+ head.frustumCulled = false;
43
+ group.add(head);
44
+ return group;
45
+ }, [bodyHeight, headY, tint]);
46
+ useEffect(() => () => {
47
+ const materials = new Set();
48
+ model.traverse(node => {
49
+ if (!node.isMesh) return;
50
+ node.geometry.dispose();
51
+ materials.add(node.material);
52
+ });
53
+ for (const material of materials) material.dispose();
54
+ }, [model]);
55
+
56
+ return (
57
+ <group
58
+ position={position}
59
+ rotation={[0, proxyYaw(rot, posture), 0]}
60
+ scale={scale}
61
+ userData={pickId ? { characterPick: pickId, selected: !!selected } : undefined}
62
+ >
63
+ <group position={posture === "lie" ? [0, BODY_RADIUS, 0] : [0, 0, 0]} rotation={posture === "lie" ? [-Math.PI / 2, 0, 0] : [0, 0, 0]}>
64
+ <primitive object={model} />
65
+ </group>
66
+ </group>
67
+ );
68
+ });
69
+
@@ -341,4 +341,3 @@ export function RangePinPanel({
341
341
  );
342
342
  }
343
343
 
344
- export { PARTS as RANGE_PIN_PARTS, partFor as rangePinPartFor, freshDraft as createRangePinDraft };