cozyclay 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
- import { frameFromClientX, promptMoveStartFrame, shotBlockGeometry } from "./timeline-coordinates.js";
2
+ import { frameFromClientX, motionTrimRange, promptMoveStartFrame, shotBlockGeometry } from "./timeline-coordinates.js";
3
3
  import { promptResizeFrame } from "./timeline-resize.js";
4
4
  import { ko, isKo } from "../locale.js";
5
5
 
@@ -18,8 +18,8 @@ import { ko, isKo } from "../locale.js";
18
18
  * and PlayView ride the keys segment by segment.
19
19
  */
20
20
 
21
- const DEFAULT_FRAME_COUNT = 300; // 15 s @ 20 fps, the ARDY Core cadence
22
- const DEFAULT_FPS = 20;
21
+ const DEFAULT_FRAME_COUNT = 360; // 15 s @ 24 fps, the production clock the app timeline runs on
22
+ const DEFAULT_FPS = 24;
23
23
  // Trackpad/wheel zoom over the FRAME ruler: the gesture changes only the
24
24
  // horizontal visual scale of the time surface — never frameCount, fps,
25
25
  // duration, waypoints or the generation request.
@@ -168,15 +168,24 @@ export default function Timeline({
168
168
  fps = DEFAULT_FPS,
169
169
  playbackSpeed = 1,
170
170
  playing,
171
+ // Which cast member's animation layer these tracks edit ("S2", …).
172
+ trackOwner = null,
173
+ // Read-only previews of the OTHER cast members' layers: their prompt
174
+ // blocks and root pins render dimmed with an owner chip, so the whole
175
+ // cast's schedule is visible while only the active layer is editable.
176
+ ghostLayers = [], // [{ owner, promptClips: [], waypointFrames: [] }]
171
177
  waypointMode,
172
178
  waypointFrames = [],
173
- pathSpeed = null,
179
+ pathSpeed = null, // { min, max, warn } in m/s, shown on the 2D Root label
174
180
  badge,
175
181
  promptClips = [],
176
182
  selectedPromptId,
177
183
  pendingWaypointFrame = null,
178
184
  ikMode = false,
179
185
  ikDisabled = false, // a loaded motion owns the rig
186
+ // The ACTIVE cast member's loaded take, drawn as a passive strip on the
187
+ // Full-Body lane. Frames are already on the timeline's 24 fps clock.
188
+ motion = null, // { frames, label } | null
180
189
  ikFrames = [], // sorted full-body key frames
181
190
  footSnap = true, // feet stay planted while the body moves
182
191
  shots = [],
@@ -224,6 +233,8 @@ export default function Timeline({
224
233
  onShotCut,
225
234
  onShotSplit,
226
235
  onShotMove,
236
+ onMotionTrim,
237
+ onMotionTrimReset,
227
238
  }) {
228
239
  const [expanded, setExpanded] = useState(true);
229
240
  const [zoom, setZoom] = useState(ZOOM_DEFAULT);
@@ -244,7 +255,7 @@ export default function Timeline({
244
255
  // The window key/interval handlers register once; the latest callbacks
245
256
  // are read through a ref so they never go stale mid-playback.
246
257
  const handlers = useRef({});
247
- handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove, onCameraBlockSelect, onCameraBlockChange, onCameraPreview, onCameraRailDrawToggle, onRailSelect, onRailMove, onRailRangeChange, onRailRemove, onShotSelect, onShotBoundaryMove, onShotRename, onShotRemove, onShotDuplicate, onShotCut, onShotSplit, onShotMove };
258
+ handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove, onCameraBlockSelect, onCameraBlockChange, onCameraPreview, onCameraRailDrawToggle, onRailSelect, onRailMove, onRailRangeChange, onRailRemove, onShotSelect, onShotBoundaryMove, onShotRename, onShotRemove, onShotDuplicate, onShotCut, onShotSplit, onShotMove, onMotionTrim, onMotionTrimReset };
248
259
 
249
260
  // Trackpad/wheel zoom over the FRAME ruler lane only. React registers
250
261
  // onWheel as passive, so a synthetic onWheel could never preventDefault —
@@ -484,6 +495,47 @@ export default function Timeline({
484
495
  e.currentTarget.releasePointerCapture?.(e.pointerId);
485
496
  }
486
497
 
498
+ // Motion trim: frame-accurate drag of the loaded take's in/out point on
499
+ // the Full-Body strip. No block snapping — a cut lands on the exact frame
500
+ // the hand releases. The preview lives here; the CUT itself is App's.
501
+ const motionTrimRef = useRef(null);
502
+ const [trimPreview, setTrimPreview] = useState(null);
503
+
504
+ function beginMotionTrim(e, edge) {
505
+ if (e.button !== 0 || !motion) return;
506
+ e.preventDefault();
507
+ e.stopPropagation();
508
+ e.currentTarget.setPointerCapture?.(e.pointerId);
509
+ const rect = e.currentTarget.closest(".tl-lane")?.getBoundingClientRect();
510
+ if (!rect || rect.width < 2) return;
511
+ const max = Math.min(motion.frames, displayFrameCount) - 1;
512
+ // The preview always restarts from the FULL take: App composes the cut
513
+ // as trimOffset + start, so an end-only drag must still report start 0.
514
+ motionTrimRef.current = { edge, rect, max, displayFrameCount, preview: { start: 0, end: max } };
515
+ setTrimPreview({ start: 0, end: max });
516
+ }
517
+
518
+ function moveMotionTrim(e) {
519
+ const active = motionTrimRef.current;
520
+ if (!active) return;
521
+ // Same pixel→frame transform as every other lane, off the pointer-down
522
+ // geometry, so the handle stays under the cursor at any zoom.
523
+ const frame = Math.min(active.max, frameFromClientX(e.clientX, active.rect.left, active.rect.width, active.displayFrameCount, frameCount));
524
+ const next = motionTrimRange(active.edge, frame, active.preview, active.max);
525
+ active.preview = next;
526
+ setTrimPreview(next);
527
+ }
528
+
529
+ function endMotionTrim(e) {
530
+ const active = motionTrimRef.current;
531
+ motionTrimRef.current = null;
532
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
533
+ setTrimPreview(null);
534
+ if (active && (active.preview.start > 0 || active.preview.end < active.max)) {
535
+ handlers.current.onMotionTrim?.(active.preview.start, active.preview.end);
536
+ }
537
+ }
538
+
487
539
  // Camera key dots re-time by dragging, like prompt clips move: the frame
488
540
  // delta comes from the pointer-down geometry so a growing timeline cannot
489
541
  // feed back into the next pointermove.
@@ -917,6 +969,17 @@ export default function Timeline({
917
969
  <div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "") + (name === SHOTS_LANE ? " shots" : "")} key={name}>
918
970
  <span className="tl-track-label">
919
971
  {TRACK_LABELS_KO[name]}
972
+ {trackOwner && (name === "Prompts" || name === "2D Root") && <em className="tl-track-owner">{trackOwner}</em>}
973
+ {name === "2D Root" && pathSpeed && (
974
+ <em
975
+ className={"tl-path-speed" + (pathSpeed.warn ? " warn" : "")}
976
+ title={isKo ? `핀 구간 속도 ${pathSpeed.min.toFixed(1)}~${pathSpeed.max.toFixed(1)} m/s — 자연 보행은 0.8~1.2 m/s` : `Leg speeds ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s — natural gait is 0.8–1.2 m/s`}
977
+ >
978
+ {pathSpeed.min === pathSpeed.max
979
+ ? `${pathSpeed.min.toFixed(1)} m/s`
980
+ : `${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s`}
981
+ </em>
982
+ )}
920
983
  {name === "Prompts" && <button className="tl-track-add" type="button" title={ko("Add 2 second prompt clip", "2초 프롬프트 클립 추가")} onClick={() => handlers.current.onPromptAdd?.(frame)}>+</button>}
921
984
  {name === SHOTS_LANE && (
922
985
  <button
@@ -1079,6 +1142,16 @@ export default function Timeline({
1079
1142
  </div>
1080
1143
  );
1081
1144
  })}
1145
+ {name === "Prompts" && ghostLayers.map((layer) => layer.promptClips.map((clip) => (
1146
+ <div
1147
+ key={`${layer.owner}:${clip.id}`}
1148
+ className="tl-chip ghost"
1149
+ style={{ "--tl-f-start": clipPct(clip.startFrame), "--tl-f-end": clipPct(clip.endFrame) }}
1150
+ title={`${layer.owner} · ${clip.text}`}
1151
+ >
1152
+ <span className="tl-chip-ghost-label">{layer.owner} · {clip.text || "…"}</span>
1153
+ </div>
1154
+ )))}
1082
1155
  {name === "Prompts" && promptClips.map((clip) => {
1083
1156
  const duration = ((clip.endFrame - clip.startFrame) / Math.max(1, fps)).toFixed(1);
1084
1157
  return (
@@ -1089,6 +1162,44 @@ export default function Timeline({
1089
1162
  </div>
1090
1163
  );
1091
1164
  })}
1165
+ {name === IK_LANE && motion && (
1166
+ <div
1167
+ className={"tl-motion-clip" + (trimPreview ? " trimming" : "")}
1168
+ style={{
1169
+ "--tl-f-start": clipPct(trimPreview ? trimPreview.start : 0),
1170
+ "--tl-f-end": clipPct(trimPreview ? trimPreview.end + 1 : Math.min(motion.frames, displayFrameCount)),
1171
+ }}
1172
+ title={isKo
1173
+ ? `불러온 테이크 — ${motion.frames}프레임. 양끝 핸들로 잘라내고, 핸들 우클릭으로 전체 길이 복원`
1174
+ : `Loaded take — ${motion.frames} frames. Drag the end handles to cut; right-click a handle to restore the full take`}
1175
+ >
1176
+ <button
1177
+ className="tl-motion-clip-handle start"
1178
+ type="button"
1179
+ aria-label={ko("Trim take start", "테이크 시작점 자르기")}
1180
+ onPointerDown={(e) => beginMotionTrim(e, "start")}
1181
+ onPointerMove={moveMotionTrim}
1182
+ onPointerUp={endMotionTrim}
1183
+ onPointerCancel={endMotionTrim}
1184
+ onContextMenu={(e) => { e.preventDefault(); handlers.current.onMotionTrimReset?.(); }}
1185
+ />
1186
+ <span className="tl-motion-clip-label">
1187
+ {trimPreview
1188
+ ? `${trimPreview.start}–${trimPreview.end} (${trimPreview.end - trimPreview.start + 1}f)`
1189
+ : motion.label}
1190
+ </span>
1191
+ <button
1192
+ className="tl-motion-clip-handle end"
1193
+ type="button"
1194
+ aria-label={ko("Trim take end", "테이크 끝점 자르기")}
1195
+ onPointerDown={(e) => beginMotionTrim(e, "end")}
1196
+ onPointerMove={moveMotionTrim}
1197
+ onPointerUp={endMotionTrim}
1198
+ onPointerCancel={endMotionTrim}
1199
+ onContextMenu={(e) => { e.preventDefault(); handlers.current.onMotionTrimReset?.(); }}
1200
+ />
1201
+ </div>
1202
+ )}
1092
1203
  {name === IK_LANE &&
1093
1204
  ikFrames.map((f) => (
1094
1205
  <span
@@ -1108,6 +1219,14 @@ export default function Timeline({
1108
1219
  }}
1109
1220
  />
1110
1221
  ))}
1222
+ {name === "2D Root" && ghostLayers.map((layer) => layer.waypointFrames.map((f) => (
1223
+ <span
1224
+ key={`${layer.owner}:${f}`}
1225
+ className="tl-marker wp ghost"
1226
+ style={{ "--tl-f": framePct(f, displayFrameCount) }}
1227
+ title={`${layer.owner} · frame ${f}`}
1228
+ />
1229
+ )))}
1111
1230
  {name === "2D Root" &&
1112
1231
  [...waypointFrames, ...(pendingWaypointFrame == null || waypointFrames.includes(pendingWaypointFrame) ? [] : [pendingWaypointFrame])].map((f) => (
1113
1232
  <span
@@ -3,11 +3,12 @@
3
3
  * reads (local_rot_mats 27x3x3, posed_joints 27x3; the generator then runs its
4
4
  * own FK, consulting only posed_joints[0] as the root).
5
5
  *
6
- * The 19 joints CozyClay authors map through each bone's armature-space rest
7
- * rotation Rb (cskel27-rest.json): L = Rb @ basis @ Rb^T. The other 8 joints
8
- * get identity, i.e. "at ARDY rest" — CozyClay does not author them and must
9
- * not invent them; `filled_identity` says which joints that happened for and
10
- * why, so callers can report it rather than guess.
6
+ * The 19 joints CozyClay authors map through each Mixamo bone's armature-space
7
+ * rest rotation Rb (cskel27-rest.json): L = Rb @ basis @ Rb^T. ARDY has four
8
+ * torso segments where Mixamo has three, so Mixamo Spine/Spine1 target ARDY
9
+ * Spine1/Spine2, matching playback's map. The remaining joints get identity;
10
+ * `filled_identity` records them and `rotation_constraint_indices` identifies
11
+ * the rotations that are genuinely authored.
11
12
  *
12
13
  * Proportions are never hardcoded: bone offsets come from the reference frame
13
14
  * of the same base clip the pose will constrain (mirroring
@@ -16,7 +17,11 @@
16
17
  * correct by construction.
17
18
  */
18
19
 
19
- import { COZYCLAY_BONES, CSKEL27_JOINTS } from "./cskel27.js";
20
+ import {
21
+ COZYCLAY_BONES,
22
+ COZYCLAY_TO_CSKEL27,
23
+ CSKEL27_JOINTS,
24
+ } from "./cskel27.js";
20
25
  import { CSKEL27_NEUTRAL } from "./cskel27-neutral.js";
21
26
  import {
22
27
  basisQuaternionToLocalRotation,
@@ -36,6 +41,13 @@ const IDENTITY = [
36
41
  [0, 0, 1],
37
42
  ];
38
43
 
44
+ const SOURCE_BY_TARGET = new Map(
45
+ COZYCLAY_BONES.map((source) => [
46
+ CSKEL27_JOINTS[COZYCLAY_TO_CSKEL27[source]],
47
+ source,
48
+ ])
49
+ );
50
+
39
51
  const CANONICAL_ROOT_Y = -Math.min(...CSKEL27_NEUTRAL.map((joint) => joint[1]));
40
52
 
41
53
  /** Floor-aligned CoreSkeleton27 reference used when no clip reference is
@@ -160,23 +172,28 @@ export function poseToCskel27({ pose, rest, reference }) {
160
172
  restByJoint.set(entry.name, entry.rest);
161
173
  }
162
174
 
163
- const authored = new Set(COZYCLAY_BONES);
164
175
  const local_rot_mats = new Array(CSKEL27_JOINTS.length);
165
176
  const filled_identity = [];
177
+ const rotation_constraint_indices = [];
166
178
  for (let index = 0; index < CSKEL27_JOINTS.length; index += 1) {
167
179
  const name = CSKEL27_JOINTS[index];
168
- const rb = restByJoint.get(name);
169
- if (authored.has(name) && rb !== null && rb !== undefined) {
180
+ const sourceName = SOURCE_BY_TARGET.get(name);
181
+ const rb = sourceName ? restByJoint.get(sourceName) : null;
182
+ if (sourceName && rb !== null && rb !== undefined) {
170
183
  // L = Rb @ basis @ Rb^T; basisQuaternionToLocalRotation is the
171
184
  // quatToMat + basisToLocal composition from convert.js.
172
- local_rot_mats[index] = basisQuaternionToLocalRotation(pose.bones[name], rb);
185
+ local_rot_mats[index] = basisQuaternionToLocalRotation(
186
+ pose.bones[sourceName],
187
+ rb
188
+ );
189
+ rotation_constraint_indices.push(index);
173
190
  } else {
174
191
  // Fresh identity per joint so callers can never alias a shared
175
192
  // constant by mutating one row.
176
193
  local_rot_mats[index] = IDENTITY.map((row) => row.slice());
177
194
  filled_identity.push({
178
195
  joint: name,
179
- reason: authored.has(name) ? "no rest rotation" : "not authored",
196
+ reason: sourceName ? "no rest rotation" : "not authored",
180
197
  });
181
198
  }
182
199
  }
@@ -201,5 +218,10 @@ export function poseToCskel27({ pose, rest, reference }) {
201
218
  const canonicalRoot = canonicalCskel27Reference().posed_joints[0];
202
219
  const posed_joints = forwardKinematics(local_rot_mats, offsets, pose.root ?? canonicalRoot);
203
220
 
204
- return { local_rot_mats, posed_joints, filled_identity };
221
+ return {
222
+ local_rot_mats,
223
+ posed_joints,
224
+ filled_identity,
225
+ rotation_constraint_indices,
226
+ };
205
227
  }
@@ -0,0 +1,33 @@
1
+ // trim.js — cut a loaded take to a frame range. The slice is a NEW motion
2
+ // object in the exact npz-decoded shape; the caller keeps the original, so
3
+ // trimming is non-destructive and re-trimming always cuts from the full
4
+ // take. Frame 0 of the slice becomes the playback anchor: applyMotionFrame
5
+ // re-anchors X/Z on the anchor frame, so a mid-clip cut plays at the
6
+ // character's placement instead of teleporting to the original frame 0.
7
+ // The spread carries every non-array field with the slice — `personScale`
8
+ // included: a cut is the same performer, and a take that lost its stature
9
+ // mid-edit would replay the remaining frames at the wrong stride length.
10
+
11
+ import { CSKEL27_JOINTS } from "./cskel27.js";
12
+
13
+ export function sliceMotion(motion, startFrame, endFrame) {
14
+ if (!motion || !Number.isInteger(motion.frames) || motion.frames < 1) {
15
+ throw new Error("sliceMotion: motion.frames must be a positive integer");
16
+ }
17
+ if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame)) {
18
+ throw new Error("sliceMotion: startFrame and endFrame must be integers");
19
+ }
20
+ if (startFrame < 0 || endFrame >= motion.frames || startFrame > endFrame) {
21
+ throw new Error(`sliceMotion: range ${startFrame}..${endFrame} is outside 0..${motion.frames - 1}`);
22
+ }
23
+ const joints = CSKEL27_JOINTS.length;
24
+ const frames = endFrame - startFrame + 1;
25
+ return {
26
+ ...motion,
27
+ frames,
28
+ rotMats: motion.rotMats.slice(startFrame * joints * 9, (endFrame + 1) * joints * 9),
29
+ rootPos: motion.rootPos.slice(startFrame * 3, (endFrame + 1) * 3),
30
+ posedJoints: motion.posedJoints.slice(startFrame * joints * 3, (endFrame + 1) * joints * 3),
31
+ anchorFrame: 0,
32
+ };
33
+ }
@@ -0,0 +1,36 @@
1
+ import { ko } from "./locale.js";
2
+ import { CHARACTER_MODEL_IDS } from "./scenes.js";
3
+
4
+ /** Casting assets offered in the bottom Assets tab. `id` doubles as the FBX
5
+ * file stem and the ARDY wire rig name (see scenes.js). */
6
+ export const CHARACTER_ASSETS = CHARACTER_MODEL_IDS.map((id) => ({
7
+ id,
8
+ label: id === "y-bot-tpose" ? "Y Bot" : "X Bot",
9
+ }));
10
+
11
+ /** Bottom-window asset shelf: character cards the operator drags into the
12
+ * shot pane to spawn. The drag itself is owned by App (ghost overlay +
13
+ * ground raycast on drop); the pane only reports the grab. */
14
+ export default function AssetPane({ onAssetGrab }) {
15
+ return (
16
+ <div className="assets-grid">
17
+ {CHARACTER_ASSETS.map((asset) => (
18
+ <button
19
+ type="button"
20
+ className="asset-card"
21
+ key={asset.id}
22
+ title={ko(`Drag ${asset.label} into the scene`, `${asset.label}을(를) 씬에 드래그하세요`)}
23
+ onPointerDown={(event) => {
24
+ if (event.button !== 0) return;
25
+ event.preventDefault();
26
+ onAssetGrab?.(asset, event);
27
+ }}
28
+ >
29
+ <span className="asset-card-swatch" data-model={asset.id} aria-hidden="true" />
30
+ <span className="asset-card-label">{asset.label}</span>
31
+ <span className="asset-card-kind">{ko("Character", "인물")}</span>
32
+ </button>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
package/src/dualview.jsx CHANGED
@@ -53,9 +53,15 @@ const EDGE_FRAGMENT = `
53
53
  }
54
54
 
55
55
  void main() {
56
+ float centerZ = linearDepth(texture2D(tDepth, vUv).x);
57
+ // Keep the stroke kernel stable, but soften its opacity past the 3 m
58
+ // reference distance. A minimum 42% opacity keeps distant silhouettes
59
+ // readable without letting ink become the only visible part of the body.
60
+ float distanceFade = orthographic > 0.5
61
+ ? 1.0
62
+ : mix(0.42, 1.0, clamp(3.0 / max(centerZ, 0.001), 0.0, 1.0));
56
63
  vec2 x = vec2(texel.x * 0.8, 0.0);
57
64
  vec2 y = vec2(0.0, texel.y * 0.8);
58
- float centerZ = linearDepth(texture2D(tDepth, vUv).x);
59
65
  float nearestZ = min(
60
66
  min(linearDepth(texture2D(tDepth, vUv - x).x), linearDepth(texture2D(tDepth, vUv + x).x)),
61
67
  min(linearDepth(texture2D(tDepth, vUv - y).x), linearDepth(texture2D(tDepth, vUv + y).x))
@@ -88,7 +94,7 @@ const EDGE_FRAGMENT = `
88
94
  float depthLine = smoothstep(0.055, 0.16, depthEdge);
89
95
  float normalLine = smoothstep(0.7, 1.7, normalEdge);
90
96
  float edge = max(depthLine * foregroundOwner, normalLine * 0.68);
91
- gl_FragColor = vec4(edgeColor, edge * 0.76);
97
+ gl_FragColor = vec4(edgeColor, edge * 0.76 * distanceFade);
92
98
  }
93
99
  `;
94
100
 
@@ -103,7 +109,7 @@ export function fitAspect(rect, aspect) {
103
109
  return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
104
110
  }
105
111
 
106
- export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef, poserCamRef, ikMode = false, planIsMain, playMode = false, insetCollapsed = false, planZoom = 1 }) {
112
+ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef, poserCamRef, ikMode = false, planIsMain, playMode = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT }) {
107
113
  const invalidate = useThree((state) => state.invalidate);
108
114
  // Demand mode draws nothing by itself: the first frame can land before the
109
115
  // layout settles (the inset reads 0x0), and async scene content commits
@@ -253,7 +259,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
253
259
  };
254
260
 
255
261
  gl.autoClear = true;
256
- shotCam.aspect = SHOT_ASPECT;
262
+ shotCam.aspect = shotAspect;
257
263
  shotCam.updateProjectionMatrix();
258
264
  // An orthographic plan keeps every puck the same size and the floor
259
265
  // undistorted; a perspective one skews the pucks near the edges and reads
@@ -279,7 +285,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
279
285
  // GIZMO_LAYER and is dropped for this draw, mask restored after.
280
286
  const playMask = shotCam.layers.mask;
281
287
  shotCam.layers.disable(GIZMO_LAYER);
282
- draw(shotCam, mainRect, fitAspect(mainRect, SHOT_ASPECT));
288
+ draw(shotCam, mainRect, fitAspect(mainRect, shotAspect));
283
289
  shotCam.layers.mask = playMask;
284
290
  } else if (ikMode && poserCam) {
285
291
  // IK mode: the main pane is the poser working view (free navigation,
@@ -288,12 +294,12 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
288
294
  poserCam.aspect = mainRect.w / mainRect.h;
289
295
  poserCam.updateProjectionMatrix();
290
296
  draw(poserCam, mainRect);
291
- if (!insetCollapsed) draw(shotCam, insetRect, fitAspect(insetRect, SHOT_ASPECT));
297
+ if (!insetCollapsed) draw(shotCam, insetRect, fitAspect(insetRect, shotAspect));
292
298
  } else if (planIsMain) {
293
299
  draw(planCam, planPane, null, false);
294
- if (!insetCollapsed) draw(shotCam, shotPane, fitAspect(shotPane, SHOT_ASPECT));
300
+ if (!insetCollapsed) draw(shotCam, shotPane, fitAspect(shotPane, shotAspect));
295
301
  } else {
296
- draw(shotCam, shotPane, fitAspect(shotPane, SHOT_ASPECT));
302
+ draw(shotCam, shotPane, fitAspect(shotPane, shotAspect));
297
303
  if (!insetCollapsed) draw(planCam, planPane, null, false);
298
304
  }
299
305
 
@@ -27,12 +27,56 @@ export const HIERARCHY_NODES = [
27
27
  label: "Rig",
28
28
  kind: "rig",
29
29
  children: [
30
- { id: "rig.hips", label: "Root / Hips", kind: "bone" },
31
- { id: "rig.spine", label: "Spine", kind: "bone" },
32
- { id: "rig.leftArm", label: "Left Arm", kind: "bone" },
33
- { id: "rig.rightArm", label: "Right Arm", kind: "bone" },
34
- { id: "rig.leftLeg", label: "Left Leg", kind: "bone" },
35
- { id: "rig.rightLeg", label: "Right Leg", kind: "bone" },
30
+ {
31
+ id: "rig.torso",
32
+ label: "Torso",
33
+ kind: "rig",
34
+ children: [
35
+ { id: "rig.hips", label: "Root / Hips", kind: "bone" },
36
+ { id: "rig.spine", label: "Spine", kind: "bone" },
37
+ { id: "rig.chest", label: "Chest", kind: "bone" },
38
+ { id: "rig.neck", label: "Neck", kind: "bone" },
39
+ { id: "rig.head", label: "Head", kind: "bone" },
40
+ ],
41
+ },
42
+ {
43
+ id: "rig.leftArm",
44
+ label: "Left Arm",
45
+ kind: "rig",
46
+ children: [
47
+ { id: "rig.leftShoulder", label: "Left Shoulder", kind: "bone" },
48
+ { id: "rig.leftElbow", label: "Left Elbow", kind: "bone" },
49
+ { id: "rig.leftHand", label: "Left Hand", kind: "bone" },
50
+ ],
51
+ },
52
+ {
53
+ id: "rig.rightArm",
54
+ label: "Right Arm",
55
+ kind: "rig",
56
+ children: [
57
+ { id: "rig.rightShoulder", label: "Right Shoulder", kind: "bone" },
58
+ { id: "rig.rightElbow", label: "Right Elbow", kind: "bone" },
59
+ { id: "rig.rightHand", label: "Right Hand", kind: "bone" },
60
+ ],
61
+ },
62
+ {
63
+ id: "rig.leftLeg",
64
+ label: "Left Leg",
65
+ kind: "rig",
66
+ children: [
67
+ { id: "rig.leftKnee", label: "Left Knee", kind: "bone" },
68
+ { id: "rig.leftFoot", label: "Left Foot", kind: "bone" },
69
+ ],
70
+ },
71
+ {
72
+ id: "rig.rightLeg",
73
+ label: "Right Leg",
74
+ kind: "rig",
75
+ children: [
76
+ { id: "rig.rightKnee", label: "Right Knee", kind: "bone" },
77
+ { id: "rig.rightFoot", label: "Right Foot", kind: "bone" },
78
+ ],
79
+ },
36
80
  ],
37
81
  },
38
82
  ],
@@ -51,19 +95,57 @@ export const HIERARCHY_NODES = [
51
95
  },
52
96
  ];
53
97
 
54
- export function buildHierarchyNodes(sceneObjects = []) {
98
+ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
55
99
  const clone = (node) => {
56
100
  const next = { ...node };
57
101
  if (node.id === "props") {
58
- next.children = sceneObjects.map((object) => ({
59
- id: `object:${object.id}`,
60
- label: object.name,
61
- kind: "object",
62
- }));
102
+ // Grouped props nest under their parent, so a rocket built from ten
103
+ // primitives reads as one thing in the tree instead of ten siblings.
104
+ const childrenOf = (parentId) =>
105
+ sceneObjects
106
+ .filter((object) => (object.parent ?? null) === parentId)
107
+ .map((object) => {
108
+ const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
109
+ const nested = childrenOf(object.id);
110
+ if (nested.length) row.children = nested;
111
+ return row;
112
+ });
113
+ // An object whose parent is missing still has to appear somewhere, so
114
+ // anything unreachable from the top level is shown at the top level.
115
+ const ids = new Set(sceneObjects.map((object) => object.id));
116
+ const rooted = sceneObjects.filter(
117
+ (object) => (object.parent ?? null) === null || !ids.has(object.parent),
118
+ );
119
+ next.children = rooted.map((object) => {
120
+ const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
121
+ const nested = childrenOf(object.id);
122
+ if (nested.length) row.children = nested;
123
+ return row;
124
+ });
63
125
  } else if (node.children) {
64
126
  next.children = node.children.map(clone);
65
127
  }
66
128
  return next;
67
129
  };
68
- return HIERARCHY_NODES.map(clone);
130
+ const nodes = HIERARCHY_NODES.map(clone);
131
+ // The cast is list-driven: one row per visible character. The first two
132
+ // keep the legacy row ids (characterA/characterB) that selection, IK and
133
+ // the inspector already route to; extras carry their character id.
134
+ if (Array.isArray(characters)) {
135
+ const group = nodes[0]?.children?.find((node) => node.id === "characters");
136
+ if (group) {
137
+ const rigChildren = group.children.find((node) => node.id === "characterA")?.children;
138
+ // Row ids follow the entry's LIST index so they match the viewport
139
+ // pickId (A/B/charId) stamped by the Character renderer in App.jsx.
140
+ group.children = characters.flatMap((entry, listIndex) => entry.hidden ? [] : [{
141
+ id: listIndex === 0 ? "characterA" : listIndex === 1 ? "characterB" : `character:${entry.id}`,
142
+ label: `Character ${listIndex + 1}`,
143
+ kind: "character",
144
+ // Only the primary carries the rig subtree: IK and viewport pose
145
+ // handles are scoped to it this phase.
146
+ ...(listIndex === 0 && rigChildren ? { children: rigChildren } : {}),
147
+ }]);
148
+ }
149
+ }
150
+ return nodes;
69
151
  }