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
@@ -21,6 +21,7 @@ import { Euler, Quaternion } from "three";
21
21
  import { createObjectPath, translateObjectPath } from "./object-path.js";
22
22
  import { elementByPath } from "./studio-elements.js";
23
23
  import { MESH_DEFAULT_HEIGHT, MESH_HEIGHT_MIN } from "./scene-mesh.js";
24
+ import { normalizeSculptRecipe, sculptStandingBox } from "./sculpt-recipe.js";
24
25
 
25
26
  export const DEFAULT_SCENE_OBJECTS = [];
26
27
  /** The persistence contract (plan §8.1): the version lives in the key AND in
@@ -96,9 +97,9 @@ export const OBJECT_COLORS = [
96
97
  /** How many hand-mixed colours the inspector remembers. Six is one palette
97
98
  * row: enough to keep a scene's custom tints reachable, short enough that the
98
99
  * popover never turns into a second, unruly palette. */
99
- export const RECENT_OBJECT_COLORS_MAX = 6;
100
+ const RECENT_OBJECT_COLORS_MAX = 6;
100
101
  /** Session memory of hand-mixed tints; versioned like every other key here. */
101
- export const RECENT_OBJECT_COLORS_KEY = "cozyclay.objectColors.recent";
102
+ const RECENT_OBJECT_COLORS_KEY = "cozyclay.objectColors.recent";
102
103
 
103
104
  const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
104
105
  const HEX_LONG = /^#?([0-9a-f]{6})$/i;
@@ -209,13 +210,67 @@ const MESH_ENTRY = {
209
210
  color: "#c49a6c",
210
211
  };
211
212
 
213
+ /**
214
+ * A sculpt is a clay prop built from a recipe of soft parts (sculpt-recipe.js):
215
+ * data an agent writes from a reference image, never code. Like a mesh its size
216
+ * is per-instance, but it is MEASURED from the recipe rather than stored as a
217
+ * fact, so a record can never disagree with the parts it draws.
218
+ */
219
+ export const SCULPT_KIND = "sculpt";
220
+ const SCULPT_ENTRY = {
221
+ kind: SCULPT_KIND,
222
+ label: "Sculpt",
223
+ group: "Sculpts",
224
+ footprint: { width: 1, depth: 1 },
225
+ height: 1,
226
+ color: "#c2c6c8",
227
+ };
228
+
229
+ /**
230
+ * An empty is a pure grouping node: a transform and a place in the hierarchy,
231
+ * with nothing to draw (Unity's Create Empty, Blender's Empty). It exists so a
232
+ * multi-part assembly can have one top-level handle — move it, turn it, give it
233
+ * a route — without any of the parts having to stand in for it.
234
+ *
235
+ * Its size is zero on purpose. Every consumer that reads geometry (blockers,
236
+ * drop surfaces, ground sampling, support rise, fits, the plan footprint) goes
237
+ * through `footprint` × `height`, so a zero box makes the empty invisible to
238
+ * all of them without a per-consumer special case; the few that would
239
+ * misread a zero box as a point (dropToSurfacePatch) check `isEmptyObject`.
240
+ * It is creatable from the menu, so it lives beside the library entries rather
241
+ * than being import-only.
242
+ */
243
+ export const EMPTY_KIND = "empty";
244
+ /** Editor-only marker size in metres: the axes cross drawn for an empty, and
245
+ * the side of the invisible volume that makes it clickable. */
246
+ export const EMPTY_MARKER_SIZE = 0.3;
247
+ const EMPTY_ENTRY = {
248
+ kind: EMPTY_KIND,
249
+ label: "Empty",
250
+ group: "Primitives",
251
+ footprint: { width: 0, depth: 0 },
252
+ height: 0,
253
+ color: "#ffffff",
254
+ };
255
+
256
+ /** True for a record that is only a node: no mesh, no collision, no surface. */
257
+ export function isEmptyObject(object) {
258
+ return object?.renderer === EMPTY_KIND;
259
+ }
260
+
261
+ /** The catalogue grouped for the create menu: the library plus the empty,
262
+ * which is creatable but has no footprint to satisfy the library's contract. */
263
+ export const OBJECT_MENU_ENTRIES = Object.freeze([EMPTY_ENTRY, ...OBJECT_LIBRARY]);
264
+
212
265
  /** Every kind that can exist in a scene: the catalogue you can create from,
213
266
  * plus the kinds that arrive by import and so are deliberately absent from the
214
267
  * "Add object" menu (a cutout without an image, a mesh without a GLB, has
215
268
  * nothing to draw). */
