Files
ttstd-infinite-canvas/web/test/director-placement.test.ts
echoD886 ad0775605d fix(canvas): 3D 导演台 - 完成 Issue #305 P0 稳定性治理 (#324)
* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期

* style(canvas): 3D 导演台 - 对齐 CI 格式门禁
2026-08-27 10:32:45 +08:00

348 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
DIRECTOR_PLACEMENT_MARGIN,
directorObjectFootprint,
emptyDirectorPlacementIntent,
finiteDirectorGroundPoint,
resolveDirectorPlacement,
resolveDirectorPlacementAnchor,
type DirectorGroundPoint,
} from "../src/lib/canvas/director/director-placement";
import { createDirectorActor, createDirectorBillboard, createDirectorModel, createDirectorObject } from "../src/lib/canvas/director/director-scene";
import type { DirectorObject, DirectorVec3 } from "../src/types/director";
function place(object: DirectorObject, existing: DirectorObject[]) {
const position = resolveDirectorPlacement({ object, existing });
return { ...object, transform: { ...object.transform, position } };
}
/** XZ 占位是否相交(含 margin);Y 完全不参与。 */
function overlapsXZ(a: DirectorObject, b: DirectorObject) {
const fa = directorObjectFootprint(a);
const fb = directorObjectFootprint(b);
const dx = Math.abs(a.transform.position[0] - b.transform.position[0]);
const dz = Math.abs(a.transform.position[2] - b.transform.position[2]);
return dx < (fa.width + fb.width) / 2 + DIRECTOR_PLACEMENT_MARGIN && dz < (fa.depth + fb.depth) / 2 + DIRECTOR_PLACEMENT_MARGIN;
}
function scaled(object: DirectorObject, scale: DirectorVec3) {
return { ...object, transform: { ...object.transform, scale } };
}
describe("连续新增不重叠(B 回归)", () => {
test("默认场景演员之外连续加 6 个立方体,两两不相交", () => {
const placed: DirectorObject[] = [createDirectorActor("演员 1", [0, 0, 0])];
for (let index = 0; index < 6; index += 1) placed.push(place(createDirectorObject("box", `立方体 ${index}`), placed));
placed.forEach((a, i) => placed.slice(i + 1).forEach((b) => expect(overlapsXZ(a, b)).toBe(false)));
});
test("混合 kind 连续新增:actor / model / billboard / primitive 互不相交", () => {
const placed: DirectorObject[] = [];
placed.push(place(createDirectorActor("演员 1", [0, 0, 0]), placed));
placed.push(place(createDirectorModel({ name: "模型", storageKey: undefined, url: "u", mimeType: undefined, assetId: undefined }), placed));
placed.push(place(createDirectorBillboard("立牌", "u"), placed));
placed.push(place(createDirectorObject("sphere", "球体"), placed));
placed.push(place(createDirectorActor("演员 2", [0, 0, 0]), placed));
placed.forEach((a, i) => placed.slice(i + 1).forEach((b) => expect(overlapsXZ(a, b)).toBe(false)));
});
test("默认 actor 与默认 model 都在原点时不再重叠", () => {
const actor = createDirectorActor("演员 1", [0, 0, 0]);
const model = place(createDirectorModel({ name: "模型", storageKey: undefined, url: "u", mimeType: undefined, assetId: undefined }), [actor]);
expect(overlapsXZ(actor, model)).toBe(false);
});
test("隐藏对象同样占位", () => {
const hidden = { ...createDirectorObject("box", "隐藏"), visible: false };
const next = place(createDirectorObject("box", "新的"), [hidden]);
expect(overlapsXZ(hidden, next)).toBe(false);
});
});
describe("占位尺寸与缩放", () => {
test("footprint 随 scale.x/z 放大,且不使用 scale.y", () => {
const base = directorObjectFootprint(createDirectorObject("box", "b"));
const wide = directorObjectFootprint(scaled(createDirectorObject("box", "b"), [3, 9, 2]));
expect(wide.width).toBeCloseTo(base.width * 3, 6);
expect(wide.depth).toBeCloseTo(base.depth * 2, 6);
});
test("负缩放按绝对值占位,不产生负尺寸", () => {
const mirrored = directorObjectFootprint(scaled(createDirectorObject("box", "b"), [-3, 1, -2]));
expect(mirrored.width).toBeCloseTo(3, 6);
expect(mirrored.depth).toBeCloseTo(2, 6);
});
test("大缩放对象周围留出更大空隙", () => {
const big = scaled(createDirectorObject("box", "大"), [6, 1, 6]);
const next = place(createDirectorObject("box", "小"), [big]);
expect(overlapsXZ(big, next)).toBe(false);
expect(Math.hypot(next.transform.position[0], next.transform.position[2])).toBeGreaterThan(3);
});
test("billboard 用宽而浅的占位", () => {
const footprint = directorObjectFootprint(createDirectorBillboard("立牌", "u"));
expect(footprint.width).toBeGreaterThan(footprint.depth);
});
});
describe("只改 XZ、确定性与拥挤回退", () => {
test("保留调用方给定的 Y", () => {
expect(place(createDirectorObject("box", "b"), [createDirectorObject("box", "a")]).transform.position[1]).toBeCloseTo(0.5, 6);
expect(place(createDirectorActor("演员", [0, 0, 0]), [createDirectorActor("已有", [0, 0, 0])]).transform.position[1]).toBeCloseTo(0, 6);
expect(place(createDirectorBillboard("立牌", "u"), [createDirectorBillboard("已有", "u")]).transform.position[1]).toBeCloseTo(1.1, 6);
});
test("同样输入得到同样输出(确定性)", () => {
const existing = [createDirectorObject("box", "a"), createDirectorActor("演员", [2, 0, 0])];
const first = resolveDirectorPlacement({ object: createDirectorObject("box", "新"), existing });
const second = resolveDirectorPlacement({ object: createDirectorObject("box", "新"), existing });
expect(first).toEqual(second);
});
test("空场景保留原始期望位置", () => {
expect(resolveDirectorPlacement({ object: createDirectorObject("box", "b"), existing: [] })).toEqual([0, 0.5, 0]);
});
test("极端拥挤时回退位置必须与所有对象都不相交", () => {
const crowd: DirectorObject[] = [];
for (let x = -20; x <= 20; x += 1) {
for (let z = -20; z <= 20; z += 1) {
crowd.push({ ...createDirectorObject("box", `c-${x}-${z}`), transform: { position: [x, 0.5, z], rotation: [0, 0, 0], scale: [1, 1, 1] } });
}
}
const placed = place(createDirectorObject("box", "新"), crowd);
placed.transform.position.forEach((value) => expect(Number.isFinite(value)).toBe(true));
expect(placed.transform.position[1]).toBeCloseTo(0.5, 6);
// 核心性质:兜底不是「随便给一点」,必须真的不与任何既有占位相交。
crowd.forEach((item) => expect(overlapsXZ(placed, item)).toBe(false));
});
test("超大 scale 超出 ring 搜索半径时兜底仍不相交", () => {
// ring 最大半径 24*0.75 = 18;用远大于它的占位强制走兜底分支。
const huge = scaled(createDirectorObject("box", "巨大"), [80, 1, 80]);
const placed = place(createDirectorObject("box", "新"), [huge]);
expect(overlapsXZ(placed, huge)).toBe(false);
expect(placed.transform.position[1]).toBeCloseTo(0.5, 6);
});
test("多个超大占位混合时兜底位于全部占位之外", () => {
const first = { ...scaled(createDirectorObject("box", "大 1"), [60, 1, 60]), transform: { position: [0, 0.5, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [60, 1, 60] as DirectorVec3 } };
const second = { ...scaled(createDirectorObject("box", "大 2"), [40, 1, 40]), transform: { position: [70, 0.5, 10] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [40, 1, 40] as DirectorVec3 } };
const placed = place(createDirectorObject("box", "新"), [first, second]);
[first, second].forEach((item) => expect(overlapsXZ(placed, item)).toBe(false));
});
test("非法输入不产生 NaN", () => {
const broken = { ...createDirectorObject("box", "坏"), transform: { position: [Number.NaN, Number.NaN, Number.NaN] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [Number.NaN, 1, 1] as DirectorVec3 } };
resolveDirectorPlacement({ object: broken, existing: [createDirectorObject("box", "a")] }).forEach((value) => expect(Number.isFinite(value)).toBe(true));
});
});
describe("地面锚点归一化", () => {
test("有限数值成为合法地面点", () => {
expect(finiteDirectorGroundPoint(1.5, -2.25)).toEqual({ x: 1.5, z: -2.25 });
expect(finiteDirectorGroundPoint(0, 0)).toEqual({ x: 0, z: 0 });
});
test("NaN / ±Infinity / 非数值一律不可用", () => {
expect(finiteDirectorGroundPoint(Number.NaN, 0)).toBeNull();
expect(finiteDirectorGroundPoint(0, Number.NaN)).toBeNull();
expect(finiteDirectorGroundPoint(Number.POSITIVE_INFINITY, 0)).toBeNull();
expect(finiteDirectorGroundPoint(0, Number.NEGATIVE_INFINITY)).toBeNull();
expect(finiteDirectorGroundPoint(undefined, 0)).toBeNull();
expect(finiteDirectorGroundPoint("3", 0)).toBeNull();
expect(finiteDirectorGroundPoint(null, null)).toBeNull();
});
});
describe("放置来源优先级", () => {
test("pointer 存在时优先于 orbit target", () => {
const anchor = resolveDirectorPlacementAnchor({
intent: { pointer: { x: 3, z: -4 }, orbitTarget: { x: 9, z: 9 } },
fallback: [0, 0.5, 0],
});
expect(anchor).toEqual([3, 0.5, -4]);
});
test("pointer 缺失时回退 orbit target", () => {
const anchor = resolveDirectorPlacementAnchor({
intent: { pointer: null, orbitTarget: { x: -2.5, z: 6 } },
fallback: [0, 0.5, 0],
});
expect(anchor).toEqual([-2.5, 0.5, 6]);
});
test("两者都不可用时保留对象原始 XZ", () => {
expect(resolveDirectorPlacementAnchor({ intent: emptyDirectorPlacementIntent, fallback: [7, 0.5, -8] })).toEqual([7, 0.5, -8]);
expect(resolveDirectorPlacementAnchor({ intent: null, fallback: [7, 0.5, -8] })).toEqual([7, 0.5, -8]);
});
test("上下文不可用返回 null intent 时不抛异常且退回默认原点", () => {
expect(resolveDirectorPlacementAnchor({ intent: null, fallback: [0, 0, 0] })).toEqual([0, 0, 0]);
});
test("fallback 自身 XZ 非有限时归零,Y 仍保留", () => {
expect(resolveDirectorPlacementAnchor({ intent: null, fallback: [Number.NaN, 1.1, Number.NaN] })).toEqual([0, 1.1, 0]);
});
test("非法 pointer 不得遮蔽合法 orbit target", () => {
const cases: DirectorGroundPoint[] = [
{ x: Number.NaN, z: 0 },
{ x: 0, z: Number.NaN },
{ x: Number.POSITIVE_INFINITY, z: 0 },
{ x: 0, z: Number.NEGATIVE_INFINITY },
{ x: Number.NaN, z: Number.NaN },
];
for (const pointer of cases) {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer, orbitTarget: { x: -2.5, z: 6 } },
fallback: [0, 0.5, 0],
}),
).toEqual([-2.5, 0.5, 6]);
}
});
test("非法 pointer + 非法 orbit 时回退到合法 fallback XZ", () => {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer: { x: Number.NaN, z: Number.NaN }, orbitTarget: { x: Number.POSITIVE_INFINITY, z: 3 } },
fallback: [7, 0.5, -8],
}),
).toEqual([7, 0.5, -8]);
});
test("三个来源全非法时归零且仍保留构造器 Y", () => {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer: { x: Number.NaN, z: 0 }, orbitTarget: { x: Number.NaN, z: 0 } },
fallback: [Number.NaN, 1.1, Number.POSITIVE_INFINITY],
}),
).toEqual([0, 1.1, 0]);
});
test("合法 pointer 含 0 不被误判为缺失", () => {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer: { x: 0, z: 0 }, orbitTarget: { x: 9, z: 9 } },
fallback: [7, 0.5, -8],
}),
).toEqual([0, 0.5, 0]);
});
test("非法 orbit 不影响合法 pointer", () => {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer: { x: 3, z: -4 }, orbitTarget: { x: Number.NaN, z: Number.NaN } },
fallback: [0, 0.5, 0],
}),
).toEqual([3, 0.5, -4]);
});
test("非有限 fallback Y 归零,XZ 来源仍生效", () => {
expect(
resolveDirectorPlacementAnchor({
intent: { pointer: { x: 2, z: 2 }, orbitTarget: null },
fallback: [0, Number.NaN, 0],
}),
).toEqual([2, 0, 2]);
});
});
describe("锚点不改变构造器 Y", () => {
const pointer = { pointer: { x: 4, z: 5 }, orbitTarget: null };
test("primitive 保持 0.5", () => {
const box = createDirectorObject("box", "立方体");
expect(box.transform.position[1]).toBe(0.5);
expect(resolveDirectorPlacementAnchor({ intent: pointer, fallback: box.transform.position })[1]).toBe(0.5);
});
test("actor 保持 0", () => {
const actor = createDirectorActor("演员");
expect(resolveDirectorPlacementAnchor({ intent: pointer, fallback: actor.transform.position })[1]).toBe(0);
});
test("model 保持 0", () => {
const model = createDirectorModel({ name: "模型", assetId: "a", storageKey: "k", url: "u", mimeType: "model/gltf-binary" });
expect(resolveDirectorPlacementAnchor({ intent: pointer, fallback: model.transform.position })[1]).toBe(0);
});
test("billboard 保持 1.1", () => {
const billboard = createDirectorBillboard("立牌", "https://example.invalid/a.png");
expect(billboard.transform.position[1]).toBe(1.1);
expect(resolveDirectorPlacementAnchor({ intent: pointer, fallback: billboard.transform.position })[1]).toBe(1.1);
});
test("四种 kind 的锚点 XZ 都来自 pointer", () => {
const objects = [
createDirectorObject("box", "立方体"),
createDirectorActor("演员"),
createDirectorModel({ name: "模型", assetId: "a", storageKey: "k", url: "u", mimeType: "model/gltf-binary" }),
createDirectorBillboard("立牌", "https://example.invalid/a.png"),
];
for (const object of objects) {
const anchor = resolveDirectorPlacementAnchor({ intent: pointer, fallback: object.transform.position });
expect([anchor[0], anchor[2]]).toEqual([4, 5]);
}
});
});
describe("锚点与碰撞避让组合", () => {
test("锚点空闲时精确落在锚点 XZ", () => {
const box = createDirectorObject("box", "立方体");
const anchored = { ...box, transform: { ...box.transform, position: resolveDirectorPlacementAnchor({ intent: { pointer: { x: 6, z: -6 }, orbitTarget: null }, fallback: box.transform.position }) } };
expect(resolveDirectorPlacement({ object: anchored, existing: [] })).toEqual([6, 0.5, -6]);
});
test("锚点被占用时从锚点向外避让,且仍保持 Y", () => {
const occupant = createDirectorObject("box", "占位", [6, 0.5, -6]);
const box = createDirectorObject("box", "立方体");
const anchored = { ...box, transform: { ...box.transform, position: resolveDirectorPlacementAnchor({ intent: { pointer: { x: 6, z: -6 }, orbitTarget: null }, fallback: box.transform.position }) } };
const placed = resolveDirectorPlacement({ object: anchored, existing: [occupant] });
expect(placed[1]).toBe(0.5);
expect(placed).not.toEqual([6, 0.5, -6]);
expect(overlapsXZ({ ...anchored, transform: { ...anchored.transform, position: placed } }, occupant)).toBe(false);
// 避让必须发生在锚点附近,而不是被拉回世界原点。
expect(Math.hypot(placed[0] - 6, placed[2] + 6)).toBeLessThan(3);
});
test("避让结果对同一输入确定", () => {
const occupant = createDirectorObject("box", "占位", [6, 0.5, -6]);
const box = createDirectorObject("box", "立方体");
const anchored = { ...box, transform: { ...box.transform, position: resolveDirectorPlacementAnchor({ intent: { pointer: { x: 6, z: -6 }, orbitTarget: null }, fallback: box.transform.position }) } };
const first = resolveDirectorPlacement({ object: anchored, existing: [occupant] });
const second = resolveDirectorPlacement({ object: anchored, existing: [occupant] });
expect(first).toEqual(second);
});
test("锚点周围极端拥挤时兜底仍不相交且不改 Y", () => {
const crowd: DirectorObject[] = [];
for (let x = -10; x <= 10; x += 1) {
for (let z = -10; z <= 10; z += 1) {
crowd.push(createDirectorObject("box", `占位 ${x}:${z}`, [6 + x, 0.5, -6 + z]));
}
}
const box = createDirectorObject("box", "立方体");
const anchored = { ...box, transform: { ...box.transform, position: resolveDirectorPlacementAnchor({ intent: { pointer: { x: 6, z: -6 }, orbitTarget: null }, fallback: box.transform.position }) } };
const placed = resolveDirectorPlacement({ object: anchored, existing: crowd });
const settled = { ...anchored, transform: { ...anchored.transform, position: placed } };
expect(placed[1]).toBe(0.5);
expect(crowd.some((item) => overlapsXZ(settled, item))).toBe(false);
});
test("orbit 回退来源同样参与避让", () => {
const occupant = createDirectorObject("box", "占位", [-3, 0.5, 2]);
const box = createDirectorObject("box", "立方体");
const anchored = { ...box, transform: { ...box.transform, position: resolveDirectorPlacementAnchor({ intent: { pointer: null, orbitTarget: { x: -3, z: 2 } }, fallback: box.transform.position }) } };
const placed = resolveDirectorPlacement({ object: anchored, existing: [occupant] });
expect(placed[1]).toBe(0.5);
expect(overlapsXZ({ ...anchored, transform: { ...anchored.transform, position: placed } }, occupant)).toBe(false);
expect(Math.hypot(placed[0] + 3, placed[2] - 2)).toBeLessThan(3);
});
});