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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user