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 { if (_obj) return _obj; const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js'); return (_obj = new OBJLoader()); } async function getFBX(): Promise { if (_fbx) return _fbx; const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js'); return (_fbx = new FBXLoader()); } async function getCollada(): Promise { if (_dae) return _dae; const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js'); return (_dae = new ColladaLoader()); } async function getPLY(): Promise { 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 { 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((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(); 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((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((resolve, reject) => { loader.load(url, (group) => resolve(group), onXhr, (err) => reject(err)); }); } case 'dae': { const loader = await getCollada(); return new Promise((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((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 { 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); } }