cozyclay 3.1.6 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -0,0 +1,152 @@
1
+ // The physics overlay as three.js objects (motion3/physics-view.js and physics-trail.js give the numbers): the
2
+ // centre of mass as a 4 cm ball with a line down to the ground, the support polygon on the ground, per air run the
3
+ // hips path (white, dashed) against the gravity parabola (orange), and each spring object's tip trail. Lines are
4
+ // thin tubes of a fixed width in metres, so a capture or an export at any size draws them as the viewport does. Every
5
+ // node takes no ray and draws over the body (the centre of mass sits inside it). The body's marks are computed where
6
+ // the take was compiled and ride the character's placement; the trails are world space, as the object tracks are.
7
+ // The viewport (physics-overlay.jsx) and the export path (syncPhysicsOverlays, App's applyExportFrame) pose it for a
8
+ // frame the same way, so scrub, playback and export draw the same overlay.
9
+ import * as THREE from "three";
10
+ import { comAt, supportAt, airArcs } from "./motion3/physics-view.js";
11
+ import { tipTrails, TRAIL_FRAMES } from "./motion3/physics-trail.js";
12
+
13
+ const PHYSICS_COLORS = Object.freeze({ inside: "#2ee66b", outside: "#ff2d2d", air: "#9aa0a6", supported: "#9aa0a6", support: "#ffd84a", path: "#ffffff", fit: "#ff8a1f", trail: "#3cc8ff" });
14
+ const ORDER = 70, LINE = 0.006, DOT = 0.011, COM_RADIUS = 0.02, LIFT = 0.004;
15
+ const noRay = () => {};
16
+ const UP = new THREE.Vector3(0, 1, 0), a3 = new THREE.Vector3(), b3 = new THREE.Vector3(), d3 = new THREE.Vector3(), m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion(), s3 = new THREE.Vector3();
17
+ const tube = new THREE.CylinderGeometry(1, 1, 1, 8, 1, false), dot = new THREE.SphereGeometry(1, 12, 8);
18
+
19
+ function claim(node, layer) {
20
+ node.traverse((n) => { n.layers.set(layer); n.raycast = noRay; n.castShadow = n.receiveShadow = false; n.renderOrder = ORDER; n.frustumCulled = false; });
21
+ return node;
22
+ }
23
+ const material = (color) => new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.95, depthTest: false, depthWrite: false, toneMapped: false, fog: false });
24
+
25
+ /** A polyline drawn as tube segments (one instance each); dashed draws every other one. */
26
+ class Tubes {
27
+ constructor(color, capacity, radius = LINE) {
28
+ this.mesh = new THREE.InstancedMesh(tube, material(color), capacity);
29
+ this.mesh.count = 0;
30
+ this.radius = radius;
31
+ }
32
+ set(points, { closed = false, dashed = false } = {}) {
33
+ const n = points.length, segments = closed && n > 2 ? n : n - 1;
34
+ let count = 0;
35
+ for (let i = 0; i < segments && count < this.mesh.instanceMatrix.count; i++) {
36
+ if (dashed && i % 2) continue;
37
+ a3.fromArray(points[i]); b3.fromArray(points[(i + 1) % n]);
38
+ const length = d3.subVectors(b3, a3).length();
39
+ if (length < 1e-6) continue;
40
+ q4.setFromUnitVectors(UP, d3.divideScalar(length));
41
+ m4.compose(a3.add(b3).multiplyScalar(0.5), q4, s3.set(this.radius, length, this.radius));
42
+ this.mesh.setMatrixAt(count++, m4);
43
+ }
44
+ this.mesh.count = count;
45
+ this.mesh.instanceMatrix.needsUpdate = true;
46
+ }
47
+ dispose() { this.mesh.material.dispose(); this.mesh.dispose(); }
48
+ }
49
+
50
+ /** A dot per point (a frame of an arc: their spacing is the speed; a still path is one dot). */
51
+ class Dots extends Tubes {
52
+ constructor(color, capacity, radius = DOT) {
53
+ super(color, capacity, radius);
54
+ this.mesh.geometry = dot;
55
+ }
56
+ set(points) {
57
+ points.forEach((p, i) => this.mesh.setMatrixAt(i, m4.makeScale(this.radius, this.radius, this.radius).setPosition(p[0], p[1], p[2])));
58
+ this.mesh.count = points.length;
59
+ this.mesh.instanceMatrix.needsUpdate = true;
60
+ }
61
+ }
62
+
63
+ export class PhysicsOverlay {
64
+ constructor({ layer }) {
65
+ this.layer = layer;
66
+ this.object = new THREE.Group();
67
+ this.object.name = "physics-overlay";
68
+ this.body = new THREE.Group();
69
+ this.body.matrixAutoUpdate = false;
70
+ this.world = new THREE.Group();
71
+ this.object.add(this.body, this.world);
72
+ this.ball = new THREE.Mesh(new THREE.SphereGeometry(COM_RADIUS, 20, 14), material(PHYSICS_COLORS.inside));
73
+ this.drop = new Tubes(PHYSICS_COLORS.inside, 1, LINE * 0.6);
74
+ this.drop.mesh.material.dispose();
75
+ this.drop.mesh.material = this.ball.material;
76
+ this.support = new Tubes(PHYSICS_COLORS.support, 64);
77
+ this.arcs = new THREE.Group();
78
+ this.body.add(this.ball, this.drop.mesh, this.support.mesh, this.arcs);
79
+ this.trails = [];
80
+ this.arcView = null;
81
+ this.arcTubes = [];
82
+ claim(this.object, layer);
83
+ this.last = null;
84
+ }
85
+
86
+ /** Draw the air arcs of `view` (rebuilt only when the view changes). */
87
+ setArcs(view) {
88
+ if (this.arcView === view) return;
89
+ this.arcView = view;
90
+ for (const t of this.arcTubes) { this.arcs.remove(t.mesh); t.dispose(); }
91
+ this.arcTubes = [];
92
+ for (const arc of view ? airArcs(view) : []) {
93
+ const path = new Tubes(PHYSICS_COLORS.path, arc.path.length), fit = new Tubes(PHYSICS_COLORS.fit, arc.fit.length);
94
+ const pathDots = new Dots(PHYSICS_COLORS.path, arc.path.length, DOT * 0.8), fitDots = new Dots(PHYSICS_COLORS.fit, arc.fit.length);
95
+ path.set(arc.path, { dashed: true });
96
+ fit.set(arc.fit);
97
+ pathDots.set(arc.path);
98
+ fitDots.set(arc.fit);
99
+ for (const t of [fit, fitDots, path, pathDots]) { this.arcs.add(claim(t.mesh, this.layer)); this.arcTubes.push(t); }
100
+ // the path over the arc: where the hips stay put, the white dot shows inside the orange one
101
+ path.mesh.renderOrder = pathDots.mesh.renderOrder = ORDER + 1;
102
+ }
103
+ }
104
+
105
+ setTrails(trails) {
106
+ while (this.trails.length < trails.length) {
107
+ const t = new Tubes(PHYSICS_COLORS.trail, TRAIL_FRAMES, LINE * 0.75);
108
+ this.world.add(claim(t.mesh, this.layer));
109
+ this.trails.push(t);
110
+ }
111
+ this.trails.forEach((t, i) => { t.mesh.visible = !!trails[i]; if (trails[i]) t.set(trails[i].points); });
112
+ }
113
+
114
+ /**
115
+ * Pose the overlay for take frame `frame` of `view` (physics-view.js; null hides it) with parts `settings`
116
+ * ({ com, support, arc, trail }). placement: the matrix from where the take was compiled to where the character
117
+ * stands now. Answers what it drew (QA hooks read it).
118
+ */
119
+ update({ view, frame, placement, settings }) {
120
+ const on = !!view && (settings.com || settings.support || settings.arc || settings.trail);
121
+ this.object.visible = on;
122
+ if (!on) { this.last = null; return null; }
123
+ if (placement) { this.body.matrix.copy(placement); this.body.matrixWorldNeedsUpdate = true; }
124
+ const com = comAt(view, frame), poly = settings.support ? supportAt(view, frame) : [], trails = settings.trail ? tipTrails(view, frame) : [];
125
+ this.ball.visible = this.drop.mesh.visible = settings.com;
126
+ if (settings.com) {
127
+ this.ball.material.color.set(PHYSICS_COLORS[com.state]);
128
+ this.ball.position.fromArray(com.p);
129
+ this.drop.set([com.p, [com.p[0], com.ground + LIFT, com.p[2]]]);
130
+ }
131
+ this.support.mesh.visible = poly.length > 0;
132
+ this.support.set(poly.map(([x, y, z]) => [x, y + LIFT, z]), { closed: true });
133
+ this.arcs.visible = settings.arc;
134
+ this.setArcs(settings.arc ? view : null);
135
+ this.setTrails(trails);
136
+ this.last = { frame: com.frame, com, support: poly, arcs: settings.arc ? airArcs(view).map(({ a, b, float, worst }) => ({ a, b, float, worst })) : [], trails: trails.map(t => ({ id: t.id, points: t.points.length })) };
137
+ return this.last;
138
+ }
139
+
140
+ dispose() {
141
+ this.setArcs(null);
142
+ for (const t of [this.drop, this.support, ...this.trails]) t.dispose();
143
+ this.ball.geometry.dispose();
144
+ this.trails = [];
145
+ }
146
+ }
147
+
148
+ const live = new Set();
149
+ /** Register a viewport overlay's sync(frame) (physics-overlay.jsx); answers the unregister. */
150
+ export function addPhysicsOverlaySync(sync) { live.add(sync); return () => live.delete(sync); }
151
+ /** Pose every mounted overlay for timeline frame `frame` (the export path, before it renders), or the playhead with null. */
152
+ export function syncPhysicsOverlays(frame = null) { for (const sync of live) sync(frame); }
@@ -0,0 +1,54 @@
1
+ // The physics overlay (physics-overlay.js) over the active character's take v3. It lives on SHOT_LAYER: the shot
2
+ // camera, the editor and pose views draw it, and so do captures, contact sheets and exports (the capture camera
3
+ // drops only GIZMO_LAYER), never the top view. Off by default; motion.physicsOverlay and the motion controls'
4
+ // Physics box turn it on (physics-overlay-settings.js). Every rendered frame poses it for the playhead; the export
5
+ // path poses it for the frame it records (syncPhysicsOverlays), so scrub, playback and export agree.
6
+ import { useEffect, useMemo, useRef } from "react";
7
+ import * as THREE from "three";
8
+ import { useFrame, useThree } from "@react-three/fiber";
9
+ import { SHOT_LAYER } from "./dualview.jsx";
10
+ import { PhysicsOverlay, addPhysicsOverlaySync } from "./physics-overlay.js";
11
+ import { physicsView } from "./motion3/physics-view.js";
12
+ import { isTakeV3 } from "./motion3/playback.js";
13
+ import { playbackFrame } from "./motion/playback-frame-store.js";
14
+ import { physicsOverlayOn, physicsOverlaySettings, usePhysicsOverlaySettings } from "./physics-overlay-settings.js";
15
+
16
+ const compiled = new THREE.Matrix4(), placement = new THREE.Matrix4();
17
+
18
+ /** From where `take` was compiled (take.placement) to where the rig's parent (the character's placement) stands now. */
19
+ function placementOf(rig, take) {
20
+ const at = take.placement?.position ?? [0, 0, 0];
21
+ compiled.makeRotationY(((take.placement?.yaw ?? 0) * Math.PI) / 180).setPosition(at[0], at[1], at[2]).invert();
22
+ rig.parent.updateWorldMatrix(true, false);
23
+ return placement.multiplyMatrices(rig.parent.matrixWorld, compiled);
24
+ }
25
+
26
+ /** rig: the active character's live rig; take: its installed motion; objects: the authored records. */
27
+ export function PhysicsOverlayLayer({ rig, take, objects }) {
28
+ const settings = usePhysicsOverlaySettings();
29
+ const scene = useThree((state) => state.scene), invalidate = useThree((state) => state.invalidate);
30
+ const overlay = useMemo(() => new PhysicsOverlay({ layer: SHOT_LAYER }), []);
31
+ const on = physicsOverlayOn(settings) && !!rig && isTakeV3(take);
32
+ const view = useMemo(() => (on ? physicsView(take, objects) : null), [on, take, objects]);
33
+ const latest = useRef({});
34
+ useEffect(() => { latest.current = { rig, take, view }; });
35
+ useEffect(() => {
36
+ if (!on) return undefined;
37
+ scene.add(overlay.object);
38
+ return () => { scene.remove(overlay.object); };
39
+ }, [on, scene, overlay]);
40
+ useEffect(() => () => overlay.dispose(), [overlay]);
41
+ useEffect(() => { invalidate(); }, [invalidate, settings, view]);
42
+ const sync = useMemo(() => (frame) => {
43
+ const { rig: body, take: shown, view: measured } = latest.current;
44
+ const ready = measured && body?.parent;
45
+ return overlay.update({ view: ready ? measured : null, frame: frame ?? playbackFrame(), placement: ready ? placementOf(body, shown) : null, settings: physicsOverlaySettings() });
46
+ }, [overlay]);
47
+ useEffect(() => {
48
+ // QA hook: the overlay, what it drew last, and a way to pose it for a frame
49
+ if (typeof window !== "undefined") window.__cozyclayPhysics = { overlay, sync, drawn: () => overlay.last };
50
+ return addPhysicsOverlaySync(sync);
51
+ }, [overlay, sync]);
52
+ useFrame(() => { sync(null); });
53
+ return null;
54
+ }
@@ -0,0 +1,20 @@
1
+ // The motion controls' Physics box: the whole physics overlay on or off, through motion.physicsOverlay (the agent's
2
+ // door to the same setting, physics-overlay-settings.js).
3
+ import { ko } from "./locale.js";
4
+ import { useStudioShell } from "./shell/studio-shell-context.js";
5
+ import { physicsOverlayOn, setPhysicsOverlay, usePhysicsOverlaySettings } from "./physics-overlay-settings.js";
6
+
7
+ export function PhysicsToggle() {
8
+ const shell = useStudioShell();
9
+ const on = physicsOverlayOn(usePhysicsOverlaySettings());
10
+ const toggle = () => (shell?.runStudioAction ? shell.runStudioAction("motion.physicsOverlay", { all: !on }) : setPhysicsOverlay({ all: !on }));
11
+ return (
12
+ <label className={"tl-btn ik physics" + (on ? " on" : "")} data-testid="physics-overlay-toggle"
13
+ title={ko("Physics — draw the centre of mass (green inside the feet, red past the balance limit, grey in the air), the support polygon, the gravity arc of each jump against the hips' path, and spring trails",
14
+ "물리 — 무게중심(발 안이면 초록, 균형 한계 밖이면 빨강, 공중이면 회색), 지지 다각형, 점프마다 중력 포물선과 엉덩이 궤적, 스프링 궤적을 그립니다",
15
+ "物理 — 绘制重心(脚内为绿色,超出平衡极限为红色,空中为灰色)、支撑多边形、每次跳跃的重力抛物线与髋部轨迹,以及弹簧轨迹")}>
16
+ <input type="checkbox" checked={on} onChange={toggle} aria-label={ko("Physics overlay", "물리 오버레이", "物理叠加层")} />
17
+ {ko("Physics", "물리", "物理")}
18
+ </label>
19
+ );
20
+ }
package/src/pose-fade.js CHANGED
@@ -15,6 +15,8 @@ export function writePoseFadeOpacity(value, storage = globalThis.localStorage) {
15
15
  // Keyed by material: a material shared by several meshes is recorded once, so the restore is exact.
16
16
  const originals = new WeakMap();
17
17
  const noRaycast = () => {};
18
+ // Keyed by mesh, not userData: R3F replaces an inline userData prop on re-render (#974).
19
+ const savedRaycasts = new WeakMap();
18
20
 
19
21
  function materialsOf(mesh) {
20
22
  return Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
@@ -67,11 +69,13 @@ export function applyPoseFade(root, fade, own = 1) {
67
69
  }
68
70
  }
69
71
  if (unpickable && node.raycast !== noRaycast) {
70
- node.userData.poseFadeRaycast = node.raycast;
72
+ if (Object.hasOwn(node, "raycast")) savedRaycasts.set(node, node.raycast);
71
73
  node.raycast = noRaycast;
72
74
  } else if (!unpickable && node.raycast === noRaycast) {
73
- node.raycast = node.userData.poseFadeRaycast;
74
- delete node.userData.poseFadeRaycast;
75
+ // Nothing saved means the prototype's method was in use: drop the own property.
76
+ if (savedRaycasts.has(node)) node.raycast = savedRaycasts.get(node);
77
+ else delete node.raycast;
78
+ savedRaycasts.delete(node);
75
79
  }
76
80
  });
