Files
Genarrative/packages/model3d-viewer/src/framing.test.ts
T
k88936 b0054fd51c 新增 3D 模型查看器独立包
- 新增 packages/model3d-viewer(@genarrative/model3d-viewer),含 Model3DViewer、scene、loader、framing、status 与 index
- 组件只走 WebGL2 单一路径渲染 glb、单文件 gltf 与 fbx,未承诺格式或超过 32 MiB 时只渲染一行状态文案
- 取景按真实顶点做投影拟合,与模型尺度无关,复位回到打开时的取景
- 新增 framing 与 loader 的纯函数单测
- 根 package.json 的 workspaces 增加 packages/model3d-viewer
- scripts/check-npm-workspaces.mjs 与 scripts/check-npm-workspaces.test.mjs 同步工作区清单
- vitest.config.ts 增加新包测试匹配
- 同步 package-lock.json 的工作区链接
2026-09-19 16:13:55 +08:00

212 lines
5.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
computeModel3dViewerBoxCenter,
computeModel3dViewerBoxRadius,
computeModel3dViewerFitDistance,
computeModel3dViewerFraming,
createModel3dViewerFitSolver,
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
MODEL3D_VIEWER_FIT_FRACTION,
type Model3dViewerBox,
} from './framing';
type Point = [number, number, number];
function box(min: Point, max: Point): Model3dViewerBox {
return {
min: { x: min[0], y: min[1], z: min[2] },
max: { x: max[0], y: max[1], z: max[2] },
};
}
function scaleBox(source: Model3dViewerBox, factor: number): Model3dViewerBox {
return {
min: {
x: source.min.x * factor,
y: source.min.y * factor,
z: source.min.z * factor,
},
max: {
x: source.max.x * factor,
y: source.max.y * factor,
z: source.max.z * factor,
},
};
}
function solveForPoints(
source: Model3dViewerBox,
points: Point[],
aspect: number,
) {
const solver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(source),
aspect,
fallbackRadius: computeModel3dViewerBoxRadius(source),
});
for (const [x, y, z] of points) {
solver.add(x, y, z);
}
return solver.resolve();
}
/** 旧口径的包围球拟合距离,作为「不该退回到这么远」的对照基线。 */
function sphereBaselineDistance(source: Model3dViewerBox): number {
const halfFovY = (MODEL3D_VIEWER_CAMERA_FOV_DEGREES * Math.PI) / 360;
return (
computeModel3dViewerBoxRadius(source) /
(MODEL3D_VIEWER_FIT_FRACTION * Math.tan(halfFovY))
);
}
const CUBE = box([-1, -1, -1], [1, 1, 1]);
const WINGS = box([-4, -0.4, -0.4], [4, 0.4, 0.4]);
const CUBE_CORNERS: Point[] = [
[-1, -1, -1],
[-1, -1, 1],
[-1, 1, -1],
[-1, 1, 1],
[1, -1, -1],
[1, -1, 1],
[1, 1, -1],
[1, 1, 1],
];
describe('computeModel3dViewerFitDistance', () => {
it('与模型尺度成正比,任意尺度都占同样的视野比例', () => {
const small = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1.5 });
const large = computeModel3dViewerFitDistance({
box: scaleBox(CUBE, 10000),
aspect: 1.5,
});
expect(small).toBeGreaterThan(0);
expect(large / small).toBeCloseTo(10000, 3);
});
it('按包围盒投影取景,明显近于按包围球取景', () => {
for (const source of [CUBE, WINGS]) {
const boxFit = computeModel3dViewerFitDistance({
box: source,
aspect: 2,
});
expect(boxFit).toBeLessThan(sphereBaselineDistance(source) * 0.9);
}
});
it('竖屏时相机更远,避免横向出框', () => {
const landscape = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
const portrait = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 0.5,
});
expect(portrait).toBeGreaterThan(landscape);
});
it('退化或非法包围盒回落到可用的默认取景', () => {
const degenerate = [
box([0, 0, 0], [0, 0, 0]),
box([Number.NaN, 0, 0], [1, 1, 1]),
box([Number.POSITIVE_INFINITY, 0, 0], [Number.NEGATIVE_INFINITY, 1, 1]),
];
for (const candidate of degenerate) {
const distance = computeModel3dViewerFitDistance({
box: candidate,
aspect: 1,
});
expect(Number.isFinite(distance)).toBe(true);
expect(distance).toBeGreaterThan(0);
}
});
it('fitFraction 越小相机越远', () => {
const near = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1 });
const far = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 1,
fitFraction: 0.31,
});
expect(far).toBeGreaterThan(near);
});
it('传入已算好的距离时直接采用,不再按角点重算', () => {
const distance = computeModel3dViewerFitDistance({
box: CUBE,
aspect: 1,
distance: 7,
});
expect(distance).toBe(7);
});
});
describe('createModel3dViewerFitSolver', () => {
it('喂包围盒角点时与包围盒口径一致', () => {
const expected = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
expect(solveForPoints(CUBE, CUBE_CORNERS, 2)).toBeCloseTo(expected, 6);
});
it('喂真实顶点时避开包围盒空角,取景更近', () => {
// 斜向细杆:包围盒是 -1..1 的立方体,但 6 个角点都是空的。
const rod: Point[] = [
[-1, -1, -1],
[1, 1, 1],
];
const boxFit = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 });
const vertexFit = solveForPoints(CUBE, rod, 2);
expect(vertexFit).toBeLessThan(boxFit * 0.9);
});
it('没有任何顶点时回落到与尺度相关的可用距离', () => {
const fit = solveForPoints(CUBE, [], 1);
expect(Number.isFinite(fit)).toBe(true);
expect(fit).toBeGreaterThan(0);
});
});
describe('computeModel3dViewerFraming', () => {
it('推导出合法的裁剪面与缩放范围', () => {
const framing = computeModel3dViewerFraming({
box: box([-1, -1, -1], [3, 5, 7]),
aspect: 1.6,
});
expect(framing.center).toEqual({ x: 1, y: 2, z: 3 });
expect(framing.near).toBeGreaterThan(0);
expect(framing.near).toBeLessThan(framing.far);
expect(framing.minDistance).toBeLessThan(framing.distance);
expect(framing.maxDistance).toBeGreaterThan(framing.distance);
});
it('复位方向是单位向量且带正向仰角', () => {
const { resetDirection } = computeModel3dViewerFraming({
box: CUBE,
aspect: 1,
});
expect(
Math.hypot(resetDirection.x, resetDirection.y, resetDirection.z),
).toBeCloseTo(1, 6);
expect(resetDirection.y).toBeGreaterThan(0);
expect(resetDirection.z).toBeGreaterThan(0);
});
});
describe('computeModel3dViewerBoxCenter', () => {
it('非法包围盒回落到原点,避免相机拿到 NaN', () => {
expect(
computeModel3dViewerBoxCenter({
min: { x: Number.POSITIVE_INFINITY, y: 0, z: 0 },
max: { x: Number.NEGATIVE_INFINITY, y: 0, z: 0 },
}),
).toEqual({ x: 0, y: 0, z: 0 });
});
});