Files
dwg-dxf-viewer-sample/apps/viewer-3d/src/viewer/modelLoader.ts
T

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);
}
}