77
81
  }
@@ -6,6 +6,7 @@ import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
6
6
  import { poseThumbnail, warmThumbnailModels } from "./pose-thumbs.js";
7
7
  import { IK_TRACKS, MID_TRACKS, ikControlIsExposed } from "./motion/ik.js";
8
8
  import { pickIkHit } from "./motion/ik-pick.js";
9
+ import { blockerDistanceTo, exposureProbe } from "./motion/ik-exposure.js";
9
10
  import { shouldRefreshIkExposure } from "./use-render-activity.js";
10
11
  import { POSER_LAYER } from "./dualview.jsx";
11
12
  import { ko } from "./locale.js";
@@ -283,7 +284,7 @@ const FK_ROTATE_SPEED = 5;
283
284
  * Every drag move calls `onSolve(kind, trackId, targetWorld)`; the drag end
284
285
  * calls `onDragEnd(trackId)` so the caller can key it.
285
286
  */
286
- export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd, selectionOnly = false, onPartPick }) {
287
+ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd, selectionOnly = false, onPartPick, focusToolsHidden = false, exposureProbeRef }) {
287
288
  const { camera, gl, raycaster, scene } = useThree();
288
289
  const handleRefs = useRef({}); // id -> mesh (all sphere handles)
289
290
  const handleMetaRef = useRef(new Map()); // id -> { track, radius }
@@ -298,6 +299,9 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
298
299
  const blockerRefs = useRef([]);
299
300
  const skinnedBlockerProxiesRef = useRef(new Map());
300
301
  const occlusionRay = useRef(new THREE.Raycaster()).current;
302
+ // The last pass's blockers, so the fingertip handles can ask the same exposure question (#913).
303
+ const occludersRef = useRef(null);
304
+ useEffect(() => { if (exposureProbeRef) exposureProbeRef.current = exposureProbe(camera, occludersRef); }, [exposureProbeRef, camera]);
301
305
  const gizmoRef = useRef(null);
302
306
  const ringRef = useRef(null); // rotation rings around the focused joint
303
307
  const readoutRef = useRef(null);
@@ -405,7 +409,7 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
405
409
  tmp.target.copy(tmp.hit).add(d.offset);
406
410
  }
