b0054fd51c
- 新增 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 的工作区链接
212 lines
5.9 KiB
TypeScript
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 });
|
|
});
|
|
});
|