61cfb8800d
- packages/model3d-viewer/src/loader.ts:字节魔数多认一种 ASCII FBX 开头(`; FBX`,Blender 等工具的默认导出),此前这批文件在没显式给 format 时会被判 unsupported-format,尽管 FBXLoader 能解析 - packages/model3d-viewer/src/loader.ts:BOM / 前导空白的跳过抽成 resolveModel3dViewerContentStart,JSON 与 ASCII FBX 两处判据共用 - packages/model3d-viewer/src/loader.test.ts:补 ASCII FBX(含前导空白)的字节判定用例
278 lines
8.9 KiB
TypeScript
278 lines
8.9 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');
|
||
// ASCII FBX(Blender 默认导出)没有 Kaydara 魔数,按开头这行认。
|
||
expect(
|
||
resolveModel3dViewerFormatFromBytes(
|
||
new TextEncoder().encode(
|
||
'; FBX 7.4.0 project file\nFBXHeaderExtension: {',
|
||
),
|
||
),
|
||
).toBe('fbx');
|
||
expect(
|
||
resolveModel3dViewerFormatFromBytes(
|
||
new TextEncoder().encode('\n ; FBX 7.3.0 project file'),
|
||
),
|
||
).toBe('fbx');
|
||
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',
|
||
]);
|
||
});
|
||
});
|