407
411
  if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
408
- solveRef.current?.(d.kind, d.trackId, tmp.target);
412
+ solveRef.current?.(d.kind, d.trackId, tmp.target, { bypass: ev.altKey });
409
413
  };
410
414
  const onUp = (ev) => {
411
415
  // Detach this drag's listeners but KEEP the handler pair — nulling
@@ -679,28 +683,9 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
679
683
  mesh.getWorldPosition(tmp.viewPoint);
680
684
  const targetDistance = tmp.cameraPos.distanceTo(tmp.viewPoint);
681
685
  tmp.rayDirection.copy(tmp.viewPoint).sub(tmp.cameraPos).normalize();
682
- occlusionRay.set(tmp.cameraPos, tmp.rayDirection);
683
- occlusionRay.near = 0.001;
684
- occlusionRay.far = Math.max(0.001, targetDistance - 0.001);
685
- let blockerDistance = occlusionRay.intersectObjects(staticBlockers, false)[0]?.distance;
686
- // A coarse cloud of baked skin-surface spheres preserves the
687
- // actual animated silhouette without asking Three.js to skin
688
- // every triangle once per control ray. Camera-only changes now
689
- // test a few cached points; pose changes rebuild the cloud once.
690
- for (const proxy of skinnedProxies) {
691
- const radiusSq = proxy.radius * proxy.radius;
692
- for (const point of proxy.points) {
693
- const dx = point.x - tmp.cameraPos.x;
694
- const dy = point.y - tmp.cameraPos.y;
695
- const dz = point.z - tmp.cameraPos.z;
696
- const along = dx * tmp.rayDirection.x + dy * tmp.rayDirection.y + dz * tmp.rayDirection.z;
697
- if (along < 0 || along > targetDistance + proxy.radius) continue;
698
- const perpendicularSq = dx * dx + dy * dy + dz * dz - along * along;
699
- if (perpendicularSq > radiusSq) continue;
700
- const distance = Math.max(0, along - Math.sqrt(radiusSq - perpendicularSq));
701
- if (distance < (blockerDistance ?? Infinity)) blockerDistance = distance;
702
- }
703
- }
686
+ // A coarse cloud of baked skin-surface spheres preserves the animated
687
+ // silhouette; camera-only changes test a few cached points, pose changes rebuild the cloud once.
688
+ const blockerDistance = blockerDistanceTo(occlusionRay, tmp.cameraPos, tmp.rayDirection, targetDistance, staticBlockers, skinnedProxies);
704
689
  const visibilityDepth = meta?.track.visibilityDepth ?? (meta?.radius ?? JOINT_R) * 2;
705
690
  const isExposed = ikControlIsExposed(targetDistance, blockerDistance, visibilityDepth);
706
691
  visibilityDetailRef.current.set(id, {
@@ -719,6 +704,7 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
719
704
  tmp.viewPoint.applyMatrix4(camera.matrixWorldInverse);
720
705
  exposureDepthRef.current.set(id, -tmp.viewPoint.z);
721
706
  }
707
+ occludersRef.current = { blockers: [...blockers] };
722
708
  exposurePerfRef.current.passes += 1;
723
709
  exposurePerfRef.current.raycasts += Object.keys(handleRefs.current).length;
724
710
  exposurePerfRef.current.lastPassMs = performance.now() - passStartedAt;
@@ -757,7 +743,8 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
757
743
  }
758
744
  // A focused control keeps its gizmo and rings even when the body hides it:
759
745
  // the user picked it, and while focused nothing else can take a press.
760
- const focusExposed = !!focus;
746
+ // Finger editing hands the focused hand's space to the fingertip handles: no gizmo, rings or readout.
747
+ const focusExposed = !!focus && !focusToolsHidden;
761
748
  if (gizmoRef.current) gizmoRef.current.visible = focusExposed;
762
749
  if (ringRef.current) ringRef.current.visible = focusExposed;
763
750
 
@@ -947,7 +934,7 @@ const CLICK_PX = 4;
947
934
  handleRefs.current[p.track.id]?.visible &&
948
935
  (handleRefs.current[p.track.id]?.userData.ikExposed !== false || !p.part || p.track.id === focused)
949
936
  );
