Files
dwg-dxf-viewer-sample/apps/viewer-3d/src/viewer/ThreeDViewer.ts
T
lectom bedb357259 chore(monorepo): import hmwebviewer history at a717900
git-subtree-dir: apps/viewer-3d
git-subtree-mainline: 5e10bb1be4
git-subtree-split: a71790070d
2026-07-29 09:04:52 +09:00

361 lines
14 KiB
TypeScript

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