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
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
3
  import * as THREE from "three";
4
4
  import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
5
- import { IK_TRACKS, MID_TRACKS } from "./ardy/ik.js";
5
+ import { poseThumbnail, warmThumbnailModels } from "./pose-thumbs.js";
6
+ import { IK_TRACKS, MID_TRACKS, ikControlIsExposed } from "./ardy/ik.js";
6
7
  import { POSER_LAYER } from "./dualview.jsx";
7
8
  import { ko, isKo } from "./locale.js";
8
9
 
@@ -242,6 +243,8 @@ const AXIS_DEFS = [
242
243
  const GIZMO_LEN = 0.22;
243
244
  const GIZMO_SHAFT_R = 0.012;
244
245
  const GIZMO_TIP_R = 0.03;
246
+ const GIZMO_PICK_SHAFT_R = 0.045;
247
+ const GIZMO_PICK_TIP_R = 0.055;
245
248
  const HANDLE_R = 0.055; // chain target spheres
246
249
  const JOINT_R = 0.042; // mid-joint + FK swing spheres
247
250
  const SWING_RING_R = 0.12; // effector rotation ring
@@ -249,7 +252,11 @@ const SWING_RING_TUBE = 0.012; // visible tube
249
252
  const SWING_RING_PICK_TUBE = 0.045; // invisible grab tube
250
253
  /** Unfocused handles fade back so the focused one reads clearly. */
251
254
  const OPACITY_FOCUSED = 1;
252
- const OPACITY_UNFOCUSED = 0.3;
255
+ const OPACITY_UNFOCUSED = 0.5;
256
+ const OPACITY_NEAR = 0.86;
257
+ const OPACITY_FAR = 0.5;
258
+ const OPACITY_DISABLED_NEAR = 0.16;
259
+ const OPACITY_DISABLED_FAR = 0.07;
253
260
  /** FK swing drag speed: TransformControls uses 20/camDist, tuned for its
254
261
  * gizmo rings; posing needs finer steps, so the same formula runs at 5 —
255
262
  * a ~60 px drag reads as ~15°, a full-screen sweep as ~165°. */
@@ -274,8 +281,18 @@ const FK_ROTATE_SPEED = 5;
274
281
  * calls `onDragEnd(trackId)` so the caller can key it.
275
282
  */
276
283
  export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd }) {
277
- const { camera, gl, raycaster } = useThree();
284
+ const { camera, gl, raycaster, scene } = useThree();
278
285
  const handleRefs = useRef({}); // id -> mesh (all sphere handles)
286
+ const handleMetaRef = useRef(new Map()); // id -> { track, radius }
287
+ const visibilityRef = useRef(new Map()); // id -> exposed in the active camera
288
+ const visibilityDetailRef = useRef(new Map());
289
+ const exposureDepthRef = useRef(new Map()); // id -> active-camera depth
290
+ const exposureInputRef = useRef({ values: [], count: 0, valid: false });
291
+ const poseInputRef = useRef({ values: [], count: 0, valid: false });
292
+ const exposurePerfRef = useRef({ passes: 0, skippedFrames: 0, raycasts: 0, lastPassMs: 0 });
293
+ const blockerRefs = useRef([]);
294
+ const skinnedBlockerProxiesRef = useRef(new Map());
295
+ const occlusionRay = useRef(new THREE.Raycaster()).current;
279
296
  const gizmoRef = useRef(null);
280
297
  const ringRef = useRef(null); // swing ring around the focused effector
281
298
  const dragRef = useRef(null);
@@ -298,8 +315,26 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
298
315
  delta: new THREE.Vector3(),
299
316
  target: new THREE.Vector3(),
300
317
  pos: new THREE.Vector3(),
318
+ cameraPos: new THREE.Vector3(),
319
+ rayDirection: new THREE.Vector3(),
320
+ viewPoint: new THREE.Vector3(),
321
+ skinnedVertex: new THREE.Vector3(),
301
322
  }).current;
302
323
 
