feat(previs): 预演工作台 - 重做界面与取景手势,缩放不得进入演员体积

滚轮和按钮共用最近距离,环绕目标落在演员体内时停在包围盒外;取景按可见对象体积适配入画,取消暂存手势时不写入历史。
This commit is contained in:
ddcat666
2026-10-08 10:54:41 +08:00
parent f67a304380
commit f0359a6bee
5 changed files with 4042 additions and 1546 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import { Vector3 } from "three";
import { previsActorProfileForArchetype } from "@/lib/canvas/previs/previs-scene";
import type { PrevisObject } from "@/types/previs";
export type PrevisFrameBounds = {
min: Vector3;
max: Vector3;
span: Vector3;
target: Vector3;
fitDistance: number;
};
/** Smallest orbit distance allowed regardless of scene contents. */
export const PREVIS_MIN_ORBIT_DISTANCE = 0.7;
/** 单个对象的近似世界包围盒;演员按体积而不是原点计算,避免相机推进到身体内部。 */
export function resolvePrevisObjectBox(object: PrevisObject): { min: Vector3; max: Vector3 } {
const [x, y, z] = object.transform.position;
const [sx, sy, sz] = object.transform.scale;
const actor = object.kind === "actor" || object.primitive === "character";
if (actor) {
const profile = { ...previsActorProfileForArchetype(object.archetype || "adult"), ...(object.actorProfile || {}) };
const actorScaleY = Math.max(0.1, Math.abs(sy) * profile.height);
const actorHeight = object.pose === "sit" || object.pose === "squat" ? 2.05 : profile.accessory === "horns" ? 2.8 : 2.45;
const halfWidth = 0.78 * Math.max(0.8, profile.shoulderWidth) * Math.max(0.1, Math.abs(sx));
const halfDepth = 0.72 * Math.max(0.1, Math.abs(sz));
return { min: new Vector3(x - halfWidth, y - 0.12 * actorScaleY, z - halfDepth), max: new Vector3(x + halfWidth, y + actorHeight * actorScaleY, z + halfDepth) };
}
const primitiveBounds: [number, number, number] = object.primitive === "sphere" ? [0.6, 0.6, 0.6] : object.primitive === "cylinder" ? [0.55, 0.8, 0.55] : object.primitive === "plane" ? [1.2, 0.05, 1.2] : [0.6, 0.6, 0.6];
const half = new Vector3(primitiveBounds[0] * Math.max(0.1, Math.abs(sx)), primitiveBounds[1] * Math.max(0.1, Math.abs(sy)), primitiveBounds[2] * Math.max(0.1, Math.abs(sz)));
return { min: new Vector3(x, y, z).sub(half), max: new Vector3(x, y, z).add(half) };
}
/**
* 缩放的最近距离:相机不得进入包含环绕目标的对象体积(否则背面剔除让身体消失、只剩地面阴影)。
* 只看包含目标的对象,大场景里推近其它区域不受整体尺寸限制。
*/
export function resolvePrevisZoomMinDistance(objects: PrevisObject[], target: Vector3): number {
let minDistance = PREVIS_MIN_ORBIT_DISTANCE;
const margin = 0.15;
objects.forEach((object) => {
if (!object.visible || object.primitive === "plane") return;
const box = resolvePrevisObjectBox(object);
const contains = target.x >= box.min.x - margin && target.x <= box.max.x + margin && target.y >= box.min.y - margin && target.y <= box.max.y + margin && target.z >= box.min.z - margin && target.z <= box.max.z + margin;
if (!contains) return;
// 目标到盒子最远角的距离 + 近裁剪余量,保证整个体积都在相机前方。
const farthest = new Vector3(Math.max(target.x - box.min.x, box.max.x - target.x), Math.max(target.y - box.min.y, box.max.y - target.y), Math.max(target.z - box.min.z, box.max.z - target.z));
minDistance = Math.max(minDistance, farthest.length() + 0.35);
});
return minDistance;
}
/** 可见对象(可选限定 ids)的联合包围盒,以及在给定 aspect/fov 下完整入画所需的距离。 */
export function resolvePrevisFrameBounds(objects: PrevisObject[], ids?: Set<string>, aspect = 1, fov = 50): PrevisFrameBounds | null {
const visible = objects.filter((object) => object.visible && (!ids || ids.has(object.id)));
if (!visible.length) return null;
const min = new Vector3(Infinity, Infinity, Infinity);
const max = new Vector3(-Infinity, -Infinity, -Infinity);
visible.forEach((object) => {
const box = resolvePrevisObjectBox(object);
min.min(box.min);
max.max(box.max);
});
const target = min.clone().add(max).multiplyScalar(0.5);
const span = max.clone().sub(min);
const verticalHalfFov = (Math.max(1, fov) * Math.PI) / 360;
const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(0.1, aspect));
const fitDistance = Math.max(span.y / 2 / Math.tan(verticalHalfFov), Math.max(span.x, span.z) / 2 / Math.tan(horizontalHalfFov)) * 1.08;
return { min, max, span, target, fitDistance };
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { Vector3 } from "three";
import { PREVIS_MIN_ORBIT_DISTANCE, resolvePrevisFrameBounds, resolvePrevisObjectBox, resolvePrevisZoomMinDistance } from "@/lib/canvas/previs/previs-frame-bounds";
import { createPrevisActor, createPrevisObject } from "@/lib/canvas/previs/previs-scene";
describe("previs frame bounds", () => {
test("treats actors as body volumes instead of origin points", () => {
const box = resolvePrevisObjectBox(createPrevisActor("A", [0, 0, 0]));
expect(box.max.y).toBeGreaterThan(1.5);
expect(box.max.x - box.min.x).toBeGreaterThan(0.5);
});
test("keeps the orbit camera outside an actor that contains the target", () => {
const actor = createPrevisActor("A", [0, 0, 0]);
const target = new Vector3(0, 1, 0);
const minDistance = resolvePrevisZoomMinDistance([actor], target);
const box = resolvePrevisObjectBox(actor);
// Any camera position at minDistance from the target lies outside the actor volume.
const corner = new Vector3(Math.max(-box.min.x, box.max.x), Math.max(target.y - box.min.y, box.max.y - target.y), Math.max(-box.min.z, box.max.z));
expect(minDistance).toBeGreaterThan(corner.length());
});
test("does not restrict zoom when the target is away from every object", () => {
const actor = createPrevisActor("A", [0, 0, 0]);
expect(resolvePrevisZoomMinDistance([actor], new Vector3(10, 1, 10))).toBe(PREVIS_MIN_ORBIT_DISTANCE);
});
test("ignores hidden objects and ground planes", () => {
const hidden = { ...createPrevisActor("A", [0, 0, 0]), visible: false };
const plane = createPrevisObject("plane", "floor", [0, 1, 0]);
expect(resolvePrevisZoomMinDistance([hidden, plane], new Vector3(0, 1, 0))).toBe(PREVIS_MIN_ORBIT_DISTANCE);
expect(resolvePrevisFrameBounds([hidden])).toBeNull();
});
test("fits every visible actor and needs more distance in portrait viewports", () => {
const actors = [createPrevisActor("A", [0, 0, 0]), createPrevisActor("B", [1.8, 0, -0.8])];
const wide = resolvePrevisFrameBounds(actors, undefined, 16 / 9, 50);
const tall = resolvePrevisFrameBounds(actors, undefined, 9 / 16, 50);
expect(wide).not.toBeNull();
expect(tall).not.toBeNull();
expect(wide!.min.x).toBeLessThan(0);
expect(wide!.max.x).toBeGreaterThan(1.8);
expect(tall!.fitDistance).toBeGreaterThan(wide!.fitDistance);
});
});