feat: multi-format 3D viewer with large-file OBJ streaming, Z-up, projection/outline UI
Three.js viewer supporting glb/gltf/obj/fbx/dae/ifc/ply via both server (SSR) and drag&drop (CSR) paths. - Streaming OBJ parser (src/viewer/objStream.ts) for files past the V8 max string length (>~1GB text) that OBJLoader can't handle; indexed geometry, per-vertex color from MTL Kd, float64 recenter baked in. - In-viewer float64 recenter (objRecenter.ts) for huge CAD/survey coordinates (~1e8) so float32 vertex buffers keep precision (no cracked faces). - Z-up right-handed world; Y-up formats rotated on load. - OBJ+MTL+texture drag&drop (LoadingManager URL-modifier maps dropped images). - OrbitControls ground-plane panning (road/rail alignment workflow). - UI: Zoom Fit, perspective/orthographic toggle, feature-edge outline. - DoubleSide for CAD OBJ; PLY mesh + point-cloud support. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { getLoaders } from './loaders';
|
||||
import { loadModel, extOf } from './modelLoader';
|
||||
import { recenterObjFile } from './objRecenter';
|
||||
import { createHydrationGate } from './hydration';
|
||||
import { showProgress, hideProgress, setProgress } from '../ui/progress';
|
||||
import { createFpsMeter } from '../ui/fps';
|
||||
import { createAdaptiveQuality } from './adaptiveQuality';
|
||||
|
||||
// Formats authored Y-up; the viewer world is Z-up (structure convention), so
|
||||
// these are rotated +90° about X on load. OBJ/IFC are Z-up native (no rotate).
|
||||
const Y_UP_EXTS = new Set(['glb', 'gltf', 'fbx', 'dae']);
|
||||
function isYUp(name: string): boolean {
|
||||
const e = extOf(name);
|
||||
return e !== null && Y_UP_EXTS.has(e);
|
||||
}
|
||||
|
||||
export class ThreeDViewer {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly perspCamera: THREE.PerspectiveCamera;
|
||||
private readonly orthoCamera: THREE.OrthographicCamera;
|
||||
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
private projection: 'persp' | 'ortho' = 'persp';
|
||||
private readonly fov = 50;
|
||||
private readonly controls: OrbitControls;
|
||||
private readonly gate;
|
||||
private readonly onError;
|
||||
private readonly fps = createFpsMeter();
|
||||
private readonly adaptive;
|
||||
private raf = 0;
|
||||
private current: THREE.Object3D | null = null;
|
||||
private outline: THREE.Group | null = null;
|
||||
private outlineOn = false;
|
||||
|
||||
constructor(
|
||||
private readonly container: HTMLElement,
|
||||
private readonly progress: HTMLElement,
|
||||
preview: HTMLImageElement,
|
||||
onError?: (msg: string) => void,
|
||||
) {
|
||||
this.onError = onError ?? ((msg: string) => console.error('[hmwebviewer]', msg));
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
// Adaptive quality owns renderer.setPixelRatio: tier 0 applies a DPR cap of
|
||||
// min(devicePixelRatio, 2) immediately (uncapped DPR on 3x devices is the
|
||||
// #1 cause of <60fps). It then steps the pixel ratio down/up with hysteresis.
|
||||
this.adaptive = createAdaptiveQuality(this.renderer);
|
||||
this.container.appendChild(this.fps.el);
|
||||
|
||||
this.perspCamera = new THREE.PerspectiveCamera(this.fov, 1, 0.1, 1000);
|
||||
this.orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
for (const c of [this.perspCamera, this.orthoCamera]) {
|
||||
c.up.set(0, 0, 1); // Z-up world (right-handed, structure convention)
|
||||
c.position.set(2, -3, 2);
|
||||
}
|
||||
this.camera = this.perspCamera;
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
// Pan in the ground (XY) plane, not the screen plane — keeps elevation
|
||||
// constant when panning along a road/rail alignment between turntable spins.
|
||||
this.controls.screenSpacePanning = false;
|
||||
|
||||
this.scene.background = new THREE.Color(0xcfcfcf);
|
||||
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1.2);
|
||||
hemi.position.set(0, 0, 1); // sky toward +Z
|
||||
this.scene.add(hemi);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||
dir.position.set(2, -3, 5); // from above (+Z) and front
|
||||
this.scene.add(dir);
|
||||
|
||||
// Warm the shared singleton loaders so KTX2 detectSupport(renderer) runs at
|
||||
// init; loadModel() reuses the same singletons for GLB/GLTF.
|
||||
getLoaders(this.renderer);
|
||||
this.gate = createHydrationGate(preview);
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.onResize();
|
||||
this.animate();
|
||||
this.gate.markWebGLReady();
|
||||
}
|
||||
|
||||
/** Path A — server asset (SSR placeholder already shown by host page). */
|
||||
loadServerAsset(url: string): void {
|
||||
showProgress(this.progress);
|
||||
loadModel(url, this.renderer, (loaded, total) => {
|
||||
setProgress(this.progress, (loaded / total) * 100);
|
||||
}).then(
|
||||
(obj) => this.onLoaded(obj, isYUp(url)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load server asset.');
|
||||
console.error('[hmwebviewer] server asset load failed', err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path B — local file (Drag & Drop). Blob URL revoked on both paths.
|
||||
* `sidecars` carries companion files dropped alongside (e.g. a .mtl for OBJ);
|
||||
* the blob: URL can't resolve them, so their text is read and passed inline.
|
||||
*/
|
||||
loadLocalFile(file: File, sidecars: File[] = []): void {
|
||||
showProgress(this.progress);
|
||||
const mtlFile = sidecars.find((f) => /\.mtl$/i.test(f.name));
|
||||
const texFiles = sidecars.filter((f) => /\.(png|jpe?g|bmp|gif|webp|tga)$/i.test(f.name));
|
||||
let url: string | null = null;
|
||||
const isObj = extOf(file.name) === 'obj';
|
||||
// OBJLoader builds one giant string; past ~V8 max string length (~1GB of
|
||||
// text) it fails. Route large OBJ through the streaming parser instead.
|
||||
const huge = isObj && file.size > 300 * 1024 * 1024;
|
||||
const run = async (): Promise<THREE.Object3D> => {
|
||||
const mtlText = mtlFile ? await mtlFile.text() : undefined;
|
||||
// Small/medium OBJ: recenter in float64 BEFORE OBJLoader quantizes to
|
||||
// float32 (objRecenter.ts). Huge OBJ recenters inside the streaming parser.
|
||||
const source = (isObj && !huge)
|
||||
? await recenterObjFile(file, (l, t) => setProgress(this.progress, (l / t) * 100))
|
||||
: file;
|
||||
url = URL.createObjectURL(source);
|
||||
return loadModel(
|
||||
url,
|
||||
this.renderer,
|
||||
(loaded, total) => setProgress(this.progress, (loaded / total) * 100),
|
||||
file.name,
|
||||
{ mtlText, texFiles, stream: huge },
|
||||
);
|
||||
};
|
||||
run()
|
||||
.then(
|
||||
(obj) => this.onLoaded(obj, isYUp(file.name)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load file (corrupt or unsupported?).');
|
||||
console.error('[hmwebviewer] local file load failed', err);
|
||||
},
|
||||
)
|
||||
.finally(() => { if (url) URL.revokeObjectURL(url); });
|
||||
}
|
||||
|
||||
private onLoaded(obj: THREE.Object3D, yUp = false): void {
|
||||
if (this.current) {
|
||||
this.scene.remove(this.current);
|
||||
this.disposeObject(this.current);
|
||||
}
|
||||
this.current = obj;
|
||||
if (yUp) obj.rotateX(Math.PI / 2); // Y-up source → Z-up world
|
||||
this.scene.add(this.current);
|
||||
this.frameObject(this.current);
|
||||
this.applyOutlineState();
|
||||
hideProgress(this.progress);
|
||||
this.gate.markModelLoaded();
|
||||
// observable hook for perf smoke tests (tools/perf-smoke.mjs)
|
||||
window.dispatchEvent(new CustomEvent('hmw:ready', { detail: performance.now() }));
|
||||
}
|
||||
|
||||
private frameObject(obj: THREE.Object3D): void {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const dist = (maxDim / 2) / Math.tan(fovRad / 2) * 2;
|
||||
const near = Math.max(maxDim / 1000, 0.01); // adaptive: fixed far=1000 clips large models
|
||||
const far = (dist + maxDim) * 4;
|
||||
const aspect = this.aspect;
|
||||
const dir = new THREE.Vector3(1, -1, 0.8).normalize(); // iso view, Z up
|
||||
const pos = center.clone().addScaledVector(dir, dist);
|
||||
|
||||
this.perspCamera.position.copy(pos);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = near;
|
||||
this.perspCamera.far = far;
|
||||
this.perspCamera.lookAt(center);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
|
||||
// Ortho frustum half-height matches the perspective apparent size at `dist`,
|
||||
// so toggling projection doesn't jump the model scale.
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(pos);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = near;
|
||||
this.orthoCamera.far = far;
|
||||
this.orthoCamera.lookAt(center);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
|
||||
this.controls.target.copy(center);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
/** Re-frame the current model to fit the view (Zoom Fit button). */
|
||||
fitView(): void {
|
||||
if (this.current) this.frameObject(this.current);
|
||||
}
|
||||
|
||||
private applyOutlineState(): void {
|
||||
this.clearOutline();
|
||||
if (this.outlineOn && this.current) this.buildOutline(this.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feature-edge outline (EdgesGeometry, 30° threshold) — the object's
|
||||
* silhouette + sharp creases as black lines, far sparser than a wireframe.
|
||||
* Built lazily; one-time cost can be seconds on very large meshes.
|
||||
*/
|
||||
private buildOutline(obj: THREE.Object3D): void {
|
||||
obj.updateMatrixWorld(true);
|
||||
const group = new THREE.Group();
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0x1a1a1a });
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (!mesh.isMesh || !mesh.geometry) return;
|
||||
const seg = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 30), mat);
|
||||
mesh.matrixWorld.decompose(seg.position, seg.quaternion, seg.scale);
|
||||
group.add(seg);
|
||||
});
|
||||
this.outline = group;
|
||||
this.scene.add(group);
|
||||
}
|
||||
|
||||
private clearOutline(): void {
|
||||
if (!this.outline) return;
|
||||
this.scene.remove(this.outline);
|
||||
this.outline.traverse((node) => {
|
||||
const seg = node as THREE.LineSegments;
|
||||
if (seg.geometry) seg.geometry.dispose();
|
||||
});
|
||||
const m = (this.outline.children[0] as THREE.LineSegments | undefined)?.material;
|
||||
if (m && !Array.isArray(m)) m.dispose();
|
||||
this.outline = null;
|
||||
}
|
||||
|
||||
/** Toggle the object outline overlay (테두리 button). */
|
||||
toggleOutline(): boolean {
|
||||
this.outlineOn = !this.outlineOn;
|
||||
this.applyOutlineState();
|
||||
return this.outlineOn;
|
||||
}
|
||||
|
||||
/** Switch between perspective and orthographic projection, preserving the view. */
|
||||
setProjection(mode: 'persp' | 'ortho'): void {
|
||||
if (mode === this.projection) return;
|
||||
const target = this.controls.target;
|
||||
const from = this.camera;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const offset = from.position.clone().sub(target);
|
||||
const dist = offset.length() || 1;
|
||||
const aspect = this.aspect;
|
||||
|
||||
if (mode === 'ortho') {
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(from.position);
|
||||
this.orthoCamera.up.copy(from.up);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = from.near;
|
||||
this.orthoCamera.far = from.far;
|
||||
this.orthoCamera.lookAt(target);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
this.camera = this.orthoCamera;
|
||||
} else {
|
||||
// Place the perspective camera so apparent size matches the ortho view.
|
||||
const orthoHalfH = this.orthoCamera.top / this.orthoCamera.zoom;
|
||||
const d = orthoHalfH / Math.tan(fovRad / 2);
|
||||
this.perspCamera.position.copy(target).add(offset.setLength(d));
|
||||
this.perspCamera.up.copy(from.up);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = from.near;
|
||||
this.perspCamera.far = from.far;
|
||||
this.perspCamera.lookAt(target);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
this.camera = this.perspCamera;
|
||||
}
|
||||
this.projection = mode;
|
||||
this.controls.object = this.camera;
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
private disposeObject(obj: THREE.Object3D): void {
|
||||
const texUrls = obj.userData?.__texUrls as string[] | undefined;
|
||||
if (texUrls) texUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
const mat = mesh.material;
|
||||
if (Array.isArray(mat)) mat.forEach((m) => this.disposeMaterial(m));
|
||||
else if (mat) this.disposeMaterial(mat);
|
||||
});
|
||||
}
|
||||
|
||||
private disposeMaterial(mat: THREE.Material): void {
|
||||
for (const v of Object.values(mat)) {
|
||||
if (v instanceof THREE.Texture) v.dispose();
|
||||
}
|
||||
mat.dispose();
|
||||
}
|
||||
|
||||
private get aspect(): number {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
return w / h;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
const aspect = w / h;
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
const halfH = this.orthoCamera.top || 1;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (now: number = performance.now()): void => {
|
||||
this.raf = requestAnimationFrame(this.animate);
|
||||
this.fps.sample(now);
|
||||
this.adaptive.update(this.fps.fps());
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.controls.dispose();
|
||||
this.clearOutline();
|
||||
if (this.current) this.disposeObject(this.current);
|
||||
this.fps.el.remove();
|
||||
this.renderer.dispose();
|
||||
this.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
/** Orbit the camera to azimuth (radians) around the model — used by tools/prerender.mjs. */
|
||||
rotateTo(azimuth: number): void {
|
||||
if (!this.current) return;
|
||||
const offset = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
|
||||
const radius = Math.max(offset.length(), 1e-3);
|
||||
const phi = Math.acos(THREE.MathUtils.clamp(offset.y / radius, -1, 1));
|
||||
const target = this.controls.target;
|
||||
this.camera.position.set(
|
||||
target.x + radius * Math.sin(phi) * Math.sin(azimuth),
|
||||
target.y + radius * Math.cos(phi),
|
||||
target.z + radius * Math.sin(phi) * Math.cos(azimuth),
|
||||
);
|
||||
this.camera.lookAt(target);
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
/**
|
||||
* Adaptive-quality controller.
|
||||
*
|
||||
* The single highest-leverage, lowest-risk runtime knob in this codebase is
|
||||
* renderer.setPixelRatio: it re-allocates the drawing buffer to
|
||||
* floor(clientSize * pixelRatio) without touching CSS layout (onResize already
|
||||
* uses updateStyle=false), so there is zero reflow. We walk a tiered ladder of
|
||||
* pixel-ratio steps with hysteresis (asymmetric down/up thresholds + sustained
|
||||
* windows) so the tier cannot oscillate around the 60fps line.
|
||||
*
|
||||
* Antialias is deliberately NOT touched: it is fixed at WebGLRenderer
|
||||
* construction and changing it would force a new context + KTX2 detectSupport,
|
||||
* violating the singleton-loader invariant in src/viewer/loaders.ts.
|
||||
*/
|
||||
|
||||
const TIER_MIN = 0;
|
||||
const TIER_MAX = 4;
|
||||
// Sustained-sample windows (in frames at ~60fps): ~1s down, longer to recover.
|
||||
const DOWN_FRAMES = 60;
|
||||
const UP_FRAMES = 180;
|
||||
const DOWN_FPS = 60;
|
||||
const UP_FPS = 72;
|
||||
|
||||
export interface AdaptiveQuality {
|
||||
/** Feed the current rolling-average fps; may step the tier. */
|
||||
update(avgFps: number): void;
|
||||
/** Current tier index (0 = best). */
|
||||
tier(): number;
|
||||
}
|
||||
|
||||
export interface AdaptiveQualityOptions {
|
||||
/** Override the tier-0 (best) pixel ratio. Default min(devicePixelRatio, 2). */
|
||||
baseRatio?: number;
|
||||
}
|
||||
|
||||
/** Create the controller and apply tier 0 immediately. */
|
||||
export function createAdaptiveQuality(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
opts: AdaptiveQualityOptions = {},
|
||||
): AdaptiveQuality {
|
||||
const base = opts.baseRatio ?? Math.min(window.devicePixelRatio, 2);
|
||||
// Ladder of pixel ratios, highest quality first. Tier 0 = capped DPR.
|
||||
const ratios = [base, 1.5, 1, 0.75, 0.5];
|
||||
|
||||
let tier = 0;
|
||||
let below = 0;
|
||||
let above = 0;
|
||||
|
||||
renderer.setPixelRatio(ratios[tier]);
|
||||
|
||||
function applyTier(next: number): void {
|
||||
tier = next;
|
||||
below = 0;
|
||||
above = 0;
|
||||
const pr = ratios[tier];
|
||||
renderer.setPixelRatio(pr);
|
||||
console.info(`[hmwebviewer] adaptive quality -> tier ${tier} (pr=${pr})`);
|
||||
}
|
||||
|
||||
return {
|
||||
update(avgFps: number): void {
|
||||
// Ignore warm-up / unmeasured frames.
|
||||
if (avgFps <= 0) return;
|
||||
|
||||
if (avgFps < DOWN_FPS) {
|
||||
below++;
|
||||
above = 0;
|
||||
if (below >= DOWN_FRAMES && tier < TIER_MAX) applyTier(tier + 1);
|
||||
} else if (avgFps > UP_FPS) {
|
||||
above++;
|
||||
below = 0;
|
||||
if (above >= UP_FRAMES && tier > TIER_MIN) applyTier(tier - 1);
|
||||
} else {
|
||||
// Dead band: reset both so transient blips don't accumulate.
|
||||
below = 0;
|
||||
above = 0;
|
||||
}
|
||||
},
|
||||
tier(): number {
|
||||
return tier;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SSR -> CSR hydration gate. Fades the WebP placeholder to the Three.js canvas
|
||||
// ONLY after BOTH the WebGL context is ready AND the model is loaded.
|
||||
// Firing on just one condition = empty-canvas flash (race). Idempotent.
|
||||
|
||||
export interface HydrationGate {
|
||||
markWebGLReady(): void;
|
||||
markModelLoaded(): void;
|
||||
}
|
||||
|
||||
export function createHydrationGate(preview: HTMLImageElement): HydrationGate {
|
||||
let webglReady = false;
|
||||
let modelLoaded = false;
|
||||
let fired = false;
|
||||
|
||||
function maybeFire(): void {
|
||||
if (fired || !(webglReady && modelLoaded)) return;
|
||||
fired = true;
|
||||
preview.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
preview.classList.add('hidden');
|
||||
preview.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
markWebGLReady() {
|
||||
webglReady = true;
|
||||
maybeFire();
|
||||
},
|
||||
markModelLoaded() {
|
||||
modelLoaded = true;
|
||||
maybeFire();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
||||
|
||||
export interface ViewerLoaders {
|
||||
gltf: GLTFLoader;
|
||||
draco: DRACOLoader;
|
||||
ktx2: KTX2Loader;
|
||||
}
|
||||
|
||||
// SINGLETON — multiple DRACOLoader/KTX2Loader instances crash (three.js #22445).
|
||||
// Reuse across every load. Decoder WASM lives under public/draco + public/basis
|
||||
// (version-matched to the installed three.js).
|
||||
let _cache: ViewerLoaders | null = null;
|
||||
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
||||
if (_cache) return _cache;
|
||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
||||
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
_cache = { gltf, draco, ktx2 };
|
||||
return _cache;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* In-viewer float64 recenter for OBJ files with huge absolute coordinates
|
||||
* (CAD/survey, ~1e8). Done at LOAD time, before OBJLoader quantizes to float32.
|
||||
*
|
||||
* Why here and not after parse: WebGL vertex buffers are float32; at ~1e8 the
|
||||
* float32 ulp is 16-64 units, so vertices snap to that grid, merge, and faces
|
||||
* crack. Subtracting the bbox center from the *source text* (float64) keeps
|
||||
* coords small (~±3e4) so float32 holds full detail — no preprocessed files,
|
||||
* no shader-side double emulation (RTE/two-float), which a single offset of
|
||||
* this magnitude makes unnecessary (relative precision after centering ~3e-5
|
||||
* vs float32 ~1e-7).
|
||||
*
|
||||
* Streamed in two passes (bbox, then rewrite) to keep memory bounded on the
|
||||
* 100-200MB OBJ files this targets.
|
||||
*/
|
||||
|
||||
const V = 118; // 'v'
|
||||
const SP = 32; // ' '
|
||||
const RECENTER_THRESHOLD = 1e4; // models nearer the origin than this are left as-is
|
||||
|
||||
/** Yield LF-normalized lines from a Blob's stream without buffering the whole file. */
|
||||
async function* lines(blob: Blob): AsyncGenerator<string> {
|
||||
const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : String(Math.round(n * 1000) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an origin-centered copy of `file` as a Blob, or the original `file`
|
||||
* unchanged when it already sits near the origin (small models pay only the
|
||||
* cheap first-pass scan). The returned Blob carries the same OBJ text minus a
|
||||
* per-file integer offset on every `v` line; vn/f/usemtl/mtllib are verbatim.
|
||||
*/
|
||||
export async function recenterObjFile(
|
||||
file: File,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<Blob> {
|
||||
// Two streamed passes (bbox, then rewrite) over the file's bytes; report
|
||||
// progress across both, throttled so the DOM isn't touched per line.
|
||||
const total = file.size * 2;
|
||||
let read = 0, tick = 0;
|
||||
const report = (line: string): void => {
|
||||
read += line.length + 1;
|
||||
if (onProgress && (++tick & 0x3fff) === 0) onProgress(read, total); // every ~16k lines
|
||||
};
|
||||
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) !== V || line.charCodeAt(1) !== SP) continue;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
}
|
||||
if (!Number.isFinite(xmin)) return file; // no vertices — let OBJLoader handle it
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
if (Math.hypot(cx, cy, cz) < RECENTER_THRESHOLD) return file;
|
||||
|
||||
const ox = Math.round(cx), oy = Math.round(cy), oz = Math.round(cz);
|
||||
const enc = new TextEncoder();
|
||||
const parts: BlobPart[] = [];
|
||||
let out = '';
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) === V && line.charCodeAt(1) === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
out += `v ${fmt(+p[1] - ox)} ${fmt(+p[2] - oy)} ${fmt(+p[3] - oz)}\n`;
|
||||
} else {
|
||||
out += line + '\n';
|
||||
}
|
||||
if (out.length > (1 << 23)) { parts.push(enc.encode(out)); out = ''; } // flush ~8MB
|
||||
}
|
||||
if (out) parts.push(enc.encode(out));
|
||||
if (onProgress) onProgress(total, total);
|
||||
return new Blob(parts, { type: 'text/plain' });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Streaming OBJ parser for files too large for OBJLoader.
|
||||
*
|
||||
* OBJLoader builds one giant string of the whole file; past ~512MB-1GB of text
|
||||
* that exceeds the V8 max string length and fails (empty geometry). This parses
|
||||
* the file in two streamed passes (count, then fill) so it never holds the whole
|
||||
* text, building an INDEXED BufferGeometry (positions indexed by vertex, normals
|
||||
* computed). Memory stays ~O(vertices + triangles) of typed arrays.
|
||||
*
|
||||
* Trade-offs vs OBJLoader (acceptable for huge structural CAD exports):
|
||||
* - smooth (computed) normals, not per-face-corner — hard edges soften.
|
||||
* - per-vertex color from the active material's Kd (one draw call, no textures).
|
||||
* - float64 recenter baked in (huge absolute coords don't crack float32).
|
||||
*/
|
||||
|
||||
const SP = 32; // ' '
|
||||
const V = 118; // 'v'
|
||||
const F = 102; // 'f'
|
||||
const U = 117; // 'u' (usemtl)
|
||||
const RECENTER_THRESHOLD = 1e4;
|
||||
|
||||
async function* streamLines(url: string): AsyncGenerator<string> {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok || !resp.body) throw new Error('fetch failed: ' + resp.status);
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function parseMtlColors(text: string): Map<string, [number, number, number]> {
|
||||
const map = new Map<string, [number, number, number]>();
|
||||
let cur = '';
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('newmtl ')) cur = line.slice(7).trim();
|
||||
else if (cur && line.startsWith('Kd ')) {
|
||||
const p = line.split(/\s+/);
|
||||
map.set(cur, [+p[1], +p[2], +p[3]]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function loadObjStreaming(
|
||||
url: string,
|
||||
mtlText?: string,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<THREE.Object3D> {
|
||||
// PASS 1 — count vertices + triangles, accumulate bbox in float64.
|
||||
let nV = 0, nTri = 0;
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0);
|
||||
if (c0 === V && line.charCodeAt(1) === SP) {
|
||||
nV++;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
} else if (c0 === F && line.charCodeAt(1) === SP) {
|
||||
let corners = 0;
|
||||
const p = line.split(/\s+/);
|
||||
for (let k = 1; k < p.length; k++) if (p[k]) corners++;
|
||||
if (corners >= 3) nTri += corners - 2;
|
||||
}
|
||||
}
|
||||
if (nV === 0) throw new Error('OBJ has no vertices');
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
const far = Math.hypot(cx, cy, cz) >= RECENTER_THRESHOLD;
|
||||
const ox = far ? Math.round(cx) : 0, oy = far ? Math.round(cy) : 0, oz = far ? Math.round(cz) : 0;
|
||||
|
||||
// PASS 2 — fill typed arrays.
|
||||
const positions = new Float32Array(nV * 3);
|
||||
const index = new Uint32Array(nTri * 3);
|
||||
const colorMap = mtlText ? parseMtlColors(mtlText) : null;
|
||||
const colors = colorMap && colorMap.size ? new Float32Array(nV * 3) : null;
|
||||
let vi = 0, ii = 0;
|
||||
let cr = 0.8, cg = 0.8, cb = 0.8;
|
||||
const total = nV + nTri; let done = 0;
|
||||
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0), c1 = line.charCodeAt(1);
|
||||
if (c0 === V && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
positions[vi * 3] = +p[1] - ox;
|
||||
positions[vi * 3 + 1] = +p[2] - oy;
|
||||
positions[vi * 3 + 2] = +p[3] - oz;
|
||||
vi++;
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === F && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
const vs: number[] = [];
|
||||
for (let k = 1; k < p.length; k++) {
|
||||
if (!p[k]) continue;
|
||||
const slash = p[k].indexOf('/');
|
||||
let idx = parseInt(slash >= 0 ? p[k].slice(0, slash) : p[k], 10);
|
||||
idx = idx < 0 ? nV + idx : idx - 1; // negative = relative; OBJ is 1-based
|
||||
vs.push(idx);
|
||||
}
|
||||
if (colors) for (const v of vs) { colors[v * 3] = cr; colors[v * 3 + 1] = cg; colors[v * 3 + 2] = cb; }
|
||||
for (let t = 1; t + 1 < vs.length; t++) { // fan triangulate
|
||||
index[ii++] = vs[0]; index[ii++] = vs[t]; index[ii++] = vs[t + 1];
|
||||
}
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === U && line.startsWith('usemtl ')) {
|
||||
const c = colorMap?.get(line.slice(7).trim());
|
||||
if (c) { cr = c[0]; cg = c[1]; cb = c[2]; }
|
||||
}
|
||||
}
|
||||
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
if (colors) geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
geom.setIndex(new THREE.BufferAttribute(index, 1));
|
||||
geom.computeVertexNormals();
|
||||
onProgress?.(total, total);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colors ? 0xffffff : 0xcccccc,
|
||||
vertexColors: !!colors,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
return new THREE.Mesh(geom, mat);
|
||||
}
|
||||
Reference in New Issue
Block a user