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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/posestudio.jsx
CHANGED
|
@@ -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 {
|
|
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.
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|