216
- export function objectLibraryEntry(kind) {
269
+ function objectLibraryEntry(kind) {
217
270
  if (kind === CUTOUT_KIND) return CUTOUT_ENTRY;
218
271
  if (kind === MESH_KIND) return MESH_ENTRY;
272
+ if (kind === SCULPT_KIND) return SCULPT_ENTRY;
273
+ if (kind === EMPTY_KIND) return EMPTY_ENTRY;
219
274
  return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
220
275
  }
221
276
 
@@ -231,9 +286,9 @@ const cutoutHeight = (value) => Math.max(CUTOUT_HEIGHT_MIN, value);
231
286
  const cutoutAspect = (value) => clamp(value, CUTOUT_ASPECT_MIN, CUTOUT_ASPECT_MAX);
232
287
 
233
288
  /** How far a card may be pulled off the picture's own proportions. */
234
- export const CUTOUT_STRETCH_MIN = 0.1;
235
- export const CUTOUT_STRETCH_MAX = 10;
236
- export const cutoutStretch = (value) => {
289
+ const CUTOUT_STRETCH_MIN = 0.1;
290
+ const CUTOUT_STRETCH_MAX = 10;
291
+ const cutoutStretch = (value) => {
237
292
  const n = Number(value);
238
293
  return Number.isFinite(n) ? clamp(n, CUTOUT_STRETCH_MIN, CUTOUT_STRETCH_MAX) : 1;
239
294
  };
@@ -255,8 +310,6 @@ export function cutoutFootprint(height, aspect, stretch = 1) {
255
310
  };
256
311
  }
257
312
 
258
- /* ----------------------------------------------------- attachment ---- */
259
-
260
313
  /**
261
314
  * The frames a prop can be pinned to on a character. These are the APP's IK
262
315
  * track ids — the same names the rig panel and the animation tracks speak —
@@ -332,7 +385,7 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
332
385
  export function createSceneObject(kind, existing = [], placement = {}) {
333
386
  // Cutouts and meshes come from an import, never from the catalogue:
334
387
  // without an asset id the record has nothing to draw.
335
- if (kind === CUTOUT_KIND || kind === MESH_KIND) return null;
388
+ if (kind === CUTOUT_KIND || kind === MESH_KIND || kind === SCULPT_KIND) return null;
336
389
  const entry = objectLibraryEntry(kind);
337
390
  if (!entry) return null;
338
391
  const names = new Set(existing.map((object) => object.name));
@@ -510,6 +563,52 @@ export function duplicateMeshOptions(object) {
510
563
  return { assetId: object.assetId, height: object.height, footprint: object.footprint, name: object.name, clay: object.clay === true };
511
564
  }
512
565
 
566
+ /**
567
+ * A fresh sculpt from a recipe. Returns `{ object }` or `{ error }` with the
568
+ * recipe's own refusal, so a caller (the agent above all) hears why a recipe
569
+ * cannot be drawn instead of getting a silent null.
570
+ */
571
+ export function createSculptObject({ recipe, name = "" } = {}, existing = [], placement = {}) {
572
+ const normalized = normalizeSculptRecipe(recipe);
573
+ if (!normalized.ok) return { error: normalized.error };
574
+ const { footprint, height } = sculptStandingBox(normalized.recipe);
575
+ const base = typeof name === "string" && name.trim() ? name.trim() : SCULPT_ENTRY.label;
576
+ const names = new Set(existing.map((object) => object.name));
577
+ let displayName = base;
578
+ for (let n = 2; names.has(displayName); n += 1) displayName = `${base} ${n}`;
579
+ const ids = new Set(existing.map((object) => object.id));
580
+ let id = SCULPT_KIND;
581
+ for (let n = 2; ids.has(id); n += 1) id = `${SCULPT_KIND}-${n}`;
582
+ return {
583
+ object: {
584
+ id,
585
+ name: displayName,
586
+ renderer: SCULPT_KIND,
587
+ x: TRANSFORM_LIMITS.x(Number(placement.x) || 0),
588
+ y: TRANSFORM_LIMITS.y(Number(placement.y) || 0),
589
+ z: TRANSFORM_LIMITS.z(Number(placement.z) || 0),
590
+ rot: wrapAngle(Number(placement.rot) || 0),
591
+ rotX: 0,
592
+ rotZ: 0,
593
+ scaleX: 1,
594
+ scaleY: 1,
595
+ scaleZ: 1,
596
+ path: null,
597
+ color: SCULPT_ENTRY.color,
598
+ parent: null,
599
+ attach: null,
600
+ hidden: false,
601
+ recipe: normalized.recipe,
602
+ footprint,
603
+ height,
604
+ },
605
+ };
606
+ }
607
+
608
+ export function duplicateSculptOptions(object) {
609
+ return { recipe: object.recipe, name: object.name };
610
+ }
611
+
513
612
  /** Every writable transform channel and the rule that keeps it in the room. */
514
613
  const TRANSFORM_LIMITS = {
515
614
  x: (value) => clamp(value, OBJECT_POSITION_LIMITS.min.x, OBJECT_POSITION_LIMITS.max.x),
@@ -523,6 +622,16 @@ const TRANSFORM_LIMITS = {
523
622
  scaleZ: (value) => clamp(value, OBJECT_SCALE_LIMITS.min.z, OBJECT_SCALE_LIMITS.max.z),
524
623
  };
525
624
 
625
+ /** The patch keys updateSceneObject reads for this object; any other key is
626
+ * silently ignored there, so callers that must not be silent check against this. */
627
+ export function objectPatchFields(object) {
628
+ const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path"];
629
+ if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
630
+ if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
631
+ if (object?.renderer === SCULPT_KIND) fields.push("recipe");
632
+ return fields;
633
+ }
634
+
526
635
  /** Every object that hangs off `id`, at any depth. A cycle cannot form because
527
636
  * setParent refuses one, but the seen-set keeps this total even if data is
528
637
  * hand-edited into a loop. */
@@ -574,13 +683,117 @@ export function isEffectivelyHidden(entity, objects = [], characters = []) {
574
683
  return false;
575
684
  }
576
685
 
686
+ /** Float dust (6e-17 where 0 belongs) is snapped away, and everything else is
687
+ * kept to 1e-9: fine enough that hundreds of small gizmo steps cannot drift a
688
+ * carried child visibly, coarse enough that stored values stay readable. */
689
+ const tidy = (value) => {
690
+ const rounded = Math.round(value * 1e9) / 1e9;
691
+ return rounded === 0 ? 0 : rounded;
692
+ };
693
+ export const tidyAngle = (deg) => tidy(wrapAngle(tidy(deg)));
694
+
695
+ const orientationOf = (record) =>
696
+ new Quaternion().setFromEuler(new Euler((record.rotX ?? 0) * DEG, (record.rot ?? 0) * DEG, (record.rotZ ?? 0) * DEG, EULER_ORDER));
697
+
698
+ /** The Euler triple (degrees, XYZ) for an orientation that stays closest to
699
+ * `near`. Every orientation has two XYZ triples — (x, y, z) and
700
+ * (x+180, 180−y, z+180) — and a bare setFromQuaternion always answers with the
701
+ * one whose Y is within ±90. Taking that blindly would turn a plain yaw of 170
702
+ * into "rotX 180, rot −10, rotZ 180": the same pose, but the bird's-eye board
703
+ * and the inspector read `rot` as the yaw. The nearer triple keeps a flat
704
+ * object flat across every turn. */
705
+ function eulerNear(quaternion, near) {
706
+ const e = new Euler().setFromQuaternion(quaternion, EULER_ORDER);
707
+ const here = [near.rotX ?? 0, near.rot ?? 0, near.rotZ ?? 0];
708
+ const candidates = [
709
+ [e.x / DEG, e.y / DEG, e.z / DEG],
710
+ [e.x / DEG + 180, 180 - e.y / DEG, e.z / DEG + 180],
711
+ ].map((triple) => triple.map(tidyAngle));
712
+ const distance = (triple) => triple.reduce((sum, value, i) => sum + Math.abs(wrapAngle(value - here[i])), 0);
713
+ const [rotX, rot, rotZ] = distance(candidates[1]) < distance(candidates[0]) ? candidates[1] : candidates[0];
714
+ return { rotX, rot, rotZ };
715
+ }
716
+
717
+ /**
718
+ * The rigid motion that takes a pose to another: where its pivot was and is,
719
+ * and the turn Δ = q_after · q_before⁻¹ between the two orientations (XYZ
720
+ * Eulers, the renderer's order). Anything riding the pose goes to
721
+ * `to + Δ·(p − from)` and turns by Δ on the spot. Returns null when the
722
+ * orientation did not change — a pure move stays a plain translation.
723
+ *
724
+ * `yaw` is the part of Δ that turns about the vertical (swing-twist about +Y,
725
+ * degrees). A character only has a yaw, so it takes this and nothing more: the
726
+ * pitch or roll of what it is grouped under moves its position and leaves its
727
+ * facing alone.
728
+ *
729
+ * @returns {{ quaternion: Quaternion, matrix: number[], yaw: number, from: object, to: object } | null}
730
+ */
731
+ export function rigidMotionBetween(before, after) {
732
+ if ((before.rotX ?? 0) === (after.rotX ?? 0) && (before.rot ?? 0) === (after.rot ?? 0) && (before.rotZ ?? 0) === (after.rotZ ?? 0)) return null;
733
+ const quaternion = orientationOf(after).multiply(orientationOf(before).invert());
734
+ if (quaternion.w < 0) quaternion.set(-quaternion.x, -quaternion.y, -quaternion.z, -quaternion.w);
735
+ if (quaternion.w > 1 - 1e-15) return null;
736
+ const { x, y, z, w } = quaternion;
737
+ return {
738
+ quaternion,
739
+ // row-major 3x3 of Δ
740
+ matrix: [
741
+ 1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
742
+ 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
743
+ 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
744
+ ],
745
+ yaw: tidyAngle((2 * Math.atan2(y, w)) / DEG),
746
+ from: { x: before.x ?? 0, y: before.y ?? 0, z: before.z ?? 0 },
747
+ to: { x: after.x ?? 0, y: after.y ?? 0, z: after.z ?? 0 },
748
+ };
749
+ }
750
+
751
+ /** A point carried by a rigid motion from `rigidMotionBetween`. */
752
+ export function carryPointByMotion(point, motion) {
753
+ const { matrix: m, from, to } = motion;
754
+ const dx = point.x - from.x;
755
+ const dy = (point.y ?? 0) - from.y;
756
+ const dz = point.z - from.z;
757
+ return {
758
+ x: tidy(to.x + m[0] * dx + m[1] * dy + m[2] * dz),
759
+ y: tidy(to.y + m[3] * dx + m[4] * dy + m[5] * dz),
760
+ z: tidy(to.z + m[6] * dx + m[7] * dy + m[8] * dz),
761
+ };
762
+ }
763
+
764
+ /** Descendants that turn with a rotating parent: everything under it except
765
+ * records riding a character (their numbers are bone-local, so they follow the
766
+ * bone, not the parent) and whatever hangs below such a record. */
767
+ function descendantsThatTurn(objects, id) {
768
+ const out = new Set();
769
+ let frontier = [id];
770
+ while (frontier.length) {
771
+ const next = [];
772
+ for (const object of objects) {
773
+ if (!object.parent || !frontier.includes(object.parent) || object.id === id || out.has(object.id) || object.attach) continue;
774
+ out.add(object.id);
775
+ next.push(object.id);
776
+ }
777
+ frontier = next;
778
+ }
779
+ return out;
780
+ }
781
+
577
782
  export function updateSceneObject(objects, id, patch) {
578
783
  let changed = false;
579
784
  const target = objects.find((object) => object.id === id);
580
- // A parent carries its children: the group is dragged, nudged and dropped as
581
- // one body. Only translation rides along — rotating or scaling a group would
582
- // have to orbit and rescale every child about the parent's origin, which is a
583
- // different feature and is deliberately not pretended at here.
785
+ // A parent carries its children: the group is dragged, nudged, turned and
786
+ // dropped as one body. Translation shifts every descendant by the same
787
+ // delta. Rotation turns the group rigidly about the parent's pivot: each
788
+ // descendant orbits it by Δ = q_new · q_old⁻¹ and composes Δ onto its own
789
+ // orientation, so every child keeps its pose relative to the parent. A
790
+ // child's travel route is NOT turned with it: the route is a road in the
791
+ // world, and a parent's turn turns the bodies (their orientation and the
792
+ // authored position they orbit), never the road they would drive. A move and a turn in one patch is both at once,
793
+ // about the pivot's old and new place. Scale is NOT carried — rescaling a
794
+ // group would have to resize every child about the parent, a different
795
+ // feature. Records riding a character (`attach`) hold bone-local numbers and
796
+ // are left alone by the turn, and so is everything below them.
584
797
  const carried = target ? descendantsOf(objects, id) : [];
585
798
  const delta = { x: 0, y: 0, z: 0 };
586
799
  if (target) {
@@ -593,10 +806,43 @@ export function updateSceneObject(objects, id, patch) {
593
806
  }
594
807
  const moving = new Set(carried.map((object) => object.id));
595
808
  const shifts = delta.x || delta.y || delta.z;
809
+ let turn = null;
810
+ let turning = null;
811
+ if (target && !target.attach && carried.length) {
812
+ const after = { x: (target.x ?? 0) + delta.x, y: (target.y ?? 0) + delta.y, z: (target.z ?? 0) + delta.z };
813
+ for (const key of ["rotX", "rot", "rotZ"]) {
814
+ const value = patch[key] === undefined ? NaN : Number(patch[key]);
815
+ after[key] = Number.isFinite(value) ? TRANSFORM_LIMITS[key](value) : (target[key] ?? 0);
816
+ }
817
+ turn = rigidMotionBetween(target, after);
818
+ if (turn) turning = descendantsThatTurn(objects, id);
819
+ }
596
820
 
597
821
  const next = objects.map((object) => {
598
822
  if (object.id !== id) {
599
- if (!shifts || !moving.has(object.id)) return object;
823
+ if ((!shifts && !turn) || !moving.has(object.id)) return object;
824
+ if (turn && turning.has(object.id)) {
825
+ const update = {};
826
+ const orbit = carryPointByMotion({ x: object.x, y: object.y, z: object.z }, turn);
827
+ for (const axis of ["x", "y", "z"]) {
828
+ const bounded = TRANSFORM_LIMITS[axis](orbit[axis]);
829
+ if (bounded !== (object[axis] ?? 0)) update[axis] = bounded;
830
+ }
831
+ const orientation = new Quaternion().multiplyQuaternions(turn.quaternion, orientationOf(object));
832
+ const euler = eulerNear(orientation, object);
833
+ for (const key of ["rotX", "rot", "rotZ"]) {
834
+ if (euler[key] !== (object[key] ?? 0)) update[key] = euler[key];
835
+ }
836
+ // A move in the same edit still carries the road along, unturned.
837
+ if (object.path && shifts) {
838
+ const moved = translateObjectPath(object.path, delta);
839
+ if (JSON.stringify(moved ?? null) !== JSON.stringify(object.path ?? null)) update.path = moved;
840
+ }
841
+ if (!Object.keys(update).length) return object;
842
+ changed = true;
843
+ return { ...object, ...update };
844
+ }
845
+ if (!shifts) return object;
600
846
  const update = {};
601
847
  const carriedDelta = { x: 0, y: 0, z: 0 };
602
848
  for (const axis of ["x", "y", "z"]) {
@@ -629,6 +875,10 @@ export function updateSceneObject(objects, id, patch) {
629
875
  update[key] = patch[key];
630
876
  }
631
877
  if (typeof patch.hidden === "boolean" && patch.hidden !== (object.hidden === true)) update.hidden = patch.hidden;
878
+ if (patch.opacity !== undefined && Number.isFinite(Number(patch.opacity))) {
879
+ const opacity = objectOpacity(patch.opacity);
880
+ if (opacity !== objectOpacity(object.opacity)) update.opacity = opacity;
881
+ }
632
882
  // The travel path is authored geometry, not a bounded transform: it is
633
883
  // normalized by createObjectPath (which repairs or refuses it) and set
634
884
  // wholesale, with null clearing it back to a standing object.
@@ -704,6 +954,15 @@ export function updateSceneObject(objects, id, patch) {
704
954
  }
705
955
  }
706
956
  }
957
+ // A recipe that cannot be drawn leaves the sculpt as it was; the command
958
+ // layer (object.sculpt) is where the refusal is reported.
959
+ if (object.renderer === SCULPT_KIND && patch.recipe !== undefined) {
960
+ const normalized = normalizeSculptRecipe(patch.recipe);
961
+ if (normalized.ok && JSON.stringify(normalized.recipe) !== JSON.stringify(object.recipe)) {
962
+ update.recipe = normalized.recipe;
963
+ Object.assign(update, sculptStandingBox(normalized.recipe));
964
+ }
965
+ }
707
966
  if (!Object.keys(update).length) return object;
708
967
  changed = true;
709
968
  return { ...object, ...update };
@@ -788,8 +1047,34 @@ export function removeSceneObject(objects, id) {
788
1047
  // is an editing convenience and never an owner of the parts.
789
1048
  return next.map((object) => (object.parent === id ? { ...object, parent: null } : object));
790
1049
  }
791
- /* -------------------------------------------------- persistence ---- */
792
1050
 
1051
+ /**
1052
+ * Wrap `id` in a new Empty: the Empty takes the object's place in the tree
1053
+ * (its parent, its position) and the object becomes its child. One pure edit,
1054
+ * so the caller can publish it as ONE history entry.
1055
+ *
1056
+ * Returns `{ objects, emptyId }`, or null when the object is unknown or is
1057
+ * carried by a character (its numbers are local to a bone frame, so an Empty
1058
+ * "at its position" would sit at a fictional place — detach first).
1059
+ *
1060
+ * Rotation is not copied: the Empty starts unturned, because a group node
1061
+ * should be a clean handle, and the children keep their world transforms
1062
+ * (grouping moves nothing on screen).
1063
+ */
1064
+ export function groupUnderNewEmpty(objects, id) {
1065
+ const target = objects.find((object) => object.id === id);
1066
+ if (!target || target.attach) return null;
1067
+ const created = createSceneObject(EMPTY_KIND, objects, { x: target.x, z: target.z });
1068
+ if (!created) return null;
1069
+ const names = new Set(objects.map((object) => object.name));
1070
+ const base = `${target.name} Group`;
1071
+ let name = base;
1072
+ for (let n = 2; names.has(name); n += 1) name = `${base} ${n}`;
1073
+ const empty = { ...created, name, y: TRANSFORM_LIMITS.y(target.y ?? 0), parent: target.parent ?? null };
1074
+ // The Empty goes in just before the object, so the Outliner row keeps its place.
1075
+ const next = objects.flatMap((object) => (object.id === id ? [empty, { ...object, parent: empty.id }] : [object]));
1076
+ return { objects: next, emptyId: empty.id };
1077
+ }
793
1078
  /**
794
1079
  * Repair one stored record into a live record, or return null to drop it.
795
1080
  * Storage is never trusted: `footprint`/`height` are rebuilt from the
@@ -798,6 +1083,14 @@ export function removeSceneObject(objects, id) {
798
1083
  * `renderer` (or a record without an id) has nothing to render or address,
799
1084
  * so it is dropped rather than half-restored (plan §8.2).
800
1085
  */
1086
+ const OBJECT_OPACITY_MIN = 0.05;
1087
+
1088
+ /** 1 = solid. Clamped above zero so a see-through object never becomes an invisible, unpickable one. */
1089
+ function objectOpacity(value) {
1090
+ const n = Number(value);
1091
+ return Number.isFinite(n) ? Math.min(1, Math.max(OBJECT_OPACITY_MIN, n)) : 1;
1092
+ }
1093
+
801
1094
  export function normalizeSceneObject(record) {
802
1095
  if (!record || typeof record !== "object" || Array.isArray(record)) return null;
803
1096
  const entry = objectLibraryEntry(record.renderer);
@@ -808,6 +1101,9 @@ export function normalizeSceneObject(record) {
808
1101
  // rule an unknown renderer already gets.
809
1102
  const isCutout = entry.kind === CUTOUT_KIND;
810
1103
  const isMesh = entry.kind === MESH_KIND;
1104
+ const sculpt = entry.kind === SCULPT_KIND ? normalizeSculptRecipe(record.recipe) : null;
1105
+ // Same rule as a cutout without its picture: nothing to draw, so dropped.
1106
+ if (sculpt && !sculpt.ok) return null;
811
1107
  if (isCutout && (typeof record.assetId !== "string" || !record.assetId)) return null;
812
1108
  if (isMesh && (typeof record.assetId !== "string" || !record.assetId)) return null;
813
1109
  // Defensive import fallback, not a migration: hand-authored or external
@@ -855,6 +1151,8 @@ export function normalizeSceneObject(record) {
855
1151
  // all, and null is exactly what it meant: world-anchored.
856
1152
  attach: normalizeSceneAttach(record.attach) ?? null,
857
1153
  hidden: record.hidden === true,
1154
+ // Absent means solid, so documents written before see-through objects are unchanged.
1155
+ ...(objectOpacity(record.opacity) < 1 ? { opacity: objectOpacity(record.opacity) } : {}),
858
1156
  // Library kinds take their size from the library — a stored footprint is
859
1157
  // stale data, not a fact. Cutouts and meshes are the exceptions: their
860
1158
  // size IS per-instance. A cutout rebuilds the footprint from height and
@@ -880,6 +1178,8 @@ export function normalizeSceneObject(record) {
880
1178
  ),
881
1179
  height: cutoutHeight(pick(record.height, CUTOUT_DEFAULT_HEIGHT)),
882
1180
  }
1181
+ : sculpt
1182
+ ? { recipe: sculpt.recipe, ...sculptStandingBox(sculpt.recipe) }
883
1183
  : isMesh
884
1184
  ? {
885
1185
  assetId: record.assetId,
@@ -945,8 +1245,6 @@ export function loadScene(raw) {
945
1245
  return { status: "valid", objects, dropped };
946
1246
  }
947
1247
 
948
- /* ------------------------------------------------------------- gizmo ---- */
949
-
950
1248
  /** The three world axes the gizmo drags along, and the record field each one
951
1249
  * rotates / scales. */
952
1250
  const WORLD_AXES = ["x", "y", "z"];
@@ -1025,8 +1323,6 @@ export function objectSize(object) {
1025
1323
  depth: (object.footprint?.depth ?? 1) * (object.scaleZ ?? 1),
1026
1324
  };
1027
1325
  }
1028
- /* -------------------------------------------------- drop-to-surface ---- */
1029
-
1030
1326
  /** The tolerance that keeps an edge-abutting footprint from counting as
1031
1327
  * overlap: a strict `<` against `max - EPS` makes a 0.5 - 0.5 touch false. */
1032
1328
  const OVERLAP_EPS = 1e-4;
@@ -1078,9 +1374,13 @@ export function objectFootprintBounds(object) {
1078
1374
  * clamped here: the y clamp stays in updateSceneObject, the single owner.
1079
1375
  */
1080
1376
  export function dropToSurfacePatch(object, others, characters = []) {
1377
+ // An empty has no base to rest on and no extent to rest with: dropping it
1378
+ // would carry a whole assembly to wherever a zero-size point happens to sit.
1379
+ if (isEmptyObject(object)) return null;
1081
1380
  const self = objectFootprintBounds(object);
1082
1381
  let highestTop = 0;
1083
1382
  for (const other of others) {
1383
+ if (isEmptyObject(other)) continue;
1084
1384
  if (isEffectivelyHidden(other, others, characters)) continue;
1085
1385
  const bounds = objectFootprintBounds(other);
1086
1386
  if (self.minX >= bounds.maxX - OVERLAP_EPS || bounds.minX >= self.maxX - OVERLAP_EPS) continue;
package/src/scenes.js CHANGED
@@ -13,21 +13,22 @@ import { wrapAngle } from "./scene-objects.js";
13
13
  import { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame } from "./timeline-fps.js";
14
14
 
15
15
  const CHARACTER_POSITION_LIMITS = elementByPath("character.position");
16
- const CHARACTER_ROTATION_LIMITS = elementByPath("character.rot");
17
16
  const CHARACTER_SCALE_LIMITS = elementByPath("character.scale");
18
17
  const KEY_LIGHT_LIMITS = Object.fromEntries(["x", "y", "z", "intensity", "warmth"].map((axis) => [axis, elementByPath(`stage.keyLight.${axis}`)]));
19
18
 
20
- export const SCENES_VERSION = 4;
21
- export const SCENES_STORAGE_KEY = "cozyclay.scenes.v4";
22
- export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v4.quarantine";
23
- export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v3";
24
- export const V2_SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
25
- export const V1_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
19
+ export const SCENES_VERSION = 5;
20
+ export const SCENES_STORAGE_KEY = "cozyclay.scenes.v5";
21
+ export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v5.quarantine";
22
+ export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v4";
23
+ const V3_SCENES_STORAGE_KEY = "cozyclay.scenes.v3";
24
+ const V2_SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
25
+ const V1_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
26
26
  export const LEGACY_SCENE_STORAGE_KEY = "cozyclay.scene.v1";
27
27
  /** Newest-first fallback chain: a user arriving from any older build still
28
28
  * finds their scenes, and the reader migrates whatever body it lands on. */
29
29
  export const LEGACY_SCENES_STORAGE_KEYS = Object.freeze([
30
30
  PREVIOUS_SCENES_STORAGE_KEY,
31
+ V3_SCENES_STORAGE_KEY,
31
32
  V2_SCENES_STORAGE_KEY,
32
33
  V1_SCENES_STORAGE_KEY,
33
34
  ]);
@@ -38,10 +39,20 @@ export { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame };
38
39
  * file stem and the wire `source.rig` value sent to ARDY. */
39
40
  export const CHARACTER_MODEL_IDS = Object.freeze(["y-bot-tpose", "x-bot-tpose"]);
40
41
  export const DEFAULT_CHARACTER_MODEL = "y-bot-tpose";
42
+ /** A rig-free capsule stand-in for blocking. It has no FBX and is never sent
43
+ * to ARDY, so it extends the accepted kinds without joining
44
+ * CHARACTER_MODEL_IDS. */
45
+ export const PROXY_FIGURE_MODEL = "proxy-figure";
46
+ export const CHARACTER_KIND_IDS = Object.freeze([...CHARACTER_MODEL_IDS, PROXY_FIGURE_MODEL]);
47
+ export const POSTURES = Object.freeze(["stand", "sit", "lie"]);
48
+
49
+ export function isProxyFigure(character) {
50
+ return character?.model === PROXY_FIGURE_MODEL;
51
+ }
41
52
  export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
42
53
  export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
43
54
 
44
- export const DEFAULT_SCENE_STAGE = Object.freeze({
55
+ const DEFAULT_SCENE_STAGE = Object.freeze({
45
56
  characters: Object.freeze([
46
57
  Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
47
58
  ]),
@@ -153,7 +164,8 @@ export function createCharacterEntry(source = null, index = 0) {
153
164
  const s = plainObject(source) ? source : {};
154
165
  return {
155
166
  id: typeof s.id === "string" && s.id ? s.id : `char-${index + 1}`,
156
- model: CHARACTER_MODEL_IDS.includes(s.model) ? s.model : DEFAULT_CHARACTER_MODEL,
167
+ model: CHARACTER_KIND_IDS.includes(s.model) ? s.model : DEFAULT_CHARACTER_MODEL,
168
+ posture: POSTURES.includes(s.posture) ? s.posture : "stand",
157
169
  x: Math.max(CHARACTER_POSITION_LIMITS.min.x, Math.min(CHARACTER_POSITION_LIMITS.max.x, finiteOr(s.x, 0))),
158
170
  // Lift is bounded by the deck and the shared room headroom, so every
159
171
  // writer (inspector field, viewport gizmo, load path) shares one envelope.
@@ -161,6 +173,10 @@ export function createCharacterEntry(source = null, index = 0) {
161
173
  z: Math.max(CHARACTER_POSITION_LIMITS.min.z, Math.min(CHARACTER_POSITION_LIMITS.max.z, finiteOr(s.z, 0))),
162
174
  rot: wrapAngle(finiteOr(s.rot, 0)),
163
175
  hidden: s.hidden === true,
176
+ // The scene object this character is grouped under, or null. A grouped
177
+ // character rides its object's travel path the way grouped parts do; its
178
+ // own x/y/z stay world numbers at the object's authored pose.
179
+ parent: typeof s.parent === "string" && s.parent ? s.parent : null,
164
180
  // User-picked body tint; null means "model default" (y-bot clay, x-bot
165
181
  // whiter clay) so the entry survives future default tweaks.
166
182
  tint: typeof s.tint === "string" && /^#[0-9a-fA-F]{6}$/.test(s.tint) ? s.tint : null,
@@ -180,6 +196,10 @@ export function createCharacterEntry(source = null, index = 0) {
180
196
  // re-fetch and decode it on the next session.
181
197
  layer: normalizeLayer(s.layer),
182
198
  motionRef: normalizeMotionRef(s.motionRef),
199
+ // The Pose-mode / IK edits on this character's layer (encoded keys, range
200
+ // pins and their residuals). The motion domain owns them while the scene
201
+ // is open; this is what the saved scene carries so a reload keeps them.
202
+ ikEdits: normalizeIkEdits(s.ikEdits),
183
203
  };
184
204
  }
185
205
 
@@ -189,6 +209,15 @@ const MOTION_ID_RE = /^[0-9a-f]{64}$/i;
189
209
  // in the project's embedded motions) and/or by location (url: a bridge run,
190
210
  // which is what pre-motionId documents carry). Either one alone is a valid
191
211
  // ref; the restore path prefers the embedded bytes when both are present.
212
+ function normalizeIkEdits(value) {
213
+ if (!plainObject(value)) return null;
214
+ const rows = (list, valid) => (Array.isArray(list) ? list.filter(valid).map((entry) => cloneValue(entry)) : []);
215
+ const keys = rows(value.keys, (row) => plainObject(row) && Number.isFinite(row.frame) && plainObject(row.tracks));
216
+ const pins = rows(value.pins, (pin) => plainObject(pin) && typeof pin.id === "string");
217
+ const pinResiduals = rows(value.pinResiduals, (row) => Array.isArray(row) && typeof row[0] === "string");
218
+ return keys.length || pins.length ? { keys, pins, pinResiduals } : null;
219
+ }
220
+
192
221
  function normalizeMotionRef(ref) {
193
222
  if (!plainObject(ref)) return null;
194
223
  const motionId = typeof ref.motionId === "string" && MOTION_ID_RE.test(ref.motionId) ? ref.motionId.toLowerCase() : null;
@@ -491,11 +520,31 @@ export function readSceneDocument(raw, legacyRaw = null) {
491
520
  // Anything below v4 was authored while the timeline ran at 20 fps. The
492
521
  // fallback stage (a v1 global cast) rides the same conversion, applied
493
522
  // once, inside repairScene — whichever stage that scene ends up with.
523
+ // v4 → v5 needs no step of its own: every cast member passes through
524
+ // createCharacterEntry, which fills the missing posture with "stand".
494
525
  const legacyClock = payload.version < 4;
495
526
  const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
496
527
  return { status: migrating ? "migrated" : "valid", ...repaired };
497
528
  }
498
529
 
530
+ /** Lift a scene document that skipped the storage reader (a project file, a
531
+ * playground preset) onto SCENES_VERSION: v3 and older move their frames onto
532
+ * the 24 fps clock, v4 and older gain a standing posture per cast member. */
533
+ export function migrateScenesDocument(source) {
534
+ if (!Number.isInteger(source.version) || source.version >= SCENES_VERSION) return source;
535
+ const legacyClock = source.version < 4;
536
+ return {
537
+ ...source,
538
+ version: SCENES_VERSION,
539
+ scenes: source.scenes.map((scene) => {
540
+ const stage = legacyClock ? migrateStageFrames(scene.stage) : scene.stage;
541
+ return { ...scene, stage: plainObject(stage) && Array.isArray(stage.characters)
542
+ ? { ...stage, characters: stage.characters.map((entry) => (plainObject(entry) && !POSTURES.includes(entry.posture) ? { ...entry, posture: "stand" } : entry)) }
543
+ : stage };
544
+ }),
545
+ };
546
+ }
547
+
499
548
  export function serializeSceneDocument(document) {
500
549
  return JSON.stringify({ version: SCENES_VERSION, activeSceneId: document.activeSceneId, scenes: document.scenes });
501
550
  }