feat(canvas): 3D 导演台 - 补齐非 P0 分层交互与回归保护 (#333)

* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护

* fix(canvas): 3D 导演台 - 过滤非法轨迹关键帧

* chore(canvas): 3D 导演台 - 对齐前端格式规范

* fix(canvas): 3D 导演台 - 保证故障重试入口可点击

* test(canvas): 3D 导演台 - 稳定真实点击验证
This commit is contained in:
echoD886
2026-08-27 20:47:10 +08:00
committed by GitHub
parent c76f378811
commit 0e33cc805e
34 changed files with 5049 additions and 141 deletions

View File

@@ -1,8 +1,17 @@
import { describe, expect, test } from "bun:test";
import { Euler, Vector3 } from "three";
import { applyDirectorTransformDelta, directorTransformDelta, resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "../src/lib/canvas/director/director-animation-semantics";
import { interpolateDirectorTransform } from "../src/lib/canvas/director/director-scene";
import {
advanceDirectorPlayhead,
applyDirectorTransformDelta,
directorTransformDelta,
resolveDirectorCameraAlignment,
resolveDirectorCameraMoveKeyframes,
resolveDirectorKeyframeRecord,
resolveDirectorObjectTransformEdit,
snapDirectorTime,
} from "../src/lib/canvas/director/director-animation-semantics";
import { createDirectorCamera, directorTransformPathLength, finiteDirectorTransformKeyframes, interpolateDirectorTransform } from "../src/lib/canvas/director/director-scene";
import type { DirectorKeyframe, DirectorTransform } from "../src/types/director";
function transform(position: [number, number, number], rotation: [number, number, number] = [0, 0, 0], scale: [number, number, number] = [1, 1, 1]): DirectorTransform {
@@ -21,6 +30,79 @@ describe("snapDirectorTime", () => {
});
});
describe("摄影机对齐当前视图", () => {
test("无关键帧时更新基础 transform", () => {
const camera = createDirectorCamera();
const aligned = transform([9, 3, 2]);
const next = resolveDirectorCameraAlignment(camera, aligned, 1);
expect(next.transform).toEqual(aligned);
expect(next.keyframes).toEqual([]);
});
test("已有轨迹时写当前时间关键帧,CAM 立即显示对齐结果且不丢其他帧", () => {
const camera = {
...createDirectorCamera(),
keyframes: [keyframe("start", 0, transform([0, 0, 0])), keyframe("end", 2, transform([10, 0, 0]))],
};
const aligned = transform([4, 5, 6]);
const next = resolveDirectorCameraAlignment(camera, aligned, 1);
expect(next.transform).toBe(camera.transform);
expect(next.keyframes).toHaveLength(3);
expect(interpolateDirectorTransform(next.transform, next.keyframes, 1)).toEqual(aligned);
expect(next.keyframes.find((item) => item.id === "start")).toEqual(camera.keyframes[0]);
expect(next.keyframes.find((item) => item.id === "end")).toEqual(camera.keyframes[1]);
});
});
describe("生成摄影机运镜首尾帧", () => {
test("保留手工中间帧、已有 id 与 easing,只更新首尾 transform", () => {
const existing: DirectorKeyframe[] = [{ ...keyframe("start", 0, transform([9, 0, 0])), easing: "step" }, { ...keyframe("manual", 1, transform([4, 2, 0])), easing: "smooth" }, keyframe("end", 2, transform([8, 0, 0]))];
const next = resolveDirectorCameraMoveKeyframes(existing, transform([0, 0, 0]), transform([2, 0, 0]), 2);
expect(next.map((item) => item.id)).toEqual(["start", "manual", "end"]);
expect(next[0].transform.position).toEqual([0, 0, 0]);
expect(next[1]).toEqual(existing[1]);
expect(next[2].transform.position).toEqual([2, 0, 0]);
expect(next[0].easing).toBe("step");
});
test("空轨道生成两枚有序首尾帧", () => {
const next = resolveDirectorCameraMoveKeyframes([], transform([0, 0, 0]), transform([0, 0, -2]), 3);
expect(next.map((item) => item.time)).toEqual([0, 3]);
});
});
describe("播放头循环推进", () => {
test("跨越镜头末尾时保留余量", () => {
expect(advanceDirectorPlayhead(1.9, 0.3, 2)).toBeCloseTo(0.2, 8);
});
test("非法播放头、增量和时长安全回落", () => {
expect(advanceDirectorPlayhead(Number.NaN, 0.5, 2)).toBe(0.5);
expect(advanceDirectorPlayhead(1, Number.NaN, 2)).toBe(1);
expect(advanceDirectorPlayhead(1, 1, 0)).toBe(0);
});
});
describe("Transform 轨迹统计", () => {
test("按时间顺序累计空间路径,不把输入数组顺序当成路径顺序", () => {
const keys = [keyframe("end", 2, transform([3, 4, 0])), keyframe("start", 0, transform([0, 0, 0])), keyframe("middle", 1, transform([0, 4, 0]))];
expect(directorTransformPathLength(keys)).toBe(7);
expect(keys.map((key) => key.id)).toEqual(["end", "start", "middle"]);
});
test("忽略含非法坐标的段,避免 NaN 污染轨迹显示判断", () => {
const keys = [keyframe("valid", 0, transform([0, 0, 0])), keyframe("invalid", 1, transform([Number.NaN, 2, 0])), keyframe("valid-again", 2, transform([3, 4, 0]))];
expect(directorTransformPathLength(keys)).toBe(0);
});
test("轨迹渲染过滤非法时间与坐标,不把 NaN/Infinity 传给 Three", () => {
const keys = [keyframe("start", 0, transform([0, 0, 0])), keyframe("bad-time", Number.NaN, transform([1, 0, 0])), keyframe("bad-position", 1, transform([Number.POSITIVE_INFINITY, 0, 0])), keyframe("end", 2, transform([2, 0, 0]))];
const renderable = finiteDirectorTransformKeyframes(keys);
expect(renderable.map((item) => item.id)).toEqual(["start", "end"]);
expect(renderable.flatMap((item) => [item.time, ...item.transform.position]).every(Number.isFinite)).toBe(true);
});
});
describe("resolveDirectorObjectTransformEdit:Auto Key 打开", () => {
test("只在吸附播放头写单个关键帧,base 与其他关键帧不变", () => {
const base = transform([0, 0, 0]);