Files
Genarrative/packages/model3d-viewer/src/loader.test.ts
T
k88936 61cfb8800d 查看器认得 ASCII FBX 的字节开头
- 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(含前导空白)的字节判定用例
2026-09-24 17:24:28 +08:00

278 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
]);
});
});