Files
Genarrative/packages/model3d-viewer/src/loader.ts
T
k88936 03d5cde838 统一模型字节读取失败的报错口径
- response.arrayBuffer() 原先在 fetch 的 try/catch 之外,读到一半失败会直接抛出底层 DOMException
- 改为包一层并把失败归一成 Model3dViewerLoadError 的 load-failed,与其它加载失败路径一致
2026-09-21 15:11:30 +08:00

176 lines
4.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 type * as ThreeTypes from 'three';
import type { Model3dViewerFailureReason } from './status';
export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx';
export type Model3DViewerSource =
| { kind: 'url'; url: string; format: Model3DViewerFormat }
| { kind: 'bytes'; data: ArrayBuffer; format: Model3DViewerFormat };
const SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = [
'glb',
'gltf',
'fbx',
];
/** 单模型可渲染上限;超过后整体失败,不做半渲染。 */
export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 32 * 1024 * 1024;
export function isModel3dViewerFormatSupported(
value: unknown,
): value is Model3DViewerFormat {
return SUPPORTED_FORMATS.includes(value as Model3DViewerFormat);
}
export function isModel3dViewerOverSize(byteLength: number): boolean {
return (
Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES
);
}
export function describeModel3dViewerError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '未知错误';
}
export class Model3dViewerLoadError extends Error {
readonly reason: Model3dViewerFailureReason;
readonly byteLength?: number;
constructor(
reason: Model3dViewerFailureReason,
message: string,
byteLength?: number,
) {
super(message);
this.name = 'Model3dViewerLoadError';
this.reason = reason;
this.byteLength = byteLength;
}
}
export class Model3dViewerAbortedError extends Error {
constructor() {
super('模型加载已取消');
this.name = 'Model3dViewerAbortedError';
}
}
function assertModel3dViewerByteLength(byteLength: number) {
if (isModel3dViewerOverSize(byteLength)) {
throw new Model3dViewerLoadError(
'too-large',
`模型体积 ${byteLength} 字节,超过 ${MODEL3D_VIEWER_MAX_MODEL_BYTES} 字节上限`,
byteLength,
);
}
}
/** 取模型字节:url 走一次 fetch,bytes 直接复用调用方已读入的字节。 */
export async function readModel3dViewerSourceBytes(
source: Model3DViewerSource,
): Promise<ArrayBuffer> {
if (source.kind === 'bytes') {
assertModel3dViewerByteLength(source.data.byteLength);
return source.data;
}
let response: Response;
try {
response = await fetch(source.url);
} catch (error) {
throw new Model3dViewerLoadError(
'load-failed',
`模型地址不可读取:${describeModel3dViewerError(error)}`,
);
}
if (!response.ok) {
throw new Model3dViewerLoadError(
'load-failed',
`模型地址返回 ${response.status}`,
);
}
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength)) {
assertModel3dViewerByteLength(declaredLength);
}
let data: ArrayBuffer;
try {
data = await response.arrayBuffer();
} catch (error) {
throw new Model3dViewerLoadError(
'load-failed',
`模型数据读取失败:${describeModel3dViewerError(error)}`,
);
}
assertModel3dViewerByteLength(data.byteLength);
return data;
}
function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
if (source.kind !== 'url') {
return '';
}
const separatorIndex = source.url.lastIndexOf('/');
return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : '';
}
/**
* 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader;
* 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。
*/
export async function loadModel3dViewerObject(
source: Model3DViewerSource,
isAborted: () => boolean = () => false,
): Promise<ThreeTypes.Object3D> {
if (!isModel3dViewerFormatSupported(source.format)) {
throw new Model3dViewerLoadError(
'unsupported-format',
`不支持的模型格式:${String(source.format)}`,
);
}
const data = await readModel3dViewerSourceBytes(source);
if (isAborted()) {
throw new Model3dViewerAbortedError();
}
const basePath = resolveModel3dViewerBasePath(source);
try {
if (source.format === 'fbx') {
const { FBXLoader } = await import(
'three/examples/jsm/loaders/FBXLoader.js'
);
return new FBXLoader().parse(data, basePath);
}
const { GLTFLoader } = await import(
'three/examples/jsm/loaders/GLTFLoader.js'
);
const loader = new GLTFLoader();
const payload: string | ArrayBuffer =
source.format === 'gltf'
? new TextDecoder().decode(new Uint8Array(data))
: data;
const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(
(resolve, reject) => {
loader.parse(payload, basePath, resolve, reject);
},
);
return gltf.scene;
} catch (error) {
if (error instanceof Model3dViewerLoadError) {
throw error;
}
throw new Model3dViewerLoadError(
'load-failed',
`模型解析失败:${describeModel3dViewerError(error)}`,
);
}
}