- 新增 admin/src/pages/Scenes.tsx(路由 /scenes + 侧栏「场景管理」入口):
* 列表:40px 缩略图(点击进详情)、物理摘要、体积、状态、软删除;
* 上传表单:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风基础向量、
阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光),multipart 走 POST /api/scenes;
* 详情:复用 VrmPreview(懒加载 three.js 分包)预览场景 GLB + 参数可视化编辑
并通过 PATCH /api/scenes/:id 保存;
- 新增 admin/src/lib/scene-physics.ts:DEFAULT_SCENE_CONFIG(与契约 7.1、服务端
scene-config.ts 单一真源,测试断言两端一致)与 physicsSummary();
- 场景页两栏自适应样式;types/endpoints 补齐场景五接口。
- 测试:场景用例增至 9 项;规模 19 文件 / 245 项。版本 0.7.0。
140 lines
5.5 KiB
TypeScript
140 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
||
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../admin/src/lib/scene-physics';
|
||
import { toSceneDto } from '../src/scenes/scene.serializer';
|
||
import { PublishStatus, SourceType } from '../src/common/enums';
|
||
import type { Scene } from '../src/entities/scene.entity';
|
||
|
||
/**
|
||
* 场景物理参数(T-WEB-015)。
|
||
*
|
||
* 服务端**只存参数、不跑物理**,所以这里锁两件事:
|
||
* 1. 越界/非法输入一律被收敛成可解释的值(客户端拿到就一定能用);
|
||
* 2. 缺省即 `SCENE_CONFIG_DEFAULTS`(重力 -9.8 等),且序列化结构稳定。
|
||
*/
|
||
describe('SceneConfig 归一化', () => {
|
||
it('空输入 -> 默认值(重力 -9.8、无风、跟随相机)', () => {
|
||
const config = normalizeSceneConfig(undefined);
|
||
expect(config).toEqual(SCENE_CONFIG_DEFAULTS);
|
||
expect(config.physics.gravity).toEqual({ x: 0, y: -9.8, z: 0 });
|
||
expect(config.physics.wind.gustAmplitude).toBe(0);
|
||
expect(config.camera.mode).toBe('follow');
|
||
});
|
||
|
||
it('越界数值被夹紧(重力 ±200、摩擦 0~1、频率 0.01~5、雾 0~1)', () => {
|
||
const config = normalizeSceneConfig({
|
||
physics: {
|
||
gravity: { x: 9999, y: -9999, z: 0 },
|
||
wind: { base: { x: 500, y: 0, z: 0 }, gustAmplitude: 999, gustFrequency: 99 },
|
||
ground: { friction: 5, restitution: -1, slopeLimit: 120 },
|
||
},
|
||
environment: { ambientIntensity: 99, fogDensity: 9 },
|
||
camera: { distance: 999, height: -5 },
|
||
});
|
||
expect(config.physics.gravity.x).toBe(200);
|
||
expect(config.physics.gravity.y).toBe(-200);
|
||
expect(config.physics.wind.base.x).toBe(100);
|
||
expect(config.physics.wind.gustAmplitude).toBe(100);
|
||
expect(config.physics.wind.gustFrequency).toBe(5);
|
||
expect(config.physics.ground.friction).toBe(1);
|
||
expect(config.physics.ground.restitution).toBe(0);
|
||
expect(config.physics.ground.slopeLimit).toBe(89);
|
||
expect(config.environment.ambientIntensity).toBe(4);
|
||
expect(config.environment.fogDensity).toBe(1);
|
||
expect(config.camera.distance).toBe(50);
|
||
expect(config.camera.height).toBe(0);
|
||
});
|
||
|
||
it('非法枚举回退默认;风坐标系只认 local/world', () => {
|
||
const config = normalizeSceneConfig({
|
||
physics: { colliders: 'banana', wind: { space: 'space' } },
|
||
camera: { mode: 'fly' },
|
||
environment: { ambientColor: 'red' },
|
||
});
|
||
expect(config.physics.colliders).toBe('auto');
|
||
expect(config.physics.wind.space).toBe('world');
|
||
expect(config.camera.mode).toBe('follow');
|
||
expect(config.environment.ambientColor).toBe(SCENE_CONFIG_DEFAULTS.environment.ambientColor);
|
||
|
||
const local = normalizeSceneConfig({ physics: { colliders: 'mesh', wind: { space: 'local' } } });
|
||
expect(local.physics.colliders).toBe('mesh');
|
||
expect(local.physics.wind.space).toBe('local');
|
||
});
|
||
|
||
it('出生点最多 16 个,坐标与朝向被夹紧', () => {
|
||
const many = Array.from({ length: 40 }, () => ({ x: 1e9, y: 0, z: 0, yaw: 1e9 }));
|
||
const config = normalizeSceneConfig({ spawnPoints: many });
|
||
expect(config.spawnPoints.length).toBe(16);
|
||
expect(config.spawnPoints[0].x).toBe(1000);
|
||
expect(config.spawnPoints[0].yaw).toBe(360);
|
||
});
|
||
|
||
it('合法 #RRGGBB 颜色被接受并小写化', () => {
|
||
const config = normalizeSceneConfig({ environment: { ambientColor: '#AABBCC' } });
|
||
expect(config.environment.ambientColor).toBe('#aabbcc');
|
||
});
|
||
});
|
||
|
||
describe('SceneDto 序列化', () => {
|
||
it('带出 config 与来源五元组;URL 由调用方注入', () => {
|
||
const scene = {
|
||
id: 3,
|
||
name: '黄昏广场',
|
||
glbAssetId: 11,
|
||
thumbnailAssetId: 12,
|
||
config: normalizeSceneConfig({ physics: { gravity: { x: 0, y: -3.5, z: 0 } } }),
|
||
stats: { triangles: 1234 },
|
||
fileSize: 456,
|
||
fileSha256: 'abc',
|
||
conversionNote: '直接导入(GLB,未做重编码)',
|
||
sourceType: SourceType.Other,
|
||
sourceName: 'x',
|
||
sourceUrl: 'y',
|
||
license: 'z',
|
||
author: 'a',
|
||
status: PublishStatus.Pending,
|
||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||
updatedAt: new Date('2026-01-02T00:00:00Z'),
|
||
} as unknown as Scene;
|
||
const dto = toSceneDto(scene, { glbUrl: '/static/scene/x.glb', thumbnailUrl: '/static/scene/x.png' });
|
||
expect(dto.id).toBe(3);
|
||
expect(dto.glbUrl).toBe('/static/scene/x.glb');
|
||
expect(dto.config?.physics.gravity.y).toBe(-3.5);
|
||
expect(dto.stats).toEqual({ triangles: 1234 });
|
||
expect(dto.status).toBe(PublishStatus.Pending);
|
||
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||
});
|
||
});
|
||
|
||
describe('场景列表的物理摘要(管理端)', () => {
|
||
it('展示重力与风速,并在有阵风时标注幅度/频率', () => {
|
||
const calm = physicsSummary(normalizeSceneConfig(undefined));
|
||
expect(calm).toContain('重力 -9.8');
|
||
expect(calm).toContain('风 0.0m/s');
|
||
expect(calm).not.toContain('阵风');
|
||
|
||
const windy = physicsSummary(
|
||
normalizeSceneConfig({
|
||
physics: {
|
||
wind: {
|
||
base: { x: 3, y: 0, z: 4 },
|
||
gustAmplitude: 2.5,
|
||
gustFrequency: 0.5,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
expect(windy).toContain('风 5.0m/s');
|
||
expect(windy).toContain('阵风 2.5m/s@0.5Hz');
|
||
});
|
||
|
||
it('没有 config 时显示占位符(不抛错)', () => {
|
||
expect(physicsSummary(null)).toBe('—');
|
||
});
|
||
|
||
it('管理端默认值与服务端一致(契约 7.1 单一真源)', () => {
|
||
expect(DEFAULT_SCENE_CONFIG).toEqual(SCENE_CONFIG_DEFAULTS);
|
||
});
|
||
});
|