refactor(canvas): 预演台 - 重构工作台与场景交互链路

This commit is contained in:
ddcat666
2026-10-06 09:06:43 +08:00
parent 1dfc8ce7ac
commit 112916587c
128 changed files with 42372 additions and 5011 deletions

View File

@@ -4,52 +4,52 @@ import { resolve } from "node:path";
import { Euler, Vector3 } from "three";
import {
DIRECTOR_DEFAULT_VIEW_MODE,
DIRECTOR_VIEW_MODES,
directorUsablePerspectiveProjection,
directorViewFramingKey,
directorViewModeCapabilities,
resolveDirectorActiveCamera,
resolveDirectorActiveShot,
resolveDirectorEffectiveViewport,
resolveDirectorOrthographicFraming,
resolveDirectorOrthographicFrustum,
resolveDirectorViewFraming,
resolveDirectorViewUp,
type DirectorOrthographicFrustum,
type DirectorViewMode,
} from "../src/lib/canvas/director/director-view-modes";
import { createDirectorCamera, createDirectorLight, createDirectorObject, createDirectorScene } from "../src/lib/canvas/director/director-scene";
import type { DirectorObject, DirectorScene, DirectorVec3 } from "../src/types/director";
PREVIS_DEFAULT_VIEW_MODE,
PREVIS_VIEW_MODES,
previsUsablePerspectiveProjection,
previsViewFramingKey,
previsViewModeCapabilities,
resolvePrevisActiveCamera,
resolvePrevisActiveShot,
resolvePrevisEffectiveViewport,
resolvePrevisOrthographicFraming,
resolvePrevisOrthographicFrustum,
resolvePrevisViewFraming,
resolvePrevisViewUp,
type PrevisOrthographicFrustum,
type PrevisViewMode,
} from "../src/lib/canvas/previs/previs-view-modes";
import { createPrevisCamera, createPrevisLight, createPrevisObject, createPrevisScene } from "../src/lib/canvas/previs/previs-scene";
import type { PrevisObject, PrevisScene, PrevisVec3 } from "../src/types/previs";
// 只读取工具栏源码做契约断言(见文末 describe);视口接线不在这次任务范围内 —— 那是集成方要做的事,
// 不该让这份测试的通过与否依赖另一条 pane 正在改动的文件。
const toolbar = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-view-toolbar.tsx"), "utf8");
const toolbar = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/previs-view-toolbar.tsx"), "utf8");
const AXIS_MODES = ["top", "front", "back", "left", "right"] as const;
function objectAt(position: DirectorVec3): DirectorObject {
const object = createDirectorObject("box", "点云对象", position);
function objectAt(position: PrevisVec3): PrevisObject {
const object = createPrevisObject("box", "点云对象", position);
return { ...object, transform: { ...object.transform, scale: [0, 0, 0] } };
}
/** 只用可控的对象点位构图,摄影机/灯光清空,避免默认场景里的固定坐标干扰包围盒断言。 */
function sceneWithObjects(objects: DirectorObject[]): DirectorScene {
return { ...createDirectorScene(), cameras: [], lights: [], objects };
function sceneWithObjects(objects: PrevisObject[]): PrevisScene {
return { ...createPrevisScene(), cameras: [], lights: [], objects };
}
describe("取景模式骨架", () => {
test("恰好七个取景模式,顺序为 3D/CAM/TOP/FRONT/BACK/LEFT/RIGHT,模式与标签一一对应", () => {
expect(DIRECTOR_VIEW_MODES.map((item) => item.mode)).toEqual(["free", "camera", "top", "front", "back", "left", "right"]);
expect(DIRECTOR_VIEW_MODES.map((item) => item.label)).toEqual(["3D", "CAM", "TOP", "FRONT", "BACK", "LEFT", "RIGHT"]);
expect(PREVIS_VIEW_MODES.map((item) => item.mode)).toEqual(["free", "camera", "top", "front", "back", "left", "right"]);
expect(PREVIS_VIEW_MODES.map((item) => item.label)).toEqual(["3D", "CAM", "TOP", "FRONT", "BACK", "LEFT", "RIGHT"]);
});
test("默认是自由视角:接线前后行为一致", () => {
expect(DIRECTOR_DEFAULT_VIEW_MODE).toBe("free");
expect(PREVIS_DEFAULT_VIEW_MODE).toBe("free");
});
test("每个模式都有可读提示,保证可发现", () => {
DIRECTOR_VIEW_MODES.forEach((item) => {
PREVIS_VIEW_MODES.forEach((item) => {
expect(item.hint.length).toBeGreaterThan(0);
expect(item.label.length).toBeGreaterThan(0);
});
@@ -58,65 +58,65 @@ describe("取景模式骨架", () => {
describe("能力矩阵", () => {
test("3D 允许环绕、透视投影,且不接管取景", () => {
expect(directorViewModeCapabilities("free")).toEqual({ orbit: true, framed: false, projection: "perspective" });
expect(previsViewModeCapabilities("free")).toEqual({ orbit: true, framed: false, projection: "perspective" });
});
test("CAM 锁死环绕、透视投影,并接管取景", () => {
expect(directorViewModeCapabilities("camera")).toEqual({ orbit: false, framed: true, projection: "perspective" });
expect(previsViewModeCapabilities("camera")).toEqual({ orbit: false, framed: true, projection: "perspective" });
});
test("五个正交轴向:锁环绕、不接管取景、必须是正交投影", () => {
AXIS_MODES.forEach((mode) => {
expect(directorViewModeCapabilities(mode)).toEqual({ orbit: false, framed: false, projection: "orthographic" });
expect(previsViewModeCapabilities(mode)).toEqual({ orbit: false, framed: false, projection: "orthographic" });
});
});
test("未知模式回落默认能力,不返回 undefined", () => {
expect(directorViewModeCapabilities("nope" as DirectorViewMode)).toEqual(directorViewModeCapabilities("free"));
expect(previsViewModeCapabilities("nope" as PrevisViewMode)).toEqual(previsViewModeCapabilities("free"));
});
});
describe("shot/camera 解析回落", () => {
test("activeShotId 命中时取该 shot 与它的摄影机", () => {
const scene = createDirectorScene();
expect(resolveDirectorActiveShot(scene)?.id).toBe(scene.activeShotId);
expect(resolveDirectorActiveCamera(scene)?.id).toBe(scene.cameras[0].id);
const scene = createPrevisScene();
expect(resolvePrevisActiveShot(scene)?.id).toBe(scene.activeShotId);
expect(resolvePrevisActiveCamera(scene)?.id).toBe(scene.cameras[0].id);
});
test("activeShotId 指向不存在的 shot 时回落第一个 shot", () => {
const scene: DirectorScene = { ...createDirectorScene(), activeShotId: "missing" };
expect(resolveDirectorActiveShot(scene)?.id).toBe(scene.shots[0].id);
const scene: PrevisScene = { ...createPrevisScene(), activeShotId: "missing" };
expect(resolvePrevisActiveShot(scene)?.id).toBe(scene.shots[0].id);
});
test("shot 的 cameraId 失效时回落第一台摄影机,而不是返回 null", () => {
const base = createDirectorScene();
const scene: DirectorScene = { ...base, shots: [{ ...base.shots[0], cameraId: "missing" }] };
expect(resolveDirectorActiveCamera(scene)?.id).toBe(base.cameras[0].id);
const base = createPrevisScene();
const scene: PrevisScene = { ...base, shots: [{ ...base.shots[0], cameraId: "missing" }] };
expect(resolvePrevisActiveCamera(scene)?.id).toBe(base.cameras[0].id);
});
test("空场景返回 null 而不是抛异常", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
expect(resolveDirectorActiveShot(scene)).toBeNull();
expect(resolveDirectorActiveCamera(scene)).toBeNull();
const scene: PrevisScene = { ...createPrevisScene(), shots: [], cameras: [] };
expect(resolvePrevisActiveShot(scene)).toBeNull();
expect(resolvePrevisActiveCamera(scene)).toBeNull();
});
});
describe("CAM 取景解算", () => {
test("3D 模式不产生取景:自由视角完全不被写入", () => {
expect(resolveDirectorViewFraming({ scene: createDirectorScene(), mode: "free", playhead: 0 })).toBeNull();
expect(resolvePrevisViewFraming({ scene: createPrevisScene(), mode: "free", playhead: 0 })).toBeNull();
});
test("五个正交轴向同样不触发 CAM 取景:framed 只属于 camera", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorViewFraming({ scene, mode, playhead: 0 })).toBeNull();
expect(resolvePrevisViewFraming({ scene, mode, playhead: 0 })).toBeNull();
});
});
test("CAM 模式取当前 shot 摄影机的位置、焦点与光学参数", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
const camera = scene.cameras[0];
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
const framing = resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing?.cameraId).toBe(camera.id);
expect(framing?.position).toEqual(camera.transform.position);
expect(framing?.target).toEqual(camera.target);
@@ -126,77 +126,77 @@ describe("CAM 取景解算", () => {
});
test("CAM 模式下位置按 playhead 插值:拖时间轴即预览运镜", () => {
const base = createDirectorScene();
const base = createPrevisScene();
const camera = {
...base.cameras[0],
keyframes: [
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 } },
],
};
const scene: DirectorScene = { ...base, cameras: [camera] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 1 })?.position[0]).toBeCloseTo(5, 5);
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })?.position[0]).toBeCloseTo(0, 5);
const scene: PrevisScene = { ...base, cameras: [camera] };
expect(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 1 })?.position[0]).toBeCloseTo(5, 5);
expect(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 })?.position[0]).toBeCloseTo(0, 5);
});
test("空场景在 CAM 模式下返回 null,视口保持自由视角而不是黑屏", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
const scene: PrevisScene = { ...createPrevisScene(), shots: [], cameras: [] };
expect(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
});
test("非法数值(NaN/Infinity)不产生取景:绝不把坏值写进相机", () => {
const base = createDirectorScene();
const broken = { ...createDirectorCamera(), transform: { position: [Number.NaN, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } };
const brokenTarget = { ...createDirectorCamera(), target: [0, Number.POSITIVE_INFINITY, 0] as DirectorVec3 };
const brokenFov = { ...createDirectorCamera(), fov: Number.NaN };
const base = createPrevisScene();
const broken = { ...createPrevisCamera(), transform: { position: [Number.NaN, 0, 0] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 } };
const brokenTarget = { ...createPrevisCamera(), target: [0, Number.POSITIVE_INFINITY, 0] as PrevisVec3 };
const brokenFov = { ...createPrevisCamera(), fov: Number.NaN };
[broken, brokenTarget, brokenFov].forEach((camera) => {
const scene: DirectorScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
const scene: PrevisScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
expect(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
});
});
test("playhead 非有限时按 0 解算,而不是产出 NaN 取景", () => {
const scene = createDirectorScene();
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: Number.NaN });
const scene = createPrevisScene();
const framing = resolvePrevisViewFraming({ scene, mode: "camera", playhead: Number.NaN });
expect(framing).not.toBeNull();
expect(framing?.position.every(Number.isFinite)).toBe(true);
});
test("位置与焦点重合时沿摄影机 -Z 造焦点,视线不为零向量", () => {
const base = createDirectorScene();
const camera = { ...createDirectorCamera(), transform: { position: [1, 1, 1] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 }, target: [1, 1, 1] as DirectorVec3 };
const scene: DirectorScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
const base = createPrevisScene();
const camera = { ...createPrevisCamera(), transform: { position: [1, 1, 1] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 }, target: [1, 1, 1] as PrevisVec3 };
const scene: PrevisScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
const framing = resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing).not.toBeNull();
expect(framing?.target).toEqual([1, 1, 0]);
});
});
function sceneWithOptics(optics: { fov?: number; near?: number; far?: number }): DirectorScene {
const base = createDirectorScene();
function sceneWithOptics(optics: { fov?: number; near?: number; far?: number }): PrevisScene {
const base = createPrevisScene();
const camera = { ...base.cameras[0], ...optics };
return { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
}
describe("透视投影可用性:拒绝写进 Three 的非法 fov/near/far", () => {
test("默认摄影机光学参数可用", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
const camera = scene.cameras[0];
expect(directorUsablePerspectiveProjection({ fov: camera.fov, near: camera.near, far: camera.far })).toBe(true);
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).not.toBeNull();
expect(previsUsablePerspectiveProjection({ fov: camera.fov, near: camera.near, far: camera.far })).toBe(true);
expect(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 })).not.toBeNull();
});
test("非法 fov(0 / 180 / 超出开区间 / 负 / 非有限)不产生取景", () => {
[0, 180, 181, -10, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((fov) => {
expect(directorUsablePerspectiveProjection({ fov, near: 0.05, far: 500 })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics({ fov }), mode: "camera", playhead: 0 })).toBeNull();
expect(previsUsablePerspectiveProjection({ fov, near: 0.05, far: 500 })).toBe(false);
expect(resolvePrevisViewFraming({ scene: sceneWithOptics({ fov }), mode: "camera", playhead: 0 })).toBeNull();
});
});
test("非法 near(0 / 负 / 非有限)不产生取景", () => {
[0, -0.1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((near) => {
expect(directorUsablePerspectiveProjection({ fov: 50, near, far: 500 })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics({ near }), mode: "camera", playhead: 0 })).toBeNull();
expect(previsUsablePerspectiveProjection({ fov: 50, near, far: 500 })).toBe(false);
expect(resolvePrevisViewFraming({ scene: sceneWithOptics({ near }), mode: "camera", playhead: 0 })).toBeNull();
});
});
@@ -207,8 +207,8 @@ describe("透视投影可用性:拒绝写进 Three 的非法 fov/near/far", ()
{ near: 0.05, far: 0 },
{ near: 0.05, far: Number.NaN },
].forEach((optics) => {
expect(directorUsablePerspectiveProjection({ fov: 50, ...optics })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 })).toBeNull();
expect(previsUsablePerspectiveProjection({ fov: 50, ...optics })).toBe(false);
expect(resolvePrevisViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 })).toBeNull();
});
});
@@ -219,8 +219,8 @@ describe("透视投影可用性:拒绝写进 Three 的非法 fov/near/far", ()
{ fov: 50, near: 1e-6, far: 1e-5 },
{ fov: 50, near: 0.05, far: 0.0500001 },
].forEach((optics) => {
expect(directorUsablePerspectiveProjection(optics)).toBe(true);
const framing = resolveDirectorViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 });
expect(previsUsablePerspectiveProjection(optics)).toBe(true);
const framing = resolvePrevisViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 });
expect(framing).not.toBeNull();
expect(framing?.fov).toBe(optics.fov);
expect(framing?.near).toBe(optics.near);
@@ -231,61 +231,61 @@ describe("透视投影可用性:拒绝写进 Three 的非法 fov/near/far", ()
describe("有效视口回落:CAM 无取景时用 free,恢复后回到 CAM", () => {
test("空 CAM 场景:取景为 null,活动相机与环绕都回落 free,不改 scene", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
const scene: PrevisScene = { ...createPrevisScene(), shots: [], cameras: [] };
const snapshot = JSON.parse(JSON.stringify(scene));
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
const framing = resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
expect(resolvePrevisEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
expect(scene).toEqual(snapshot);
});
test("合法 → 非法 → 合法:同一决策函数确定性回到 CAM", () => {
const validScene = createDirectorScene();
const valid = resolveDirectorViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
const validScene = createPrevisScene();
const valid = resolvePrevisViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
expect(valid).not.toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: valid })).toEqual({ camera: "camera", orbit: false });
expect(resolvePrevisEffectiveViewport({ mode: "camera", framing: valid })).toEqual({ camera: "camera", orbit: false });
const invalid = resolveDirectorViewFraming({ scene: sceneWithOptics({ fov: 0 }), mode: "camera", playhead: 0 });
const invalid = resolvePrevisViewFraming({ scene: sceneWithOptics({ fov: 0 }), mode: "camera", playhead: 0 });
expect(invalid).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: invalid })).toEqual({ camera: "free", orbit: true });
expect(resolvePrevisEffectiveViewport({ mode: "camera", framing: invalid })).toEqual({ camera: "free", orbit: true });
const recovered = resolveDirectorViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
const recovered = resolvePrevisViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
expect(recovered).not.toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: recovered })).toEqual({ camera: "camera", orbit: false });
expect(directorViewFramingKey(recovered)).toBe(directorViewFramingKey(valid));
expect(resolvePrevisEffectiveViewport({ mode: "camera", framing: recovered })).toEqual({ camera: "camera", orbit: false });
expect(previsViewFramingKey(recovered)).toBe(previsViewFramingKey(valid));
});
test("非法 fov/near/far 与空场景走同一条 free 回落", () => {
const empty = resolveDirectorViewFraming({ scene: { ...createDirectorScene(), shots: [], cameras: [] }, mode: "camera", playhead: 0 });
const badFov = resolveDirectorViewFraming({ scene: sceneWithOptics({ fov: 180 }), mode: "camera", playhead: 0 });
const badNear = resolveDirectorViewFraming({ scene: sceneWithOptics({ near: 0 }), mode: "camera", playhead: 0 });
const badFar = resolveDirectorViewFraming({ scene: sceneWithOptics({ near: 10, far: 1 }), mode: "camera", playhead: 0 });
const empty = resolvePrevisViewFraming({ scene: { ...createPrevisScene(), shots: [], cameras: [] }, mode: "camera", playhead: 0 });
const badFov = resolvePrevisViewFraming({ scene: sceneWithOptics({ fov: 180 }), mode: "camera", playhead: 0 });
const badNear = resolvePrevisViewFraming({ scene: sceneWithOptics({ near: 0 }), mode: "camera", playhead: 0 });
const badFar = resolvePrevisViewFraming({ scene: sceneWithOptics({ near: 10, far: 1 }), mode: "camera", playhead: 0 });
[empty, badFov, badNear, badFar].forEach((framing) => {
expect(framing).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
expect(resolvePrevisEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
});
});
test("3D 模式始终是 free,即使误传入 CAM 取景也不切换相机", () => {
const framing = resolveDirectorViewFraming({ scene: createDirectorScene(), mode: "camera", playhead: 0 });
expect(resolveDirectorEffectiveViewport({ mode: "free", framing })).toEqual({ camera: "free", orbit: true });
expect(resolveDirectorEffectiveViewport({ mode: "free", framing: null })).toEqual({ camera: "free", orbit: true });
const framing = resolvePrevisViewFraming({ scene: createPrevisScene(), mode: "camera", playhead: 0 });
expect(resolvePrevisEffectiveViewport({ mode: "free", framing })).toEqual({ camera: "free", orbit: true });
expect(resolvePrevisEffectiveViewport({ mode: "free", framing: null })).toEqual({ camera: "free", orbit: true });
});
test("五个正交轴向不走 CAM 回落:始终正交且锁环绕", () => {
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorEffectiveViewport({ mode, framing: null })).toEqual({ camera: "orthographic", orbit: false });
expect(resolvePrevisEffectiveViewport({ mode, framing: null })).toEqual({ camera: "orthographic", orbit: false });
});
});
test("回落决策是纯函数,不写 scene / 不产生 history 输入", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
const objectsRef = scene.objects;
const camerasRef = scene.cameras;
const snapshot = JSON.parse(JSON.stringify(scene));
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
resolveDirectorEffectiveViewport({ mode: "camera", framing });
resolveDirectorEffectiveViewport({ mode: "camera", framing: null });
const framing = resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 });
resolvePrevisEffectiveViewport({ mode: "camera", framing });
resolvePrevisEffectiveViewport({ mode: "camera", framing: null });
expect(scene).toEqual(snapshot);
expect(scene.objects).toBe(objectsRef);
expect(scene.cameras).toBe(camerasRef);
@@ -294,40 +294,40 @@ describe("有效视口回落:CAM 无取景时用 free,恢复后回到 CAM",
describe("up 向量:保住荷兰角,且绝不与视线共线", () => {
test("无 roll 时就是世界 up", () => {
expect(resolveDirectorViewUp([0, 0, 0], [0, 0, -1])).toEqual([0, 1, 0]);
expect(resolvePrevisViewUp([0, 0, 0], [0, 0, -1])).toEqual([0, 1, 0]);
});
test("有 roll 时 up 随之旋转:裸 lookAt 会丢掉的荷兰角在这里留住", () => {
const up = resolveDirectorViewUp([0, 0, Math.PI / 2], [0, 0, -1]);
const up = resolvePrevisViewUp([0, 0, Math.PI / 2], [0, 0, -1]);
expect(up[0]).toBeCloseTo(-1, 5);
expect(up[1]).toBeCloseTo(0, 5);
expect(Math.abs(up[1] - 1)).toBeGreaterThan(0.5);
});
test("正俯视退化时换备选 up,叉积非零(否则 lookAt 解出 NaN 矩阵)", () => {
const view: DirectorVec3 = [0, -1, 0];
const up = resolveDirectorViewUp([0, 0, 0], view);
const view: PrevisVec3 = [0, -1, 0];
const up = resolvePrevisViewUp([0, 0, 0], view);
expect(up).toEqual([0, 0, -1]);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
});
test("正仰视同样不共线", () => {
const view: DirectorVec3 = [0, 1, 0];
const up = resolveDirectorViewUp([0, 0, 0], view);
const view: PrevisVec3 = [0, 1, 0];
const up = resolvePrevisViewUp([0, 0, 0], view);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
});
test("视线为零向量时返回世界 up,不返回非法值", () => {
expect(resolveDirectorViewUp([0, 0, 0], [0, 0, 0])).toEqual([0, 1, 0]);
expect(resolvePrevisViewUp([0, 0, 0], [0, 0, 0])).toEqual([0, 1, 0]);
});
test("任意朝向下 up 与视线都不共线:抽样覆盖球面", () => {
const angles = [-Math.PI / 2, -Math.PI / 4, 0, Math.PI / 4, Math.PI / 2, Math.PI];
angles.forEach((pitch) =>
angles.forEach((roll) => {
const rotation: DirectorVec3 = [pitch, 0, roll];
const view = new Vector3(0, 0, -1).applyEuler(new Euler(...rotation)).toArray() as DirectorVec3;
const up = resolveDirectorViewUp(rotation, view);
const rotation: PrevisVec3 = [pitch, 0, roll];
const view = new Vector3(0, 0, -1).applyEuler(new Euler(...rotation)).toArray() as PrevisVec3;
const up = resolvePrevisViewUp(rotation, view);
expect(up.every(Number.isFinite)).toBe(true);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
}),
@@ -337,79 +337,79 @@ describe("up 向量:保住荷兰角,且绝不与视线共线", () => {
describe("取景同步键", () => {
test("无取景时为空串:3D 模式不触发相机回写", () => {
expect(directorViewFramingKey(null)).toBe("");
expect(previsViewFramingKey(null)).toBe("");
});
test("同一取景值稳定:对象换身份不引起重同步", () => {
const scene = createDirectorScene();
const first = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
const second = resolveDirectorViewFraming({ scene: { ...scene, cameras: [{ ...scene.cameras[0] }] }, mode: "camera", playhead: 0 });
expect(directorViewFramingKey(first)).toBe(directorViewFramingKey(second));
const scene = createPrevisScene();
const first = resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 });
const second = resolvePrevisViewFraming({ scene: { ...scene, cameras: [{ ...scene.cameras[0] }] }, mode: "camera", playhead: 0 });
expect(previsViewFramingKey(first)).toBe(previsViewFramingKey(second));
});
test("playhead 推进导致取景变化时键随之变化", () => {
const base = createDirectorScene();
const base = createPrevisScene();
const camera = {
...base.cameras[0],
keyframes: [
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as PrevisVec3, rotation: [0, 0, 0] as PrevisVec3, scale: [1, 1, 1] as PrevisVec3 } },
],
};
const scene: DirectorScene = { ...base, cameras: [camera] };
const at0 = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 }));
const at1 = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 1 }));
const scene: PrevisScene = { ...base, cameras: [camera] };
const at0 = previsViewFramingKey(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 }));
const at1 = previsViewFramingKey(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 1 }));
expect(at0).not.toBe(at1);
});
test("换摄影机时键变化:切 shot 必须重新取景", () => {
const base = createDirectorScene();
const other = createDirectorCamera("副摄影机");
const scene: DirectorScene = { ...base, cameras: [other], shots: [{ ...base.shots[0], cameraId: other.id }] };
const first = directorViewFramingKey(resolveDirectorViewFraming({ scene: base, mode: "camera", playhead: 0 }));
const second = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 }));
const base = createPrevisScene();
const other = createPrevisCamera("副摄影机");
const scene: PrevisScene = { ...base, cameras: [other], shots: [{ ...base.shots[0], cameraId: other.id }] };
const first = previsViewFramingKey(resolvePrevisViewFraming({ scene: base, mode: "camera", playhead: 0 }));
const second = previsViewFramingKey(resolvePrevisViewFraming({ scene, mode: "camera", playhead: 0 }));
expect(first).not.toBe(second);
});
});
describe("正交轴向取景:可用性与基本形状", () => {
test("free/camera 不是正交轴向,返回 null", () => {
const scene = createDirectorScene();
expect(resolveDirectorOrthographicFraming({ scene, mode: "free" })).toBeNull();
expect(resolveDirectorOrthographicFraming({ scene, mode: "camera" })).toBeNull();
const scene = createPrevisScene();
expect(resolvePrevisOrthographicFraming({ scene, mode: "free" })).toBeNull();
expect(resolvePrevisOrthographicFraming({ scene, mode: "camera" })).toBeNull();
});
test("五个正交轴向都返回非空取景,且数值字段全部有限", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode });
const framing = resolvePrevisOrthographicFraming({ scene, mode });
expect(framing).not.toBeNull();
expect([...framing!.position, ...framing!.target, ...framing!.up, framing!.horizontalSpan, framing!.verticalSpan, framing!.near, framing!.far].every(Number.isFinite)).toBe(true);
});
});
test("near 恒小于 far,且都是有限正数", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expect(framing.near).toBeGreaterThan(0);
expect(framing.far).toBeGreaterThan(framing.near);
});
});
test("horizontalSpan 与 verticalSpan 恒为正数,绝不退化成 0(否则正交相机变成无限缩放)", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expect(framing.horizontalSpan).toBeGreaterThan(0);
expect(framing.verticalSpan).toBeGreaterThan(0);
});
});
test("同一输入调用两次结果完全一致:纯函数,值稳定可重复", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorOrthographicFraming({ scene, mode })).toEqual(resolveDirectorOrthographicFraming({ scene, mode }));
expect(resolvePrevisOrthographicFraming({ scene, mode })).toEqual(resolvePrevisOrthographicFraming({ scene, mode }));
});
});
});
@@ -418,7 +418,7 @@ describe("正交轴向取景:五个方向与 up 严格自洽", () => {
const asymmetric = () => sceneWithObjects([objectAt([-1, -1, -1]), objectAt([3, 3, 3])]);
test("top:看向 -Y,位置在目标正上方,up 为 -Z", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "top" })!;
const framing = resolvePrevisOrthographicFraming({ scene: asymmetric(), mode: "top" })!;
expect(framing.up).toEqual([0, 0, -1]);
expect(framing.position[1]).toBeGreaterThan(framing.target[1]);
expect(framing.position[0]).toBeCloseTo(framing.target[0], 5);
@@ -426,26 +426,26 @@ describe("正交轴向取景:五个方向与 up 严格自洽", () => {
});
test("front:看向 -Z,位置在目标 +Z 一侧,up 为 +Y", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "front" })!;
const framing = resolvePrevisOrthographicFraming({ scene: asymmetric(), mode: "front" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[2]).toBeGreaterThan(framing.target[2]);
expect(framing.position[0]).toBeCloseTo(framing.target[0], 5);
});
test("back:看向 +Z,位置在目标 -Z 一侧,up 为 +Y,与 front 正对面", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "back" })!;
const framing = resolvePrevisOrthographicFraming({ scene: asymmetric(), mode: "back" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[2]).toBeLessThan(framing.target[2]);
});
test("left:看向 +X,位置在目标 -X 一侧,up 为 +Y", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "left" })!;
const framing = resolvePrevisOrthographicFraming({ scene: asymmetric(), mode: "left" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[0]).toBeLessThan(framing.target[0]);
});
test("right:看向 -X,位置在目标 +X 一侧,up 为 +Y,与 left 正对面", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "right" })!;
const framing = resolvePrevisOrthographicFraming({ scene: asymmetric(), mode: "right" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[0]).toBeGreaterThan(framing.target[0]);
});
@@ -453,7 +453,7 @@ describe("正交轴向取景:五个方向与 up 严格自洽", () => {
test("up 与视线方向恒定正交,绝不共线", () => {
const scene = asymmetric();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
const view = framing.target.map((value, index) => value - framing.position[index]);
const dot = view.reduce((sum, value, index) => sum + value * framing.up[index], 0);
expect(Math.abs(dot)).toBeLessThan(1e-9);
@@ -464,18 +464,18 @@ describe("正交轴向取景:五个方向与 up 严格自洽", () => {
describe("正交轴向取景:包围盒驱动的 target 与 verticalSpan", () => {
test("target 是可见内容的包围盒中心", () => {
const scene = sceneWithObjects([objectAt([-3, 1, -2]), objectAt([5, 4, 6])]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([1, 2.5, 2]);
});
test("投影跨度取决于屏幕轴:top=X/Z,front/back=X/Y,left/right=Z/Y", () => {
// x 范围 10,y 范围 4,z 范围 6 —— 三轴都不同,能分辨每一种模式读了哪两根轴。
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([10, 4, 6])]);
const top = resolveDirectorOrthographicFraming({ scene, mode: "top" })!;
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const back = resolveDirectorOrthographicFraming({ scene, mode: "back" })!;
const left = resolveDirectorOrthographicFraming({ scene, mode: "left" })!;
const right = resolveDirectorOrthographicFraming({ scene, mode: "right" })!;
const top = resolvePrevisOrthographicFraming({ scene, mode: "top" })!;
const front = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
const back = resolvePrevisOrthographicFraming({ scene, mode: "back" })!;
const left = resolvePrevisOrthographicFraming({ scene, mode: "left" })!;
const right = resolvePrevisOrthographicFraming({ scene, mode: "right" })!;
expect(top.horizontalSpan / front.horizontalSpan).toBeCloseTo(1, 5);
expect(top.verticalSpan / front.verticalSpan).toBeCloseTo(6 / 4, 5);
expect(front.horizontalSpan).toBeCloseTo(back.horizontalSpan, 9);
@@ -490,23 +490,23 @@ describe("正交轴向取景:包围盒驱动的 target 与 verticalSpan", () =
test("verticalSpan 随包围盒线性缩放:内容放大 2 倍,取景高度也放大 2 倍", () => {
const small = sceneWithObjects([objectAt([-1, -1, -1]), objectAt([1, 1, 1])]);
const large = sceneWithObjects([objectAt([-2, -2, -2]), objectAt([2, 2, 2])]);
const smallSpan = resolveDirectorOrthographicFraming({ scene: small, mode: "front" })!.verticalSpan;
const largeSpan = resolveDirectorOrthographicFraming({ scene: large, mode: "front" })!.verticalSpan;
const smallSpan = resolvePrevisOrthographicFraming({ scene: small, mode: "front" })!.verticalSpan;
const largeSpan = resolvePrevisOrthographicFraming({ scene: large, mode: "front" })!.verticalSpan;
expect(largeSpan / smallSpan).toBeCloseTo(2, 5);
});
test("对称立方体包围盒下,五个轴向的 verticalSpan 完全相等", () => {
const scene = sceneWithObjects([objectAt([-2, -2, -2]), objectAt([2, 2, 2])]);
const spans = AXIS_MODES.map((mode) => resolveDirectorOrthographicFraming({ scene, mode })!.verticalSpan);
const spans = AXIS_MODES.map((mode) => resolvePrevisOrthographicFraming({ scene, mode })!.verticalSpan);
spans.forEach((span) => expect(span).toBeCloseTo(spans[0], 9));
});
test("大比例和旋转后的对象按角点构图,不再只看中心导致裁切", () => {
const wide = createDirectorObject("plane", "背景板", [0, 1, 0]);
const wide = createPrevisObject("plane", "背景板", [0, 1, 0]);
wide.transform = { position: [0, 1, 0], rotation: [0, Math.PI / 4, 0], scale: [10, 4, 2] };
const scene = sceneWithObjects([wide]);
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const top = resolveDirectorOrthographicFraming({ scene, mode: "top" })!;
const front = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
const top = resolvePrevisOrthographicFraming({ scene, mode: "top" })!;
expect(front.horizontalSpan).toBeGreaterThan(8);
expect(front.verticalSpan).toBeCloseTo(4 * 1.2, 6);
@@ -519,7 +519,7 @@ describe("正交轴向取景:空场景与非法数据回落", () => {
test("空场景(无对象/摄影机/灯光)回落安全默认包围盒,target 在原点", () => {
const scene = sceneWithObjects([]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expect(framing).not.toBeNull();
expect(framing.target).toEqual([0, 0, 0]);
expect(framing.horizontalSpan).toBeGreaterThan(0);
@@ -528,35 +528,35 @@ describe("正交轴向取景:空场景与非法数据回落", () => {
});
test("全部点位非法(NaN/Infinity)时回落安全默认包围盒,而不是产出非 finite 值", () => {
const base = createDirectorScene();
const scene: DirectorScene = {
const base = createPrevisScene();
const scene: PrevisScene = {
...base,
objects: [objectAt([Number.NaN, 0, 0]), objectAt([0, Number.POSITIVE_INFINITY, 0])],
cameras: [{ ...createDirectorCamera(), transform: { position: [Number.NaN, Number.NaN, Number.NaN], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
lights: [{ ...createDirectorLight("point", "坏灯光", [0, 0, 0]), transform: { position: [Number.NEGATIVE_INFINITY, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
cameras: [{ ...createPrevisCamera(), transform: { position: [Number.NaN, Number.NaN, Number.NaN], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
lights: [{ ...createPrevisLight("point", "坏灯光", [0, 0, 0]), transform: { position: [Number.NEGATIVE_INFINITY, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
};
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([0, 0, 0]);
expect([...framing.position, framing.horizontalSpan, framing.verticalSpan, framing.near, framing.far].every(Number.isFinite)).toBe(true);
});
test("部分点位非法时只用合法点计算包围盒,不被坏值污染", () => {
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([Number.NaN, 5, 0]), objectAt([4, 4, 0])]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
// 合法点只有 (0,0,0) 与 (4,4,0):中心 (2,2,0)。
expect(framing.target).toEqual([2, 2, 0]);
});
test("隐藏对象不计入包围盒,只有可见内容参与构图", () => {
const visible = objectAt([0, 0, 0]);
const hidden: DirectorObject = { ...objectAt([100, 100, 100]), visible: false };
const hidden: PrevisObject = { ...objectAt([100, 100, 100]), visible: false };
const scene = sceneWithObjects([visible, hidden]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const framing = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([0, 0, 0]);
});
});
function expectFrustumFits(frustum: DirectorOrthographicFrustum, horizontalSpan: number, verticalSpan: number, aspect: number) {
function expectFrustumFits(frustum: PrevisOrthographicFrustum, horizontalSpan: number, verticalSpan: number, aspect: number) {
const width = frustum.right - frustum.left;
const height = frustum.top - frustum.bottom;
expect(frustum.halfWidth).toBeGreaterThan(0);
@@ -575,57 +575,57 @@ describe("正交视锥:同时装下水平与竖直跨度", () => {
test("极宽内容:正方形视口下半宽度等于水平跨度的一半,而不是高度×aspect", () => {
const scene = sceneWithObjects([objectAt([-40, 0, 0]), objectAt([40, 2, 0])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
});
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const front = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
expect(front.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
const frustum = resolvePrevisOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
expect(frustum.halfWidth).toBeCloseTo(front.horizontalSpan / 2, 9);
expect(frustum.halfWidth).toBeGreaterThan(front.verticalSpan / 2);
});
test("极高内容:正方形视口下半高度等于竖直跨度的一半,水平方向按 aspect 补齐", () => {
const scene = sceneWithObjects([objectAt([0, -40, 0]), objectAt([2, 40, 0])]);
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const front = resolvePrevisOrthographicFraming({ scene, mode: "front" })!;
expect(front.verticalSpan).toBeCloseTo(80 * 1.2, 9);
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
const frustum = resolvePrevisOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
expectFrustumFits(frustum, front.horizontalSpan, front.verticalSpan, 1);
expect(frustum.halfHeight).toBeCloseTo(front.verticalSpan / 2, 9);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
});
});
test("窄视口(aspect=0.5)仍同时装下两条跨度,且宽高比保持 0.5", () => {
const scene = sceneWithObjects([objectAt([-5, 0, 0]), objectAt([5, 4, 0])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 0.5 }), framing.horizontalSpan, framing.verticalSpan, 0.5);
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 0.5 }), framing.horizontalSpan, framing.verticalSpan, 0.5);
});
});
test("宽视口(aspect=2)仍同时装下两条跨度,且宽高比保持 2", () => {
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([4, 4, 4])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 2 }), framing.horizontalSpan, framing.verticalSpan, 2);
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 2 }), framing.horizontalSpan, framing.verticalSpan, 2);
});
});
test("空场景回落默认包围盒后,视锥仍有限、为正,并能装下两条跨度", () => {
const scene = sceneWithObjects([]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 16 / 9 });
const framing = resolvePrevisOrthographicFraming({ scene, mode })!;
const frustum = resolvePrevisOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 16 / 9 });
expectFrustumFits(frustum, framing.horizontalSpan, framing.verticalSpan, 16 / 9);
});
});
test("aspect 为 0 / 负 / NaN / ±Infinity 时回落正方形,半范围仍有限且为正", () => {
[0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((aspect) => {
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: 10, verticalSpan: 10, aspect });
const frustum = resolvePrevisOrthographicFrustum({ horizontalSpan: 10, verticalSpan: 10, aspect });
expectFrustumFits(frustum, 10, 10, 1);
expect(frustum.halfWidth).toBeCloseTo(frustum.halfHeight, 9);
});
@@ -635,7 +635,7 @@ describe("正交视锥:同时装下水平与竖直跨度", () => {
const invalid = [0, -4, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
invalid.forEach((horizontalSpan) =>
invalid.forEach((verticalSpan) => {
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan, verticalSpan, aspect: 1 });
const frustum = resolvePrevisOrthographicFrustum({ horizontalSpan, verticalSpan, aspect: 1 });
expect(frustum.halfWidth).toBeGreaterThan(0);
expect(frustum.halfHeight).toBeGreaterThan(0);
expect([frustum.halfWidth, frustum.halfHeight, frustum.left, frustum.right, frustum.top, frustum.bottom].every(Number.isFinite)).toBe(true);
@@ -646,28 +646,28 @@ describe("正交视锥:同时装下水平与竖直跨度", () => {
test("left/right 的极宽内容沿 Z 装入水平跨度,top 的极宽内容沿 X 装入水平跨度", () => {
const alongZ = sceneWithObjects([objectAt([0, 0, -40]), objectAt([0, 2, 40])]);
const left = resolveDirectorOrthographicFraming({ scene: alongZ, mode: "left" })!;
const right = resolveDirectorOrthographicFraming({ scene: alongZ, mode: "right" })!;
const left = resolvePrevisOrthographicFraming({ scene: alongZ, mode: "left" })!;
const right = resolvePrevisOrthographicFraming({ scene: alongZ, mode: "right" })!;
expect(left.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
expect(right.horizontalSpan).toBeCloseTo(left.horizontalSpan, 9);
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: left.horizontalSpan, verticalSpan: left.verticalSpan, aspect: 1 }), left.horizontalSpan, left.verticalSpan, 1);
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: left.horizontalSpan, verticalSpan: left.verticalSpan, aspect: 1 }), left.horizontalSpan, left.verticalSpan, 1);
const alongX = sceneWithObjects([objectAt([-40, 0, 0]), objectAt([40, 0, 2])]);
const top = resolveDirectorOrthographicFraming({ scene: alongX, mode: "top" })!;
const top = resolvePrevisOrthographicFraming({ scene: alongX, mode: "top" })!;
expect(top.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: top.horizontalSpan, verticalSpan: top.verticalSpan, aspect: 1 }), top.horizontalSpan, top.verticalSpan, 1);
expectFrustumFits(resolvePrevisOrthographicFrustum({ horizontalSpan: top.horizontalSpan, verticalSpan: top.verticalSpan, aspect: 1 }), top.horizontalSpan, top.verticalSpan, 1);
});
});
describe("正交轴向取景:绝不写场景", () => {
test("调用后传入的 scene 与其 objects/cameras/lights 数组身份都不变", () => {
const scene = createDirectorScene();
const scene = createPrevisScene();
const snapshot = JSON.parse(JSON.stringify(scene));
const objectsRef = scene.objects;
const camerasRef = scene.cameras;
const lightsRef = scene.lights;
(["free", "camera", ...AXIS_MODES] as const).forEach((mode) => {
resolveDirectorOrthographicFraming({ scene, mode });
resolvePrevisOrthographicFraming({ scene, mode });
});
expect(scene).toEqual(snapshot);
expect(scene.objects).toBe(objectsRef);
@@ -681,7 +681,7 @@ describe("切换器可发现、可键盘、无新增全局样式", () => {
expect(toolbar).toContain('type="button"');
expect(toolbar).toContain("aria-pressed={active}");
expect(toolbar).toContain('role="group"');
expect(toolbar).toContain('aria-label="导演台取景模式"');
expect(toolbar).toContain('aria-label="预演台取景模式"');
});
test("每个按钮都有 aria-label 与 title,图标化文字也能被读出", () => {
@@ -695,12 +695,12 @@ describe("切换器可发现、可键盘、无新增全局样式", () => {
});
test("点完释放焦点,否则 W/E/R 变换快捷键会被守卫吃掉", () => {
expect(toolbar).toContain("releaseDirectorFocusAfterPointer(event)");
expect(toolbar).toContain("releasePrevisFocusAfterPointer(event)");
});
test("只用既有 token,不写颜色字面值", () => {
expect(toolbar).toContain("var(--director-dock-surface)");
expect(toolbar).toContain("var(--director-dock-active-surface)");
expect(toolbar).toContain("var(--previs-dock-surface)");
expect(toolbar).toContain("var(--previs-dock-active-surface)");
expect(toolbar).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
expect(toolbar).not.toMatch(/\brgba?\(/);
});
@@ -708,16 +708,16 @@ describe("切换器可发现、可键盘、无新增全局样式", () => {
test("不复用写死正方形尺寸的 dock 按钮类:CAM 是文字标签", () => {
const classNames = [...toolbar.matchAll(/className="([^"]*)"/g)].map((match) => match[1]).join(" ");
expect(classNames.length).toBeGreaterThan(0);
expect(classNames).not.toContain("director-viewport-dock");
expect(classNames).not.toContain("previs-viewport-dock");
});
test("对 DIRECTOR_VIEW_MODES 做整体 map,不写死具体模式或数量:新增/删减模式无需改这个文件", () => {
expect(toolbar).toContain("DIRECTOR_VIEW_MODES.map((item) => {");
test("对 PREVIS_VIEW_MODES 做整体 map,不写死具体模式或数量:新增/删减模式无需改这个文件", () => {
expect(toolbar).toContain("PREVIS_VIEW_MODES.map((item) => {");
expect(toolbar).not.toMatch(/item\.mode\s*===\s*"/);
expect(toolbar).not.toMatch(/DIRECTOR_VIEW_MODES\[\d/);
expect(toolbar).not.toMatch(/PREVIS_VIEW_MODES\[\d/);
});
test("七个模式全部渲染:DIRECTOR_VIEW_MODES 有几项,toolbar 就自动出几个按钮", () => {
expect(DIRECTOR_VIEW_MODES).toHaveLength(7);
test("七个模式全部渲染:PREVIS_VIEW_MODES 有几项,toolbar 就自动出几个按钮", () => {
expect(PREVIS_VIEW_MODES).toHaveLength(7);
});
});