324
+ useEffect(() => {
325
+ exposureInputRef.current.valid = false;
326
+ poseInputRef.current.valid = false;
327
+ if (!enabled) {
328
+ exposureDepthRef.current.clear();
329
+ visibilityRef.current.clear();
330
+ visibilityDetailRef.current.clear();
331
+ }
332
+ }, [chains, enabled, fkJoints, scene]);
333
+
334
+ useEffect(() => () => {
335
+ skinnedBlockerProxiesRef.current.clear();
336
+ }, []);
337
+
303
338
  useEffect(() => {
304
339
  if (!enabled) return undefined;
305
340
  const onMove = (ev) => {
@@ -414,6 +449,29 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
414
449
  };
415
450
  // is the swing ring mounted for this track (focused chain)?
416
451
  window.__ikRingVisible = () => !!ringRef.current;
452
+ window.__ikVisibleControls = () =>
453
+ [...visibilityRef.current.entries()]
454
+ .filter(([, exposed]) => exposed)
455
+ .map(([id]) => id)
456
+ .sort();
457
+ window.__ikVisibilityDetails = () =>
458
+ Object.fromEntries(visibilityDetailRef.current);
459
+ window.__ikVisibilityPerformance = () => ({ ...exposurePerfRef.current });
460
+ window.__ikControlScreenPositions = () => {
461
+ const rect = gl.domElement.getBoundingClientRect();
462
+ return Object.fromEntries(
463
+ Object.entries(handleRefs.current)
464
+ .filter(([, mesh]) => mesh?.parent)
465
+ .map(([id, mesh]) => {
466
+ const point = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
467
+ return [id, {
468
+ x: rect.left + ((point.x + 1) / 2) * rect.width,
469
+ y: rect.top + ((1 - point.y) / 2) * rect.height,
470
+ exposed: mesh.visible && mesh.userData.ikExposed === true,
471
+ }];
472
+ })
473
+ );
474
+ };
417
475
  }
418
476
 
419
477
  // Handles + gizmo follow the live state every frame. A chain handle RIDES
@@ -445,6 +503,208 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
445
503
  const mesh = handleRefs.current[id];
446
504
  if (mesh && joint) mesh.position.copy(joint.bone.getWorldPosition(tmp.pos));
447
505
  }
