chore(monorepo): import hmwebviewer history at a717900
git-subtree-dir: apps/viewer-3d git-subtree-mainline:5e10bb1be4git-subtree-split:a71790070d
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import * as THREE from 'three';
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => resolve(result.scene), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'ply': {
|
||||
const loader = await getPLY();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(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('/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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user