03d5cde838
- response.arrayBuffer() 原先在 fetch 的 try/catch 之外,读到一半失败会直接抛出底层 DOMException - 改为包一层并把失败归一成 Model3dViewerLoadError 的 load-failed,与其它加载失败路径一致
176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
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)}`,
|
||
);
|
||
}
|
||
}
|