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