Files
ttstd-infinite-canvas/web/src/components/canvas/director/director-viewport-rig.ts
T
ddcat666 fe17e3747a fix(web): Web 检查 - 格式化拆分模块并修正源码断言
拆分出的前端模块未按 Prettier 格式化导致 CI 格式检查失败;源码断言改为忽略空白差异,导演台引导组件恢复别名导入。同时移除误提交的分析草稿、pnpm 锁文件和调试测试。
2026-09-30 10:32:17 +08:00

177 lines
9.3 KiB
TypeScript

// 导演台人物模型的骨骼推断、姿态轨道应用与参考材质。纯 three.js 操作,不涉及 React。
import { Bone, Box3, Camera, type Material, Mesh, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Quaternion, Vector3 } from "three";
import { disposeDirectorMaterials } from "@/lib/canvas/director/director-resources";
import { type DirectorHumanoidBone, type DirectorObject, type DirectorQuat, type DirectorRig } from "@/types/director";
import { directorPoseBoneDeltas } from "@/lib/canvas/director/director-scene";
import { resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics";
export function screenPixelsToWorldRadius(camera: Camera, distance: number, pixels: number, viewportHeight: number) {
const height = Math.max(1, viewportHeight);
if (camera instanceof PerspectiveCamera) return (pixels * 2 * Math.max(0.01, distance) * Math.tan((camera.fov * Math.PI) / 360)) / (height * Math.max(0.01, camera.zoom));
if (camera instanceof OrthographicCamera) return (pixels * (camera.top - camera.bottom)) / (height * Math.max(0.01, camera.zoom));
return pixels * 0.001;
}
export function directorFingerGroup(bone: string | null) {
const match = bone?.match(/^(left|right)(Thumb|Index|Middle|Ring|Pinky)\d$/);
return match ? `${match[1]}${match[2]}` : null;
}
export function normalizeModel(root: Object3D, castShadow: boolean, receiveShadow: boolean) {
root.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(root, true);
const size = bounds.getSize(new Vector3());
const maxSize = Math.max(size.x, size.y, size.z, 0.001);
root.scale.multiplyScalar(2 / maxSize);
root.updateMatrixWorld(true);
const centered = new Box3().setFromObject(root, true);
const center = centered.getCenter(new Vector3());
root.position.sub(center);
root.position.y -= centered.min.y - center.y;
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
});
}
export function applyActorReferenceMaterial(root: Object3D, color: string) {
const material = new MeshStandardMaterial({ color, roughness: 0.74, metalness: 0.02 });
const replaced: Material[] = [];
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
// 被顶掉的原材质不再被任何 mesh 引用,必须释放,否则每次加载都泄漏一份。
if (mesh.material) replaced.push(...(Array.isArray(mesh.material) ? mesh.material : [mesh.material]));
mesh.material = material;
mesh.userData.directorActor = true;
mesh.userData.directorActorMaterial = material;
});
disposeDirectorMaterials(replaced.filter((item) => item !== material));
}
export function updateActorReferenceColor(root: Object3D, color: string) {
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh || !mesh.userData.directorActor) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => {
if (material instanceof MeshStandardMaterial) material.color.set(color);
});
});
}
export function readRigRestRotations(root: Object3D, rig: DirectorRig) {
return Object.fromEntries(
Object.entries(rig.boneMap).flatMap(([bone, name]) => {
const target = name ? root.getObjectByName(name) : null;
return target ? [[bone, target.quaternion.toArray() as DirectorQuat]] : [];
}),
) as Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
}
export function inferDirectorRig(root: Object3D, animationNames: string[]): DirectorRig {
const names = new Map<string, string>();
root.traverse((child) => {
if (child instanceof Bone) names.set(normalizeBoneName(child.name), child.name);
});
const fingerPatterns = (side: "left" | "right", finger: "thumb" | "index" | "middle" | "ring" | "pinky", segment: 1 | 2 | 3) => [
new RegExp(`^mixamorig${side}hand${finger}${segment}$`),
new RegExp(`^${side}hand${finger}${segment}$`),
new RegExp(`^${side}${finger}${segment}$`),
new RegExp(`^${finger}0?${segment}${side === "left" ? "l" : "r"}$`),
];
const patterns: Record<DirectorHumanoidBone, RegExp[]> = {
root: [/^root$/, /armature/],
hips: [/hips|pelvis/, /mixamorig.*hip/],
spine: [/spine1?$|lowerback/],
chest: [/spine2|chest|upperback/],
neck: [/neck/],
head: [/head/],
leftShoulder: [/leftshoulder|shoulder_l|mixamorigleftshoulder/],
leftUpperArm: [/leftupperarm|leftarm|upperarm_l|mixamorigleftarm/],
leftLowerArm: [/leftforearm|leftlowerarm|forearm_l|mixamorigleftforearm/],
leftHand: [/^lefthand$/, /^handl$/, /^mixamoriglefthand$/],
leftThumb1: fingerPatterns("left", "thumb", 1),
leftThumb2: fingerPatterns("left", "thumb", 2),
leftThumb3: fingerPatterns("left", "thumb", 3),
leftIndex1: fingerPatterns("left", "index", 1),
leftIndex2: fingerPatterns("left", "index", 2),
leftIndex3: fingerPatterns("left", "index", 3),
leftMiddle1: fingerPatterns("left", "middle", 1),
leftMiddle2: fingerPatterns("left", "middle", 2),
leftMiddle3: fingerPatterns("left", "middle", 3),
leftRing1: fingerPatterns("left", "ring", 1),
leftRing2: fingerPatterns("left", "ring", 2),
leftRing3: fingerPatterns("left", "ring", 3),
leftPinky1: fingerPatterns("left", "pinky", 1),
leftPinky2: fingerPatterns("left", "pinky", 2),
leftPinky3: fingerPatterns("left", "pinky", 3),
rightShoulder: [/rightshoulder|shoulder_r|mixamorigrightshoulder/],
rightUpperArm: [/rightupperarm|rightarm|upperarm_r|mixamorigrightarm/],
rightLowerArm: [/rightforearm|rightlowerarm|forearm_r|mixamorigrightforearm/],
rightHand: [/^righthand$/, /^handr$/, /^mixamorigrighthand$/],
rightThumb1: fingerPatterns("right", "thumb", 1),
rightThumb2: fingerPatterns("right", "thumb", 2),
rightThumb3: fingerPatterns("right", "thumb", 3),
rightIndex1: fingerPatterns("right", "index", 1),
rightIndex2: fingerPatterns("right", "index", 2),
rightIndex3: fingerPatterns("right", "index", 3),
rightMiddle1: fingerPatterns("right", "middle", 1),
rightMiddle2: fingerPatterns("right", "middle", 2),
rightMiddle3: fingerPatterns("right", "middle", 3),
rightRing1: fingerPatterns("right", "ring", 1),
rightRing2: fingerPatterns("right", "ring", 2),
rightRing3: fingerPatterns("right", "ring", 3),
rightPinky1: fingerPatterns("right", "pinky", 1),
rightPinky2: fingerPatterns("right", "pinky", 2),
rightPinky3: fingerPatterns("right", "pinky", 3),
leftUpperLeg: [/leftupleg|leftthigh|thigh_l|mixamorigleftupleg/],
leftLowerLeg: [/leftleg|leftcalf|calf_l|mixamorigleftleg/],
leftFoot: [/leftfoot|foot_l|mixamorigleftfoot/],
rightUpperLeg: [/rightupleg|rightthigh|thigh_r|mixamorigrightupleg/],
rightLowerLeg: [/rightleg|rightcalf|calf_r|mixamorigrightleg/],
rightFoot: [/rightfoot|foot_r|mixamorigrightfoot/],
};
const boneMap = Object.fromEntries(
Object.entries(patterns)
.map(([bone, candidates]) => [bone, candidates.map((pattern) => [...names.entries()].find(([normalized]) => pattern.test(normalized))?.[1]).find(Boolean)])
.filter(([, name]) => Boolean(name)),
) as DirectorRig["boneMap"];
return { status: Object.keys(boneMap).length >= 8 ? "ready" : "unmapped", boneMap, animationNames };
}
export function normalizeBoneName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
export function applyDirectorBoneTracks(model: Object3D, object: DirectorObject, playhead: number, rig: DirectorRig | null, restRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>>, hasActiveMotion: boolean) {
if (!rig) return;
const poseDeltas = hasActiveMotion ? {} : directorPoseBoneDeltas(object.pose || "stand");
Object.entries(rig.boneMap).forEach(([bone, name]) => {
const target = name ? model.getObjectByName(name) : null;
if (!target) return;
const humanoidBone = bone as DirectorHumanoidBone;
// hasActiveMotion 时 mixer 已写入 target.quaternion,直接作为动作层输入。
const rotation = resolveDirectorBoneRotation({
motion: hasActiveMotion ? (target.quaternion.toArray() as DirectorQuat) : null,
rest: hasActiveMotion ? null : restRotations[humanoidBone] || null,
poseDelta: hasActiveMotion ? null : poseDeltas[humanoidBone] || null,
override: object.boneOverrides?.[humanoidBone] || null,
keyframes: object.boneTracks?.find((item) => item.bone === bone)?.keyframes || null,
time: playhead,
});
if (rotation) {
target.quaternion.copy(new Quaternion(...rotation));
return;
}
if (hasActiveMotion) return;
const rest = restRotations[humanoidBone];
if (rest) target.quaternion.copy(new Quaternion(...rest));
const delta = poseDeltas[humanoidBone];
if (delta) target.quaternion.multiply(new Quaternion(...delta));
});
}