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.
- package/CHANGELOG.md +145 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +25 -17
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +3 -1
- package/src/App.jsx +42 -75
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/index.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/physics.js +21 -0
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +13 -14
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +21 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +32 -4
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +15 -28
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +53 -84
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-BLS1Zaru.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
- 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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
}
|
package/src/posestudio.jsx
CHANGED
|
@@ -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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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)} />
|
package/src/project-browser.css
CHANGED
|
@@ -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;
|
package/src/project-browser.jsx
CHANGED
|
@@ -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
|
-
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
29
|
-
|
|
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`
|
|
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
|
-
|
|
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;
|
package/src/proxy-figure.jsx
CHANGED
|
@@ -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
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
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
|
|
23
|
-
const
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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,
|
|
52
|
+
}, [bodyHeight, seated, tint]);
|
|
46
53
|
useEffect(() => () => {
|
|
47
54
|
const materials = new Set();
|
|
48
55
|
model.traverse(node => {
|