308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import * as THREE from 'three';
|
|
import { publicAssetUrl } from '../runtimeBase';
|
|
import { getLoaders } from './loaders';
|
|
|
|
/**
|
|
* Multi-format model loader dispatch.
|
|
*
|
|
* GLB/GLTF go through the shared singleton loaders (loaders.ts — Draco + KTX2).
|
|
* OBJ/FBX/Collada use three's example loaders, lazily imported as separate Vite
|
|
* chunks so they never bloat the initial bundle. IFC uses web-ifc (single-thread
|
|
* wasm under /web-ifc/, no COOP/COEP headers required).
|
|
*
|
|
* Every branch resolves to a plain THREE.Object3D ready to scene.add — the
|
|
* caller (ThreeDViewer.onLoaded) does not need to know the source format.
|
|
*/
|
|
export type SupportedExt = 'glb' | 'gltf' | 'obj' | 'fbx' | 'dae' | 'ifc' | 'ply';
|
|
|
|
export const ACCEPT_EXT: SupportedExt[] = ['glb', 'gltf', 'obj', 'fbx', 'dae', 'ifc', 'ply'];
|
|
|
|
const EXT_RE = /\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i;
|
|
|
|
/** Extract the supported extension from a file name / URL, or null. */
|
|
export function extOf(name: string): SupportedExt | null {
|
|
const m = EXT_RE.exec(name);
|
|
return m ? (m[1].toLowerCase() as SupportedExt) : null;
|
|
}
|
|
|
|
type ProgressCb = (loaded: number, total: number) => void;
|
|
|
|
// Lazy single instances for the example loaders — avoids re-import churn on
|
|
// repeat loads. NOT the Draco/KTX2 singletons (no shared-loader constraint here).
|
|
type OBJLoaderT = import('three/examples/jsm/loaders/OBJLoader.js').OBJLoader;
|
|
type FBXLoaderT = import('three/examples/jsm/loaders/FBXLoader.js').FBXLoader;
|
|
type ColladaLoaderT = import('three/examples/jsm/loaders/ColladaLoader.js').ColladaLoader;
|
|
type PLYLoaderT = import('three/examples/jsm/loaders/PLYLoader.js').PLYLoader;
|
|
|
|
let _obj: OBJLoaderT | null = null;
|
|
let _fbx: FBXLoaderT | null = null;
|
|
let _dae: ColladaLoaderT | null = null;
|
|
let _ply: PLYLoaderT | null = null;
|
|
|
|
async function getOBJ(): Promise<OBJLoaderT> {
|
|
if (_obj) return _obj;
|
|
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
|
return (_obj = new OBJLoader());
|
|
}
|
|
|
|
async function getFBX(): Promise<FBXLoaderT> {
|
|
if (_fbx) return _fbx;
|
|
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
return (_fbx = new FBXLoader());
|
|
}
|
|
|
|
async function getCollada(): Promise<ColladaLoaderT> {
|
|
if (_dae) return _dae;
|
|
const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
|
|
return (_dae = new ColladaLoader());
|
|
}
|
|
|
|
async function getPLY(): Promise<PLYLoaderT> {
|
|
if (_ply) return _ply;
|
|
const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
|
|
return (_ply = new PLYLoader());
|
|
}
|
|
|
|
function normalizePlyAttributes(geometry: THREE.BufferGeometry): void {
|
|
for (const name of ['position', 'normal', 'uv', 'color']) {
|
|
const attribute = geometry.getAttribute(name);
|
|
if (!attribute || !(attribute.array instanceof Float64Array)) continue;
|
|
geometry.setAttribute(
|
|
name,
|
|
new THREE.Float32BufferAttribute(
|
|
new Float32Array(attribute.array),
|
|
attribute.itemSize,
|
|
attribute.normalized,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load any supported model URL and resolve to a scene-addable Object3D.
|
|
* `nameHint` carries the real file name for ext detection when `url` is a
|
|
* blob: URL (drag & drop) — blob URLs have no extension.
|
|
*/
|
|
export interface LoadOpts {
|
|
/** Raw .mtl text for OBJ loads (drag & drop supplies it; blob: URLs can't
|
|
* resolve the sibling mtllib). Color-only MTLs apply without any texture fetch. */
|
|
mtlText?: string;
|
|
/** Image files dropped alongside an OBJ+MTL (textures the MTL's map_* lines
|
|
* reference). Their blob: URLs are mapped onto the requested texture names so
|
|
* textures resolve without a server. */
|
|
texFiles?: File[];
|
|
/** Parse the OBJ via the streaming parser (objStream.ts) instead of OBJLoader.
|
|
* Set for files too large for OBJLoader's single-string parse (>~300MB). */
|
|
stream?: boolean;
|
|
}
|
|
|
|
export async function loadModel(
|
|
url: string,
|
|
renderer: THREE.WebGLRenderer,
|
|
onProgress?: ProgressCb,
|
|
nameHint?: string,
|
|
opts?: LoadOpts,
|
|
): Promise<THREE.Object3D> {
|
|
const ext = extOf(nameHint ?? url) ?? extOf(url);
|
|
const onXhr = (xhr: ProgressEvent): void => {
|
|
if (onProgress && xhr.total > 0) onProgress(xhr.loaded, xhr.total);
|
|
};
|
|
|
|
switch (ext) {
|
|
case 'glb':
|
|
case 'gltf': {
|
|
const { gltf } = getLoaders(renderer);
|
|
return new Promise<THREE.Object3D>((resolve, reject) => {
|
|
gltf.load(url, (g) => resolve(g.scene), onXhr, (err) => reject(err));
|
|
});
|
|
}
|
|
case 'obj': {
|
|
if (opts?.stream) {
|
|
const { loadObjStreaming } = await import('./objStream');
|
|
return loadObjStreaming(url, opts.mtlText, onProgress);
|
|
}
|
|
const loader = await getOBJ();
|
|
const texUrls: string[] = [];
|
|
// Apply dropped .mtl (color-only) if present; otherwise clear any materials
|
|
// left on the singleton loader from a previous load.
|
|
if (opts?.mtlText) {
|
|
const { MTLLoader } = await import('three/examples/jsm/loaders/MTLLoader.js');
|
|
const manager = new THREE.LoadingManager();
|
|
if (opts.texFiles && opts.texFiles.length) {
|
|
// Map each dropped image's blob: URL onto the texture name the MTL
|
|
// references (by basename), so map_Kd etc. resolve without a server.
|
|
const byName = new Map<string, string>();
|
|
for (const f of opts.texFiles) {
|
|
const u = URL.createObjectURL(f);
|
|
texUrls.push(u);
|
|
byName.set(f.name.toLowerCase(), u);
|
|
}
|
|
manager.setURLModifier((u) => {
|
|
const base = decodeURIComponent((u.split(/[\\/]/).pop() ?? '')).toLowerCase();
|
|
return byName.get(base) ?? u;
|
|
});
|
|
}
|
|
const mc = new MTLLoader(manager).parse(opts.mtlText, '');
|
|
mc.preload();
|
|
loader.setMaterials(mc);
|
|
} else {
|
|
(loader as unknown as { materials: unknown }).materials = null;
|
|
}
|
|
return new Promise<THREE.Object3D>((resolve, reject) => {
|
|
loader.load(
|
|
url,
|
|
(group) => {
|
|
group.traverse((node) => {
|
|
const mat = (node as THREE.Mesh).material;
|
|
if (!mat) return;
|
|
(Array.isArray(mat) ? mat : [mat]).forEach((m) => {
|
|
// CAD OBJ faces often have inconsistent winding; backface culling
|
|
// tears flat surfaces. Render double-sided.
|
|
m.side = THREE.DoubleSide;
|
|
const map = (m as THREE.MeshPhongMaterial).map;
|
|
if (map) map.colorSpace = THREE.SRGBColorSpace; // color textures are sRGB
|
|
});
|
|
});
|
|
// Texture blob: URLs live with the object; revoked on dispose.
|
|
if (texUrls.length) group.userData.__texUrls = texUrls;
|
|
resolve(group);
|
|
},
|
|
onXhr,
|
|
(err) => reject(err),
|
|
);
|
|
});
|
|
}
|
|
case 'fbx': {
|
|
const loader = await getFBX();
|
|
return new Promise<THREE.Object3D>((resolve, reject) => {
|
|
loader.load(url, (group) => resolve(group), onXhr, (err) => reject(err));
|
|
});
|
|
}
|
|
case 'dae': {
|
|
const loader = await getCollada();
|
|
return new Promise<THREE.Object3D>((resolve, reject) => {
|
|
loader.load(
|
|
url,
|
|
(result) => {
|
|
if (!result) {
|
|
reject(new Error('Collada loader returned no scene'));
|
|
return;
|
|
}
|
|
resolve(result.scene);
|
|
},
|
|
onXhr,
|
|
(err) => reject(err),
|
|
);
|
|
});
|
|
}
|
|
case 'ply': {
|
|
const loader = await getPLY();
|
|
return new Promise<THREE.Object3D>((resolve, reject) => {
|
|
loader.load(
|
|
url,
|
|
(geometry) => {
|
|
normalizePlyAttributes(geometry);
|
|
const hasColor = geometry.hasAttribute('color');
|
|
if (geometry.index) {
|
|
// Triangle mesh
|
|
if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: 0xffffff,
|
|
vertexColors: hasColor,
|
|
metalness: 0.0,
|
|
roughness: 1.0,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
resolve(new THREE.Mesh(geometry, mat));
|
|
} else {
|
|
// No faces → point cloud
|
|
const mat = new THREE.PointsMaterial({
|
|
color: 0xffffff,
|
|
vertexColors: hasColor,
|
|
size: 1,
|
|
sizeAttenuation: false,
|
|
});
|
|
resolve(new THREE.Points(geometry, mat));
|
|
}
|
|
},
|
|
onXhr,
|
|
(err) => reject(err),
|
|
);
|
|
});
|
|
}
|
|
case 'ifc':
|
|
return loadIfc(url, onProgress);
|
|
default:
|
|
throw new Error('Unsupported model format: ' + url);
|
|
}
|
|
}
|
|
|
|
let _ifcApi: import('web-ifc').IfcAPI | null = null;
|
|
|
|
/** Parse an .ifc into a Group via web-ifc (single-thread wasm, no COOP/COEP). */
|
|
async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Object3D> {
|
|
try {
|
|
const WebIFC = await import('web-ifc');
|
|
if (!_ifcApi) {
|
|
_ifcApi = new WebIFC.IfcAPI();
|
|
_ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
|
|
await _ifcApi.Init(undefined, true);
|
|
}
|
|
const api = _ifcApi;
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error('fetch ' + res.status);
|
|
const buf = await res.arrayBuffer();
|
|
if (onProgress) onProgress(1, 1);
|
|
const data = new Uint8Array(buf);
|
|
|
|
const modelID = api.OpenModel(data, { COORDINATE_TO_ORIGIN: true });
|
|
if (modelID < 0) throw new Error('OpenModel returned -1');
|
|
|
|
const group = new THREE.Group();
|
|
const matrix = new THREE.Matrix4();
|
|
try {
|
|
api.StreamAllMeshes(modelID, (mesh) => {
|
|
const placedCount = mesh.geometries.size();
|
|
for (let i = 0; i < placedCount; i++) {
|
|
const placed = mesh.geometries.get(i);
|
|
const geom = api.GetGeometry(modelID, placed.geometryExpressID);
|
|
const verts = api.GetVertexArray(geom.GetVertexData(), geom.GetVertexDataSize());
|
|
const indices = api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize());
|
|
geom.delete();
|
|
|
|
// Interleaved [posX,posY,posZ, normX,normY,normZ] — stride 6.
|
|
const interleaved = new THREE.InterleavedBuffer(verts, 6);
|
|
const bg = new THREE.BufferGeometry();
|
|
bg.setAttribute('position', new THREE.InterleavedBufferAttribute(interleaved, 3, 0));
|
|
bg.setAttribute('normal', new THREE.InterleavedBufferAttribute(interleaved, 3, 3));
|
|
bg.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
|
|
const c = placed.color;
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: new THREE.Color(c.x, c.y, c.z),
|
|
metalness: 0.0,
|
|
roughness: 1.0,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
if (c.w < 1) {
|
|
mat.transparent = true;
|
|
mat.opacity = c.w;
|
|
}
|
|
|
|
const three = new THREE.Mesh(bg, mat);
|
|
matrix.fromArray(placed.flatTransformation);
|
|
three.applyMatrix4(matrix);
|
|
group.add(three);
|
|
}
|
|
});
|
|
} finally {
|
|
api.CloseModel(modelID);
|
|
}
|
|
return group;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error('IFC load failed: ' + msg);
|
|
}
|
|
}
|