506
+
507
+ // One exposure result owns both rendering and picking. Measure how far
508
+ // each control centre sits behind the first opaque scene surface; each
509
+ // track's visibilityDepth describes its legitimate under-skin depth.
510
+ // Centreline torso/head controls get enough allowance to stay usable,
511
+ // while a far-side shoulder or limb remains hidden behind the body.
512
+ camera.updateMatrixWorld();
513
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
514
+ camera.getWorldPosition(tmp.cameraPos);
515
+ const blockers = blockerRefs.current;
516
+ blockers.length = 0;
517
+ const input = exposureInputRef.current;
518
+ const poseInput = poseInputRef.current;
519
+ let inputIndex = 0;
520
+ let poseInputIndex = 0;
521
+ let exposureDirty = !input.valid;
522
+ let poseDirty = !poseInput.valid;
523
+ const captureInput = (value) => {
524
+ if (Math.abs((input.values[inputIndex] ?? Infinity) - value) > 1e-6) exposureDirty = true;
525
+ input.values[inputIndex] = value;
526
+ inputIndex += 1;
527
+ };
528
+ const capturePoseInput = (value) => {
529
+ captureInput(value);
530
+ if (Math.abs((poseInput.values[poseInputIndex] ?? Infinity) - value) > 1e-6) poseDirty = true;
531
+ poseInput.values[poseInputIndex] = value;
532
+ poseInputIndex += 1;
533
+ };
534
+ for (const value of camera.matrixWorld.elements) captureInput(value);
535
+ for (const mesh of Object.values(handleRefs.current)) {
536
+ if (!mesh?.parent) continue;
537
+ capturePoseInput(mesh.position.x);
538
+ capturePoseInput(mesh.position.y);
539
+ capturePoseInput(mesh.position.z);
540
+ }
541
+ scene.traverse((object) => {
542
+ if (!object.isMesh || !object.visible || object.layers.isEnabled(POSER_LAYER)) return;
543
+ const materials = Array.isArray(object.material) ? object.material : null;
544
+ let opaque = false;
545
+ if (materials) {
546
+ for (const material of materials) {
547
+ if (material && material.visible !== false && material.opacity > 0.01 && material.depthWrite !== false) {
548
+ opaque = true;
549
+ break;
550
+ }
551
+ }
552
+ } else {
553
+ const material = object.material;
554
+ opaque = !!material && material.visible !== false && material.opacity > 0.01 && material.depthWrite !== false;
555
+ }
556
+ if (!opaque) return;
557
+ blockers.push(object);
558
+ captureInput(object.id);
559
+ for (const value of object.matrixWorld.elements) {
560
+ if (object.isSkinnedMesh) capturePoseInput(value);
561
+ else captureInput(value);
562
+ }
563
+ for (const value of object.morphTargetInfluences ?? []) capturePoseInput(value);
564
+ if (object.isSkinnedMesh) {
565
+ // Handle centres do not reveal an in-place joint rotation
566
+ // (notably a head turn), so every skinning bone participates
567
+ // in the proxy-bake signature.
568
+ for (const bone of object.skeleton?.bones ?? []) {
569
+ for (const value of bone.matrixWorld.elements) capturePoseInput(value);
570
+ }
571
+ }
572
+ });
573
+ if (input.count !== inputIndex) exposureDirty = true;
574
+ input.count = inputIndex;
575
+ input.values.length = inputIndex;
576
+ if (poseInput.count !== poseInputIndex) poseDirty = true;
577
+ poseInput.count = poseInputIndex;
578
+ poseInput.values.length = poseInputIndex;
579
+
580
+ if (exposureDirty) {
581
+ const passStartedAt = performance.now();
582
+ input.valid = true;
583
+ poseInput.valid = true;
584
+ exposureDepthRef.current.clear();
585
+ occlusionRay.layers.set(0);
586
+ const staticBlockers = blockers.filter((object) => !object.isSkinnedMesh);
587
+ const currentSkinnedBlockers = new Set(blockers.filter((object) => object.isSkinnedMesh));
588
+ for (const object of skinnedBlockerProxiesRef.current.keys()) {
589
+ if (!currentSkinnedBlockers.has(object)) skinnedBlockerProxiesRef.current.delete(object);
590
+ }
591
+ const skinnedProxies = [];
592
+ for (const object of blockers) {
593
+ if (!object.isSkinnedMesh) continue;
594
+ let proxy = skinnedBlockerProxiesRef.current.get(object);
595
+ if (!proxy) {
596
+ proxy = { points: [], radius: 0.04 };
597
+ skinnedBlockerProxiesRef.current.set(object, proxy);
598
+ poseDirty = true;
599
+ }
600
+ if (poseDirty) {
601
+ const cells = new Set();
602
+ let pointCount = 0;
603
+ const positions = object.geometry.getAttribute("position");
604
+ // The production FBX is non-indexed and repeats vertices
605
+ // per triangle (about 166k across its two skin meshes).
606
+ // Six thousand evenly distributed samples per mesh are
607
+ // ample for a 4 cm surface cloud and keep a pose update
608
+ // inside a frame instead of re-skinning every duplicate.
609
+ const stride = Math.max(1, Math.floor(positions.count / 6000));
610
+ for (let index = 0; index < positions.count; index += stride) {
611
+ object.getVertexPosition(index, tmp.skinnedVertex);
612
+ tmp.skinnedVertex.applyMatrix4(object.matrixWorld);
613
+ const ix = Math.round(tmp.skinnedVertex.x / 0.04);
614
+ const iy = Math.round(tmp.skinnedVertex.y / 0.04);
615
+ const iz = Math.round(tmp.skinnedVertex.z / 0.04);
616
+ const key = (ix + 4096) * 16777216 + (iy + 4096) * 4096 + iz + 4096;
617
+ if (cells.has(key)) continue;
618
+ cells.add(key);
619
+ const point = proxy.points[pointCount] ?? new THREE.Vector3();
620
+ point.copy(tmp.skinnedVertex);
621
+ proxy.points[pointCount] = point;
622
+ pointCount += 1;
623
+ }
624
+ proxy.points.length = pointCount;
625
+ }
626
+ skinnedProxies.push(proxy);
627
+ }
628
+ for (const [id, mesh] of Object.entries(handleRefs.current)) {
629
+ if (!mesh?.parent) continue;
630
+ const meta = handleMetaRef.current.get(id);
631
+ mesh.getWorldPosition(tmp.viewPoint);
632
+ const targetDistance = tmp.cameraPos.distanceTo(tmp.viewPoint);
633
+ tmp.rayDirection.copy(tmp.viewPoint).sub(tmp.cameraPos).normalize();
634
+ occlusionRay.set(tmp.cameraPos, tmp.rayDirection);
635
+ occlusionRay.near = 0.001;
636
+ occlusionRay.far = Math.max(0.001, targetDistance - 0.001);
637
+ let blockerDistance = occlusionRay.intersectObjects(staticBlockers, false)[0]?.distance;
638
+ // A coarse cloud of baked skin-surface spheres preserves the
639
+ // actual animated silhouette without asking Three.js to skin
640
+ // every triangle once per control ray. Camera-only changes now
641
+ // test a few cached points; pose changes rebuild the cloud once.
642
+ for (const proxy of skinnedProxies) {
643
+ const radiusSq = proxy.radius * proxy.radius;
644
+ for (const point of proxy.points) {
645
+ const dx = point.x - tmp.cameraPos.x;
646
+ const dy = point.y - tmp.cameraPos.y;
647
+ const dz = point.z - tmp.cameraPos.z;
648
+ const along = dx * tmp.rayDirection.x + dy * tmp.rayDirection.y + dz * tmp.rayDirection.z;
649
+ if (along < 0 || along > targetDistance + proxy.radius) continue;
650
+ const perpendicularSq = dx * dx + dy * dy + dz * dz - along * along;
651
+ if (perpendicularSq > radiusSq) continue;
652
+ const distance = Math.max(0, along - Math.sqrt(radiusSq - perpendicularSq));
653
+ if (distance < (blockerDistance ?? Infinity)) blockerDistance = distance;
654
+ }
655
+ }
656
+ const visibilityDepth = meta?.track.visibilityDepth ?? (meta?.radius ?? JOINT_R) * 2;
657
+ const isExposed = ikControlIsExposed(targetDistance, blockerDistance, visibilityDepth);
658
+ visibilityDetailRef.current.set(id, {
659
+ exposed: isExposed,
660
+ targetDistance,
661
+ blockerDistance: blockerDistance ?? null,
662
+ visibilityDepth,
663
+ });
664
+ visibilityRef.current.set(id, isExposed);
665
+ mesh.userData.ikExposed = isExposed;
666
+ mesh.visible = isExposed;
667
+ if (!isExposed) continue;
668
+ tmp.viewPoint.applyMatrix4(camera.matrixWorldInverse);
669
+ exposureDepthRef.current.set(id, -tmp.viewPoint.z);
670
+ }
671
+ exposurePerfRef.current.passes += 1;
672
+ exposurePerfRef.current.raycasts += Object.keys(handleRefs.current).length;
673
+ exposurePerfRef.current.lastPassMs = performance.now() - passStartedAt;
674
+ } else {
675
+ exposurePerfRef.current.skippedFrames += 1;
676
+ }
677
+
678
+ let near = Infinity;
679
+ let far = -Infinity;
680
+ for (const depth of exposureDepthRef.current.values()) {
681
+ near = Math.min(near, depth);
682
+ far = Math.max(far, depth);
683
+ }
684
+ const span = Math.max(0.001, far - near);
685
+ const activeFocus = focusIdRef.current;
686
+ for (const [id, depth] of exposureDepthRef.current) {
687
+ const mesh = handleRefs.current[id];
688
+ if (!mesh) continue;
689
+ const focused = activeFocus === id;
690
+ const clickable = activeFocus === null || activeFocus === undefined || focused;
691
+ const closeness = 1 - THREE.MathUtils.clamp((depth - near) / span, 0, 1);
692
+ const emphasis = THREE.MathUtils.smoothstep(closeness, 0, 1);
693
+ mesh.material.opacity = focused
694
+ ? OPACITY_FOCUSED
695
+ : clickable
696
+ ? THREE.MathUtils.lerp(OPACITY_FAR, OPACITY_NEAR, emphasis)
697
+ : THREE.MathUtils.lerp(OPACITY_DISABLED_FAR, OPACITY_DISABLED_NEAR, emphasis);
698
+ mesh.material.emissiveIntensity = focused
699
+ ? 2.6
700
+ : clickable
701
+ ? THREE.MathUtils.lerp(1.5, 2.35, emphasis)
702
+ : THREE.MathUtils.lerp(0.45, 0.8, emphasis);
703
+ }
704
+ const focusExposed = !!focus && visibilityRef.current.get(focus) !== false;
705
+ if (gizmoRef.current) gizmoRef.current.visible = focusExposed;
706
+ if (ringRef.current) ringRef.current.visible = focusExposed;
707
+
448
708
  if (gizmoRef.current && focus) {
449
709
  const mid = MID_TRACKS.find((t) => t.id === focus);
450
710
  const chainTrack = IK_TRACKS.find((t) => t.id === focus);
@@ -485,6 +745,31 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
485
745
  * pointerdown + raycast against the registered handle/gizmo meshes uses
486
746
  * the CURRENT canvas rect, so every hit is pixel-exact. */
487
747
  const pickRefs = useRef([]); // [{ mesh, track, axisDir, kind }]
748
+ if (typeof window !== "undefined") {
749
+ window.__ikPickScreenPositions = () => {
750
+ const rect = gl.domElement.getBoundingClientRect();
751
+ return pickRefs.current
752
+ .filter((pick) => pick.mesh?.parent)
753
+ .map((pick) => {
754
+ pick.mesh.updateWorldMatrix(true, false);
755
+ const point = pick.part === "ring"
756
+ ? new THREE.Vector3(SWING_RING_R * Math.SQRT1_2, SWING_RING_R * Math.SQRT1_2, 0).applyMatrix4(pick.mesh.matrixWorld)
757
+ : pick.mesh.getWorldPosition(new THREE.Vector3());
758
+ point.project(camera);
759
+ const axis = pick.axisDir
760
+ ? Math.abs(pick.axisDir.x) > 0.5 ? "x" : Math.abs(pick.axisDir.y) > 0.5 ? "y" : "z"
761
+ : null;
762
+ return {
763
+ trackId: pick.track.id,
764
+ kind: pick.kind,
765
+ part: pick.part,
766
+ axis,
767
+ x: rect.left + ((point.x + 1) / 2) * rect.width,
768
+ y: rect.top + ((1 - point.y) / 2) * rect.height,
769
+ };
770
+ });
771
+ };
772
+ }
488
773
 
489
774
  /** Click/drag threshold (screen px): a press that never moves past this is
490
775
  * a click (focus toggle), not a drag (no key baked). */
@@ -580,7 +865,11 @@ const CLICK_PX = 4;
580
865
  if (ev.button !== 0) return;
581
866
  const downXY = [ev.clientX, ev.clientY];
582
867
  const focused = focusIdRef.current;
583
- let picks = pickRefs.current.filter((p) => p.mesh);
868
+ let picks = pickRefs.current.filter((p) =>
869
+ p.mesh?.visible &&
870
+ handleRefs.current[p.track.id]?.visible &&
871
+ handleRefs.current[p.track.id]?.userData.ikExposed === true
872
+ );
584
873
  if (focused) picks = picks.filter((p) => p.track.id === focused);
585
874
  else picks = picks.filter((p) => !p.axisDir); // spheres only
586
875
  if (!picks.length) {
@@ -600,6 +889,9 @@ const CLICK_PX = 4;
600
889
  }
601
890
  const pick = picks.find((p) => p.mesh === hits[0].object);
602
891
  if (!pick) return;
892
+ if (typeof window !== "undefined") {
893
+ window.__ikLastPick = { trackId: pick.track.id, kind: pick.kind, part: pick.part };
894
+ }
603
895
  ev.stopImmediatePropagation();
604
896
  ev.stopPropagation();
605
897
  ev.preventDefault();
@@ -630,7 +922,13 @@ const CLICK_PX = 4;
630
922
  key={track.id}
631
923
  ref={(m) => {
632
924
  handleRefs.current[track.id] = m;
633
- if (m) m.layers.set(POSER_LAYER);
925
+ if (m) {
926
+ m.layers.set(POSER_LAYER);
927
+ handleMetaRef.current.set(track.id, { track, radius });
928
+ } else {
929
+ handleMetaRef.current.delete(track.id);
930
+ visibilityRef.current.delete(track.id);
931
+ }
634
932
  register(track, kind)(m);
635
933
  }}
636
934
  renderOrder={999}
@@ -647,6 +945,7 @@ const CLICK_PX = 4;
647
945
  emissiveIntensity={focused ? 2.6 : 1.4}
648
946
  toneMapped={false}
649
947
  depthTest={false}
948
+ depthWrite={false}
650
949
  transparent
651
950
  opacity={focused ? OPACITY_FOCUSED : OPACITY_UNFOCUSED}
652
951
  />
@@ -654,7 +953,7 @@ const CLICK_PX = 4;
654
953
  );
