f4083dcb6d
- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址 - glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager - 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
265 lines
8.5 KiB
TypeScript
265 lines
8.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
isModel3dViewerFormatSupported,
|
|
isModel3dViewerOverSize,
|
|
loadModel3dViewerObject,
|
|
MODEL3D_VIEWER_MAX_MODEL_BYTES,
|
|
Model3dViewerAbortedError,
|
|
readModel3dViewerSource,
|
|
resolveModel3dViewerFormat,
|
|
resolveModel3dViewerFormatFromBytes,
|
|
resolveModel3dViewerFormatFromMimeType,
|
|
resolveModel3dViewerResourceUrl,
|
|
resolveModel3dViewerSourceQuery,
|
|
} from './loader';
|
|
|
|
describe('模型格式承诺范围', () => {
|
|
it('只承诺 glb / gltf / fbx', () => {
|
|
for (const format of ['glb', 'gltf', 'fbx']) {
|
|
expect(isModel3dViewerFormatSupported(format)).toBe(true);
|
|
}
|
|
for (const format of [
|
|
'obj',
|
|
'stl',
|
|
'usdz',
|
|
'3mf',
|
|
'',
|
|
null,
|
|
undefined,
|
|
7,
|
|
]) {
|
|
expect(isModel3dViewerFormatSupported(format)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('模型体积上限', () => {
|
|
it('超过 64 MiB 判定为超限', () => {
|
|
expect(isModel3dViewerOverSize(0)).toBe(false);
|
|
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false);
|
|
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('非有限值不算超限,由加载流程自行失败', () => {
|
|
expect(isModel3dViewerOverSize(Number.NaN)).toBe(false);
|
|
});
|
|
});
|
|
|
|
const GLB_MAGIC = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00]);
|
|
const FBX_MAGIC = new Uint8Array([
|
|
0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42,
|
|
0x69, 0x6e, 0x61, 0x72, 0x79, 0x00,
|
|
]);
|
|
|
|
describe('模型格式判定', () => {
|
|
it('声明类型按子串匹配各家拼法', () => {
|
|
expect(resolveModel3dViewerFormatFromMimeType('model/gltf-binary')).toBe(
|
|
'glb',
|
|
);
|
|
expect(resolveModel3dViewerFormatFromMimeType('model/gltf+json')).toBe(
|
|
'gltf',
|
|
);
|
|
expect(
|
|
resolveModel3dViewerFormatFromMimeType('application/x-fbx; charset=x'),
|
|
).toBe('fbx');
|
|
expect(
|
|
resolveModel3dViewerFormatFromMimeType('application/octet-stream'),
|
|
).toBeNull();
|
|
expect(resolveModel3dViewerFormatFromMimeType(null)).toBeNull();
|
|
});
|
|
|
|
it('字节魔数认出自包含的三种容器', () => {
|
|
expect(resolveModel3dViewerFormatFromBytes(GLB_MAGIC)).toBe('glb');
|
|
expect(resolveModel3dViewerFormatFromBytes(FBX_MAGIC)).toBe('fbx');
|
|
expect(
|
|
resolveModel3dViewerFormatFromBytes(
|
|
new TextEncoder().encode('\n {"asset":{"version":"2.0"}}'),
|
|
),
|
|
).toBe('gltf');
|
|
expect(
|
|
resolveModel3dViewerFormatFromBytes(
|
|
new TextEncoder().encode('\uFEFF{"asset":{}}'),
|
|
),
|
|
).toBe('gltf');
|
|
expect(
|
|
resolveModel3dViewerFormatFromBytes(new Uint8Array([0x00, 0x01])),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('顺序是声明 → 魔数 → 地址扩展名', () => {
|
|
// 声明正确时轮不到魔数。
|
|
expect(
|
|
resolveModel3dViewerFormat({
|
|
declaredType: 'model/gltf-binary',
|
|
url: 'https://x/model.fbx',
|
|
bytes: FBX_MAGIC,
|
|
}),
|
|
).toBe('glb');
|
|
// 声明写歪(provider 给 octet-stream、扩展名也不对)时按魔数纠正。
|
|
expect(
|
|
resolveModel3dViewerFormat({
|
|
declaredType: 'application/octet-stream',
|
|
url: 'https://x/model.obj',
|
|
bytes: GLB_MAGIC,
|
|
}),
|
|
).toBe('glb');
|
|
// 没声明也没字节时只剩扩展名这条派生线索。
|
|
expect(
|
|
resolveModel3dViewerFormat({ url: 'https://x/model.glb?sig=1' }),
|
|
).toBe('glb');
|
|
expect(resolveModel3dViewerFormat({ url: 'https://x/model.FBX' })).toBe(
|
|
'fbx',
|
|
);
|
|
});
|
|
|
|
it('三条线索都给不出答案时返回 null,交给加载流程报 unsupported-format', () => {
|
|
expect(
|
|
resolveModel3dViewerFormat({
|
|
declaredType: 'application/octet-stream',
|
|
url: 'https://x/model.obj',
|
|
bytes: new Uint8Array([0x00, 0x01]),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('模型下载可中止', () => {
|
|
it('把宿主的 signal 交给 fetch,中止后抛取消错误而不是加载失败', async () => {
|
|
const controller = new AbortController();
|
|
const originalFetch = globalThis.fetch;
|
|
const calls: Array<{ url: string; signal?: AbortSignal | null }> = [];
|
|
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
calls.push({ url: String(input), signal: init?.signal });
|
|
controller.abort();
|
|
return Promise.reject(new DOMException('aborted', 'AbortError'));
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
await expect(
|
|
readModel3dViewerSource(
|
|
{ kind: 'url', url: 'https://x/model.glb' },
|
|
controller.signal,
|
|
),
|
|
).rejects.toBeInstanceOf(Model3dViewerAbortedError);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
expect(calls).toEqual([
|
|
{ url: 'https://x/model.glb', signal: controller.signal },
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 签名模型地址的兄弟资源补鉴权。
|
|
*
|
|
* 宿主给的是私有对象的签名地址,token 在 query 里;`GLTFLoader` / `FBXLoader` 用
|
|
* `path + uri` 拼多文件模型的兄弟 buffer 与贴图,那一层只拼目录、不带 query。
|
|
*/
|
|
describe('签名模型地址的兄弟资源补鉴权', () => {
|
|
it('从模型地址的 query 里取出鉴权参数,丢掉片段', () => {
|
|
expect(
|
|
resolveModel3dViewerSourceQuery(
|
|
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.glb?token=abc&expires=1',
|
|
),
|
|
).toBe('?token=abc&expires=1');
|
|
expect(
|
|
resolveModel3dViewerSourceQuery(
|
|
'https://bucket.oss-cn-hangzhou.aliyuncs.com/m.glb?token=abc#frag',
|
|
),
|
|
).toBe('?token=abc');
|
|
expect(resolveModel3dViewerSourceQuery('https://x/m.glb?')).toBeNull();
|
|
expect(resolveModel3dViewerSourceQuery('https://x/m.glb')).toBeNull();
|
|
expect(resolveModel3dViewerSourceQuery(null)).toBeNull();
|
|
});
|
|
|
|
it('只补模型目录下的资源地址,并保留资源自己的 query', () => {
|
|
const basePath =
|
|
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/';
|
|
const query = '?token=abc';
|
|
expect(
|
|
resolveModel3dViewerResourceUrl({
|
|
url: `${basePath}buffer.bin`,
|
|
basePath,
|
|
query,
|
|
}),
|
|
).toBe(`${basePath}buffer.bin?token=abc`);
|
|
expect(
|
|
resolveModel3dViewerResourceUrl({
|
|
url: `${basePath}textures/tex.png`,
|
|
basePath,
|
|
query,
|
|
}),
|
|
).toBe(`${basePath}textures/tex.png?token=abc`);
|
|
expect(
|
|
resolveModel3dViewerResourceUrl({
|
|
url: `${basePath}buffer.bin?v=2`,
|
|
basePath,
|
|
query,
|
|
}),
|
|
).toBe(`${basePath}buffer.bin?v=2&token=abc`);
|
|
});
|
|
|
|
it('第三方 CDN、同源其它对象、无目录与空地址都原样返回', () => {
|
|
const basePath = 'https://bucket/editor/model3d/t-1/';
|
|
const query = '?token=abc';
|
|
for (const input of [
|
|
{ url: 'https://cdn.example.com/tex.png', basePath, query },
|
|
{ url: 'https://bucket/other/tex.png', basePath, query },
|
|
{
|
|
url: 'https://bucket/editor/model3d/t-1/buffer.bin',
|
|
basePath: '',
|
|
query,
|
|
},
|
|
{ url: '', basePath, query },
|
|
]) {
|
|
expect(resolveModel3dViewerResourceUrl(input)).toBe(input.url);
|
|
}
|
|
});
|
|
|
|
it('多文件 glTF 加载时把 query 补到兄弟资源地址上', async () => {
|
|
const modelUrl =
|
|
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.gltf?token=abc';
|
|
const resolvedUrls: string[] = [];
|
|
const originalFetch = globalThis.fetch;
|
|
// 动态 import 的替身只记录一次兄弟资源解析,不真的解析 glTF。
|
|
vi.doMock('three/examples/jsm/loaders/GLTFLoader.js', () => ({
|
|
GLTFLoader: class {
|
|
private readonly manager: { resolveURL: (url: string) => string };
|
|
|
|
constructor(manager: { resolveURL: (url: string) => string }) {
|
|
this.manager = manager;
|
|
}
|
|
|
|
parse(
|
|
_payload: unknown,
|
|
path: string,
|
|
onLoad: (gltf: { scene: object }) => void,
|
|
) {
|
|
resolvedUrls.push(this.manager.resolveURL(`${path}buffer.bin`));
|
|
onLoad({ scene: {} });
|
|
}
|
|
},
|
|
}));
|
|
globalThis.fetch = (async () =>
|
|
new Response(new TextEncoder().encode('{"asset":{"version":"2.0"}}'), {
|
|
headers: { 'content-type': 'model/gltf+json' },
|
|
})) as typeof fetch;
|
|
|
|
try {
|
|
await loadModel3dViewerObject({ kind: 'url', url: modelUrl });
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
vi.doUnmock('three/examples/jsm/loaders/GLTFLoader.js');
|
|
}
|
|
|
|
expect(resolvedUrls).toEqual([
|
|
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/buffer.bin?token=abc',
|
|
]);
|
|
});
|
|
});
|