348 lines
18 KiB
TypeScript
348 lines
18 KiB
TypeScript
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);
|
||
});
|
||
});
|