950
- if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused);
937
+ if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused && !(focusToolsHidden && (p.axisDir || p.part)));
951
938
  else picks = picks.filter((p) => !p.axisDir); // spheres only
952
939
  if (!picks.length) {
953
940
  if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
@@ -1059,7 +1046,7 @@ const CLICK_PX = 4;
1059
1046
  <group>
1060
1047
  {IK_TRACKS.map((track) => (chains.has(track.id) ? sphereFor(track, "chain", sideColor(track.id), HANDLE_R) : null))}
1061
1048
  {MID_TRACKS.map((track) => (chains.has(track.chain) ? sphereFor(track, "mid", sideColor(track.id), JOINT_R) : null))}
1062
- {[...(fkJoints ?? [])].map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", sideColor(id), id === "hips" ? HANDLE_R : JOINT_R))}
1049
+ {[...(fkJoints ?? [])].filter(([, joint]) => !joint.track.finger).map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", sideColor(id), id === "hips" ? HANDLE_R : JOINT_R))}
1063
1050
 
1064
1051
  {gizmoTrack && (
1065
1052
  <group ref={gizmoRef} renderOrder={999}>
@@ -1141,7 +1128,7 @@ const CLICK_PX = 4;
1141
1128
  })}
1142
1129
  </group>
1143
1130
  )}