655
954
  };
656
955
 
657
- // The gizmo appears on chain + mid (position) handles — and on the hips
956
+ // The gizmo appears on chain + mid (position) handles — and on the hips
658
957
  // BODY handle: its arrows translate the body (Y = height, for crouching
659
958
  // and lying poses) while its centre sphere keeps swinging the body.
660
959
  const gizmoTrack =
@@ -691,7 +990,7 @@ const CLICK_PX = 4;
691
990
  }}
692
991
  >
693
992
  <cylinderGeometry args={[GIZMO_SHAFT_R, GIZMO_SHAFT_R, GIZMO_LEN, 8]} />
694
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} transparent opacity={0.9} />
993
+ <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.9} />
695
994
  </mesh>
696
995
  <mesh
697
996
  position={[0, GIZMO_LEN + 0.04, 0]}
@@ -705,7 +1004,29 @@ const CLICK_PX = 4;
705
1004
  }}
706
1005
  >
707
1006
  <coneGeometry args={[GIZMO_TIP_R, 0.08, 12]} />
708
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.4} toneMapped={false} depthTest={false} transparent opacity={0.95} />
1007
+ <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.4} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
1008
+ </mesh>
1009
+ {/* Invisible forgiving hit volumes keep the target
1010
+ at least as easy to grab as the visible arrow. */}
1011
+ <mesh
1012
+ position={[0, GIZMO_LEN / 2 + 0.02, 0]}
1013
+ ref={(m) => {
1014
+ if (m) m.layers.set(POSER_LAYER);
1015
+ register(gizmoTrack, gizmoKind, dir, "hit-shaft")(m);
1016
+ }}
1017
+ >
1018
+ <cylinderGeometry args={[GIZMO_PICK_SHAFT_R, GIZMO_PICK_SHAFT_R, GIZMO_LEN + 0.04, 8]} />
1019
+ <meshBasicMaterial transparent opacity={0} depthTest={false} depthWrite={false} />
1020
+ </mesh>
1021
+ <mesh
1022
+ position={[0, GIZMO_LEN + 0.04, 0]}
1023
+ ref={(m) => {
1024
+ if (m) m.layers.set(POSER_LAYER);
1025
+ register(gizmoTrack, gizmoKind, dir, "hit-tip")(m);
1026
+ }}
1027
+ >
1028
+ <coneGeometry args={[GIZMO_PICK_TIP_R, 0.12, 12]} />
1029
+ <meshBasicMaterial transparent opacity={0} depthTest={false} depthWrite={false} />
709
1030
  </mesh>