1144
- {rotateTrack && rotationDeg && (
1131
+ {rotateTrack && rotationDeg && !focusToolsHidden && (
1145
1132
  <group ref={readoutRef}>
1146
1133
  <Html zIndexRange={[20, 0]} style={{ pointerEvents: "none" }}>
1147
1134
  <RotationReadout readout={rotationReadout(rotationDeg, rotationDrag)} />
@@ -215,6 +215,12 @@
215
215
  box-shadow: inset 0 0 0 var(--start-border-width, 1px) var(--row-divider, #1d1d20);
216
216
  }
217
217
 
218
+ /* Cast style: a three-up switch under the mode switch. */
219
+ .v2-start-screen .v2-start-mode.cast {
220
+ grid-template-columns: repeat(3, minmax(0, 1fr));
221
+ margin-top: var(--sp-1, 8px);
222
+ }
223
+
218
224
  .v2-start-screen .v2-start-mode-option {
219
225
  min-width: var(--start-template-min-width, 0);
220
226
  display: flex;
@@ -13,8 +13,10 @@ import {
13
13
  requestHandlePermission,
14
14
  storeProjectsDirectory,
15
15
  DEFAULT_PREVIS_MODE,
16
+ DEFAULT_CAST_STYLE,
16
17
  } from "./project.js";
17
18
  import { BrandMark, TemplateThumb } from "./project-browser-art.jsx";
19
+ import { CAST_OPTIONS, StartChoiceGroup } from "./project-start-choices.jsx";
18
20
  import "./project-browser.css";
19
21
 
20
22
  const APP_VERSION = packageInfo.version;
@@ -105,6 +107,7 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
105
107
  export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose, startup = false, starters = [], onStarter }) {
106
108
  const starterTemplates = useMemo(() => starters.map(sampleTemplate), [starters]);
107
109
  const [previsMode, setPrevisMode] = useState(DEFAULT_PREVIS_MODE);
110
+ const [castStyle, setCastStyle] = useState(DEFAULT_CAST_STYLE);
108
111
  const templates = previsMode === "storyboard" ? [] : starterTemplates;
109
112
  const defaultTemplate = starterTemplates[0] ?? NEW_PROJECT;
110
113
  const [activeNav, setActiveNav] = useState(startup ? "new" : "recent");
@@ -211,11 +214,11 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
211
214
  const projectName = name.trim();
212
215
  if (!projectName) return;
213
216
  if (selectedTemplate?.starterId) {
214
- onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation" });
217
+ onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation", castStyle });
215
218
  return;
216
219
  }
217
220
  stagePendingProjectName(projectName);
218
- onNew(projectName, { previsMode });
221
+ onNew(projectName, { previsMode, castStyle });
219
222
  };
220
223
 
221
224
  const recentEntries = [
@@ -260,22 +263,10 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
260
263
  {activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
261
264
  </div>
262
265
  {activeNav === "new" && (
263
- <div className="v2-start-mode" role="radiogroup" aria-label={ko("Project mode", "프로젝트 모드")} data-testid="start-previs-mode">
264
- {MODE_OPTIONS.map((option) => (
265
- <button
266
- type="button"
267
- key={option.id}
268
- role="radio"
269
- aria-checked={previsMode === option.id}
270
- className={`v2-start-mode-option${previsMode === option.id ? " active" : ""}`}
271
- data-previs-mode={option.id}
272
- onClick={() => choosePrevisMode(option.id)}
273
- >
274
- <strong>{option.label()}</strong>
275
- <span>{option.blurb()}</span>
276
- </button>
277
- ))}
278
- </div>
266
+ <>
267
+ <StartChoiceGroup label={ko("Project mode", "프로젝트 모드")} testId="start-previs-mode" dataKey="previs-mode" options={MODE_OPTIONS} value={previsMode} onChange={choosePrevisMode} />
268
+ <StartChoiceGroup label={ko("Cast style", "캐스트 방식")} testId="start-cast-style" dataKey="cast-style" options={CAST_OPTIONS} value={castStyle} onChange={setCastStyle} className="cast" />
269
+ </>
279
270
  )}
280
271
  </div>
281
272
 
@@ -0,0 +1,45 @@
1
+ import { ko } from "./locale.js";
2
+
3
+ // Which bodies a new project is cast with (castStyle in project.js). The rig
4
+ // is the detailed humanoid, the capsule the quick stand-in; hybrid mixes both
5
+ // and is the default. Either body stays reachable from Add in every style.
6
+ export const CAST_OPTIONS = [
7
+ {
8
+ id: "rig",
9
+ label: () => ko("Humanoid", "휴머노이드"),
10
+ blurb: () => ko("Detailed previs with X/Y Bot: pose, IK and motion.", "X/Y Bot으로 디테일한 프리비즈: 포즈, IK, 모션."),
11
+ },
12
+ {
13
+ id: "capsule",
14
+ label: () => ko("Capsule", "캡슐"),
15
+ blurb: () => ko("Simple previs with capsule figures: path and posture.", "캡슐 인물로 간단한 프리비즈: 경로와 자세."),
16
+ },
17
+ {
18
+ id: "hybrid",
19
+ label: () => ko("Hybrid", "하이브리드"),
20
+ blurb: () => ko("Mix both; the agent may use capsules for hard shots.", "둘을 섞어 씁니다. 어려운 샷은 에이전트가 캡슐을 쓸 수 있어요."),
21
+ },
22
+ ];
23
+
24
+ // One segmented single-choice control on the New view. Each option carries
25
+ // `data-<dataKey>` with its id so tests and styles can address it.
26
+ export function StartChoiceGroup({ label, testId, dataKey, options, value, onChange, className = "" }) {
27
+ return (
28
+ <div className={`v2-start-mode${className ? ` ${className}` : ""}`} role="radiogroup" aria-label={label} data-testid={testId}>
29
+ {options.map((option) => (
30
+ <button
31
+ type="button"
32
+ key={option.id}
33
+ role="radio"
34
+ aria-checked={value === option.id}
35
+ className={`v2-start-mode-option${value === option.id ? " active" : ""}`}
36
+ {...{ [`data-${dataKey}`]: option.id }}
37
+ onClick={() => onChange(option.id)}
38
+ >
39
+ <strong>{option.label()}</strong>
40
+ <span>{option.blurb()}</span>
41
+ </button>
42
+ ))}
43
+ </div>
44
+ );
45
+ }
package/src/project.js CHANGED
@@ -25,9 +25,12 @@ export const PROJECT_VERSION = 5;
25
25
  export const PREVIS_MODES = Object.freeze(["storyboard", "animation"]);
26
26
  export const DEFAULT_PREVIS_MODE = "animation";
27
27
 
28
- export function normalizePrevisMode(value) {
29
- return PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE;
30
- }
28
+ export const normalizePrevisMode = (value) => (PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE);
29
+ // Which bodies the project is cast with: rigged humanoids for detailed previs,
30
+ // capsule figures for simple previs, or a mix. A file without the field
31
+ // predates the choice and already holds a rig character, so it reads as hybrid.
32
+ export const CAST_STYLES = Object.freeze(["rig", "capsule", "hybrid"]), DEFAULT_CAST_STYLE = "hybrid";
33
+ export const normalizeCastStyle = (value) => (CAST_STYLES.includes(value) ? value : DEFAULT_CAST_STYLE);
31
34
  export const PROJECT_EXTENSION = ".cclayproject";
32
35
  export const WORKFLOW_VERSION = 1;
33
36
  export const WORKFLOW_STORAGE_KEY = "cozyclay.workflow.v1";
@@ -301,7 +304,7 @@ function readEmbeddedAssets(value, report) {
301
304
  * assetRef references them. Throws `resources-too-large` when the whole
302
305
  * manifest exceeds its budget.
303
306
  */
304
- export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, assets, savedAt, workflow }) {
307
+ export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, castStyle, assets, savedAt, workflow }) {
305
308
  const assetRecords = new Map();
306
309
  for (const record of Array.isArray(assets) ? assets : []) {
307
310
  const asset = embeddedAsset(record);
@@ -323,7 +326,7 @@ export function createProjectDocument({ scenesDocument, workspaceLayout, customP
323
326
  kind: "project",
324
327
  version: PROJECT_VERSION,
325
328
  name: typeof name === "string" && name.trim() ? name.trim() : "Untitled",
326
- previsMode: normalizePrevisMode(previsMode),
329
+ previsMode: normalizePrevisMode(previsMode), castStyle: normalizeCastStyle(castStyle),
327
330
  ...(Number.isFinite(savedAt) && savedAt > 0 ? { savedAt } : {}),
328
331
  scenes: scenesDocument,
329
332
  workspace: workspaceLayout ?? null,
@@ -355,7 +358,7 @@ export function buildAnimationProjectFromStoryboard(project) {
355
358
  workspaceLayout: structuredClone(project?.workspaceLayout ?? null),
356
359
  customPoses: structuredClone(project?.customPoses ?? []),
357
360
  name: `${project?.name || "Untitled"} - ${ko("Animation", "애니메이션")}`,
358
- previsMode: "animation",
361
+ previsMode: "animation", castStyle: project?.castStyle,
359
362
  assets: project?.assets ?? [],
360
363
  workflow: structuredClone(project?.workflow ?? createWorkflowGraph()),
361
364
  savedAt: Date.now(),
@@ -367,8 +370,8 @@ export function buildAnimationProjectFromStoryboard(project) {
367
370
  * problems?, project? }. Invalid embedded records are skipped, each one
368
371
  * reported as a warning string and a `{ kind, id, code, message }` problem.
369
372
  * v2/v3 files carry images at the top-level `assets`; v4 moves everything
370
- * under `resources`; v5 adds the top-level `previsMode` (absent or unknown
371
- * reads as the default). Older files are read as-is, never rewritten.
373
+ * under `resources`; v5 adds the top-level `previsMode` and `castStyle`
374
+ * (absent or unknown reads as the default). Older files are read as-is, never rewritten.
372
375
  */
373
376
  export function readProjectDocument(raw) {
374
377
  let parsed;
@@ -402,7 +405,7 @@ export function readProjectDocument(raw) {
402
405
  problems,
403
406
  project: {
404
407
  name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : "Untitled",
405
- previsMode: normalizePrevisMode(parsed.previsMode),
408
+ previsMode: normalizePrevisMode(parsed.previsMode), castStyle: normalizeCastStyle(parsed.castStyle),
406
409
  savedAt: Number.isFinite(parsed.savedAt) && parsed.savedAt > 0 ? parsed.savedAt : null,
407
410
  scenesDocument: { version: scenes.version, activeSceneId: scenes.activeSceneId ?? null, scenes: scenes.scenes },
408
411
  workspaceLayout: parsed.workspace && typeof parsed.workspace === "object" ? parsed.workspace : null,
@@ -549,17 +552,18 @@ export function loadProjectSession(storage = globalThis.localStorage) {
549
552
  const value = JSON.parse(storage?.getItem(PROJECT_SESSION_KEY) || "null");
550
553
  if (!value || typeof value !== "object") return null;
551
554
  const name = typeof value.name === "string" ? value.name.trim() : "";
552
- return name ? { name, previsMode: normalizePrevisMode(value.previsMode), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
555
+ return name ? { name, previsMode: normalizePrevisMode(value.previsMode), castStyle: normalizeCastStyle(value.castStyle), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
553
556
  } catch {
554
557
  return null;
555
558
  }
556
559
  }
557
560
 
558
- export function storeProjectSession(name, previsMode = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
561
+ // `identity` is the previs mode, or `{ previsMode, castStyle }`.
562
+ export function storeProjectSession(name, identity = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
559
563
  const normalized = typeof name === "string" ? name.trim() : "";
560
564
  if (!normalized) return false;
561
565
  try {
562
- storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(previsMode), updatedAt: Date.now() }));
566
+ storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(identity?.previsMode ?? identity), castStyle: normalizeCastStyle(identity?.castStyle), updatedAt: Date.now() }));
563
567
  return true;
564
568
  } catch {
565
569
  return false;
@@ -0,0 +1,11 @@
1
+ // Capsule figure dimensions in metres, shared by the renderer
2
+ // (proxy-figure.jsx) and the rig-free bounds (character-kind.js).
3
+ export const PROXY_BODY_RADIUS = 0.22;
4
+ export const PROXY_BODY_HEIGHT = 1.45;
5
+ export const PROXY_HEAD_RADIUS = 0.12;
6
+ // Head centre sits this far above the top of the body or torso.
7
+ export const PROXY_HEAD_LIFT = 0.13;
8
+ // Sit is an L: a vertical torso at the hip and one leg capsule on the floor
9
+ // reaching this far forward of the hip, along the way the figure faces (+z).
10
+ export const PROXY_SIT_TORSO_HEIGHT = 0.85;
11
+ export const PROXY_SIT_LEG_REACH = 0.55;
@@ -1,10 +1,20 @@
1
1
  import { memo, useEffect, useMemo } from "react";
2
2
  import * as THREE from "three";
3
3
  import { defaultCharacterTint } from "./app-stage.jsx";
4
+ import {
5
+ PROXY_BODY_HEIGHT as BODY_HEIGHT, PROXY_BODY_RADIUS as BODY_RADIUS, PROXY_HEAD_LIFT,
6
+ PROXY_HEAD_RADIUS as HEAD_RADIUS, PROXY_SIT_LEG_REACH, PROXY_SIT_TORSO_HEIGHT,
7
+ } from "./proxy-figure-shape.js";
4
8
 
5
- const BODY_RADIUS = 0.22;
6
- const BODY_HEIGHT = 1.45;
7
- const HEAD_RADIUS = 0.12;
9
+ function addPart(group, geometry, material, position) {
10
+ const mesh = new THREE.Mesh(geometry, material);
11
+ mesh.position.set(...position);
12
+ mesh.castShadow = true;
13
+ mesh.receiveShadow = true;
14
+ mesh.frustumCulled = false;
15
+ group.add(mesh);
16
+ return mesh;
17
+ }
8
18
 
9
19
  export function proxyYaw(rot, posture = "stand") {
10
20
  return (rot * Math.PI) / 180 + (posture === "lie" ? Math.PI : 0);
@@ -19,8 +29,8 @@ export const ProxyFigure = memo(function ProxyFigure({
19
29
  pickId,
20
30
  selected,
21
31
  }) {
22
- const bodyHeight = posture === "sit" ? BODY_HEIGHT * 0.6 : BODY_HEIGHT;
23
- const headY = bodyHeight + 0.13;
32
+ const seated = posture === "sit";
33
+ const bodyHeight = seated ? PROXY_SIT_TORSO_HEIGHT : BODY_HEIGHT;
24
34
  const model = useMemo(() => {
25
35
  const bodyMaterial = new THREE.MeshStandardMaterial({
26
36
  color: tint ?? defaultCharacterTint({ model: "proxy-figure" }, 0),
@@ -29,20 +39,17 @@ export const ProxyFigure = memo(function ProxyFigure({
29
39
  envMapIntensity: 0.35,
30
40
  });
31
41
  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);
42
+ addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, bodyHeight - BODY_RADIUS * 2, 12, 20), bodyMaterial, [0, bodyHeight / 2, 0]);
43
+ addPart(group, new THREE.SphereGeometry(HEAD_RADIUS, 16, 12), bodyMaterial, [0, bodyHeight + PROXY_HEAD_LIFT, 0]);
44
+ if (seated) {
45
+ // Legs on the floor from behind the hip to PROXY_SIT_LEG_REACH ahead of
46
+ // it, along local +z: the way the figure (and a rig at the same rot) faces.
47
+ const legs = addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, PROXY_SIT_LEG_REACH - BODY_RADIUS, 12, 20), bodyMaterial,
48
+ [0, BODY_RADIUS, (PROXY_SIT_LEG_REACH - BODY_RADIUS) / 2]);
49
+ legs.rotation.x = Math.PI / 2;
50
+ }
44
51
  return group;
45
- }, [bodyHeight, headY, tint]);
52
+ }, [bodyHeight, seated, tint]);
46
53
  useEffect(() => () => {
47
54
  const materials = new Set();
48
55
  model.traverse(node => {