710
1031
  </group>
711
1032
  );
@@ -735,6 +1056,7 @@ const CLICK_PX = 4;
735
1056
  emissiveIntensity={2.2}
736
1057
  toneMapped={false}
737
1058
  depthTest={false}
1059
+ depthWrite={false}
738
1060
  transparent
739
1061
  opacity={0.9}
740
1062
  />
@@ -760,7 +1082,35 @@ const CLICK_PX = 4;
760
1082
  * `{ thumb, custom: true }`. The tile entry animation cascades with the same
761
1083
  * stagger as the reference.
762
1084
  */
763
- export function PoseStudioPanel({ subject, poses, selectedId, onSelect, onApply, onReset, onSave, onDelete, onClose, closing, motionActive = false }) {
1085
+ /** Pose tile preview: generated lazily per model+pose and cached for the
1086
+ * session (see pose-thumbs.js); user-saved poses keep their stored thumb. */
1087
+ function PoseThumb({ model, pose, alt }) {
1088
+ const [url, setUrl] = useState(pose.thumb ?? null);
1089
+ useEffect(() => {
1090
+ if (pose.thumb) {
1091
+ setUrl(pose.thumb);
1092
+ return undefined;
1093
+ }
1094
+ let live = true;
1095
+ setUrl(null);
1096
+ poseThumbnail(model, pose).then((generated) => {
1097
+ if (live) setUrl(generated);
1098
+ }).catch(() => {});
1099
+ return () => { live = false; };
1100
+ }, [model, pose]);
1101
+ return url ? <img src={url} alt={alt} /> : <div className="tile-blank" />;
1102
+ }
1103
+
1104
+ // Warm the thumbnail rigs while the studio sits idle: the first pose-studio
1105
+ // open then pays no FBX parse. Both shipped models, one idle render each.
1106
+ let thumbnailsWarmed = false;
1107
+ export function warmPoseThumbnails() {
1108
+ if (thumbnailsWarmed) return;
1109
+ thumbnailsWarmed = true;
1110
+ warmThumbnailModels(["y-bot-tpose", "x-bot-tpose"]);
1111
+ }
1112
+
1113
+ export function PoseStudioPanel({ subject, model, poses, selectedId, onSelect, onApply, onReset, onSave, onDelete, onClose, closing, motionActive = false, docked = false }) {
764
1114
  const poseLabelsKo = {
765
1115
  "T-pose": ko("T-pose", "T 포즈"),
766
1116
  Relaxed: ko("Relaxed", "편안한 자세"),
@@ -801,7 +1151,7 @@ export function PoseStudioPanel({ subject, poses, selectedId, onSelect, onApply,
801
1151
  const categories = ["all", ...Array.from(new Set(poses.map(poseCategory)))];
802
1152
  const visiblePoses = category === "all" ? poses : poses.filter((pose) => poseCategory(pose) === category);
803
1153
  return (
804
- <div className={"pose-studio" + (closing ? " closing" : "")}>
1154
+ <div className={"pose-studio" + (docked ? " docked" : "") + (closing ? " closing" : "")}>
805
1155
  <div className="studio-head">
806
1156
  <span>{isKo ? `포즈 스튜디오 · 인물 ${subject}` : `Pose Studio · Subject ${subject}`}</span>
807
1157
  <button type="button" className="x" onClick={onClose} aria-label={ko("Close pose studio", "포즈 스튜디오 닫기")}>
@@ -847,7 +1197,7 @@ export function PoseStudioPanel({ subject, poses, selectedId, onSelect, onApply,
847
1197
  style={{ animationDelay: `${0.04 + index * 0.028}s` }}
848
1198
  onClick={() => selectPose(pose.id)}
849
1199
  >
850
- {pose.thumb ? <img src={pose.thumb} alt={displayPoseLabel(pose)} /> : <div className="tile-blank" />}
1200
+ <PoseThumb model={model} pose={pose} alt={displayPoseLabel(pose)} />
851
1201
  <span>{displayPoseLabel(pose)}</span>
852
1202
  {pose.custom && (
853
1203
  <em
@@ -0,0 +1,135 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ko } from "./locale.js";
3
+ import {
4
+ hasDirectoryPicker,
5
+ hasFileSystemAccess,
6
+ listProjectsInDirectory,
7
+ loadProjectsDirectory,
8
+ loadRecentProjects,
9
+ pickProjectsDirectory,
10
+ queryHandlePermission,
11
+ removeRecentProject,
12
+ storeProjectsDirectory,
13
+ } from "./project.js";
14
+
15
+ /**
16
+ * Game-engine style project picker. Two sources of truth:
17
+ * - Recents: everything the operator opened or saved before (always works).
18
+ * - A projects folder: when granted, the folder is enumerated so files the
19
+ * operator never opened in THIS browser show up too.
20
+ * Fallback for browsers without the File System Access API: recents plus a
21
+ * plain "open file" button (the folder section is hidden).
22
+ */
23
+ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose }) {
24
+ const [recents, setRecents] = useState([]);
25
+ const [folder, setFolder] = useState(null);
26
+ const [folderProjects, setFolderProjects] = useState([]);
27
+ const [folderDenied, setFolderDenied] = useState(false);
28
+
29
+ useEffect(() => {
30
+ loadRecentProjects().then(setRecents);
31
+ loadProjectsDirectory().then(async (handle) => {
32
+ if (!handle) return;
33
+ const permission = await queryHandlePermission(handle);
34
+ if (permission !== "granted") {
35
+ setFolderDenied(true);
36
+ return;
37
+ }
38
+ setFolder(handle);
39
+ setFolderProjects(await listProjectsInDirectory(handle));
40
+ });
41
+ }, []);
42
+
43
+ const chooseFolder = async () => {
44
+ try {
45
+ const handle = await pickProjectsDirectory();
46
+ await storeProjectsDirectory(handle);
47
+ setFolder(handle);
48
+ setFolderDenied(false);
49
+ setFolderProjects(await listProjectsInDirectory(handle));
50
+ } catch (err) {
51
+ if (err?.name !== "AbortError") setFolderDenied(true);
52
+ }
53
+ };
54
+
55
+ const refreshFolder = async () => {
56
+ if (folder) setFolderProjects(await listProjectsInDirectory(folder));
57
+ };
58
+
59
+ return (
60
+ <div className="project-browser-backdrop" onPointerDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
61
+ <div className="project-browser" role="dialog" aria-label={ko("Open project", "프로젝트 열기")}>
62
+ <div className="project-browser-head">
63
+ <strong>{ko("Projects", "프로젝트")}</strong>
64
+ <button type="button" className="x" onClick={onClose} aria-label={ko("Close", "닫기")}>✕</button>
65
+ </div>
66
+
67
+ <section>
68
+ <div className="project-browser-section-head">
69
+ <span>{ko("Recent projects", "최근 프로젝트")}</span>
70
+ </div>
71
+ {recents.length === 0 ? (
72
+ <p className="project-browser-empty">{ko("No projects yet — save one and it shows up here.", "아직 프로젝트가 없어요. 저장하면 여기에 나타납니다.")}</p>
73
+ ) : (
74
+ <ul className="project-browser-list">
75
+ {recents.map((entry) => (
76
+ <li key={entry.name} className={entry.name === currentName ? "active" : ""}>
77
+ <button type="button" onClick={() => onOpen(entry)}>
78
+ <strong>{entry.name}</strong>
79
+ <span>{new Date(entry.openedAt).toLocaleString()}</span>
80
+ </button>
81
+ <button
82
+ type="button"
83
+ className="del"
84
+ title={ko("Remove from list", "목록에서 제거")}
85
+ onClick={async () => {
86
+ await removeRecentProject(entry.name);
87
+ setRecents(await loadRecentProjects());
88
+ }}
89
+ >
90
+ ✕
91
+ </button>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )}
96
+ </section>
97
+
98
+ {hasFileSystemAccess() && hasDirectoryPicker() && (
99
+ <section>
100
+ <div className="project-browser-section-head">
101
+ <span>{ko("Projects folder", "프로젝트 폴더")}</span>
102
+ <div className="row-actions">
103
+ {folder && <button type="button" onClick={refreshFolder}>{ko("Refresh", "새로고침")}</button>}
104
+ <button type="button" onClick={chooseFolder}>
105
+ {folder ? ko("Change folder…", "폴더 변경…") : ko("Choose folder…", "폴더 지정…")}
106
+ </button>
107
+ </div>
108
+ </div>
109
+ {folderDenied && <p className="project-browser-empty">{ko("Folder access is not granted — choose it again.", "폴더 접근 권한이 없어요. 다시 지정해 주세요.")}</p>}
110
+ {folder && folderProjects.length === 0 && !folderDenied && (
111
+ <p className="project-browser-empty">{ko("No .cclayproject files in this folder.", "이 폴더에 .cclayproject 파일이 없어요.")}</p>
112
+ )}
113
+ {folderProjects.length > 0 && (
114
+ <ul className="project-browser-list">
115
+ {folderProjects.map((entry) => (
116
+ <li key={entry.name} className={entry.name === currentName ? "active" : ""}>
117
+ <button type="button" onClick={() => onOpen(entry)}>
118
+ <strong>{entry.name}</strong>
119
+ <span>{new Date(entry.lastModified).toLocaleString()}</span>
120
+ </button>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
125
+ </section>
126
+ )}
127
+
128
+ <div className="project-browser-foot">
129
+ <button type="button" className="btn ghost" onClick={onOpenFile}>{ko("Open file…", "파일로 열기…")}</button>
130
+ <button type="button" className="btn primary" onClick={onNew}>{ko("New Project", "새 프로젝트")}</button>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ );
135
+ }