Files
Pier_v1/랜딩페이지/sample-preview.ts
T

47 lines
7.2 KiB
TypeScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { buildPier } from '../src/core/geometry';
import { buildAllPiers } from '../src/view/all-piers';
import { terrainGeometry } from '../src/view/terrain';
import { buildTerrainMapGroup, setTerrainMapMode, disposeTerrainMapGroup } from '../src/view/terrain-map';
import { sampleResource } from './sample-resource';
import type { Sample } from './sample-explorer';
const disposers=new WeakMap<HTMLElement,()=>void>();
export async function renderSamplePreview(host:HTMLElement,sample:Sample,current:()=>boolean):Promise<void>{
disposers.get(host)?.();host.replaceChildren();host.dataset.sample=String(sample.sample);host.dataset.previewState='loading';
const note=document.createElement('span');note.className='sample-preview-note';note.textContent='실제 지도·지반·교각을 불러오는 중…';host.append(note);
let disposed=false,renderer:THREE.WebGLRenderer|undefined,controls:OrbitControls|undefined,maps:THREE.Group|null=null,frame=0,resize:ResizeObserver|undefined;
const scene=new THREE.Scene();let ground:THREE.Mesh|null=null;
const dispose=()=>{disposed=true;cancelAnimationFrame(frame);resize?.disconnect();host.classList.remove('is-expanded');controls?.dispose();disposeTerrainMapGroup(maps);maps?.removeFromParent();scene.traverse(o=>{const m=o as THREE.Mesh;m.geometry?.dispose();(Array.isArray(m.material)?m.material:[m.material]).forEach(v=>v?.dispose());});renderer?.dispose();renderer?.forceContextLoss();};
disposers.set(host,dispose);
try{
const {project}=await sampleResource(sample);if(!current()||disposed)return;
renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.localClippingEnabled=true;renderer.setClearColor(0xeef5fb,1);renderer.domElement.setAttribute('aria-label',`${sample.bridge} 실제 지도·지반·교각 3D 미리보기`);renderer.domElement.tabIndex=0;host.onkeydown=e=>{if(e.key==='Escape')host.classList.remove('is-expanded');};host.prepend(renderer.domElement);
const entries=project.piers.flatMap(p=>{try{return[{name:p.input.name,model:buildPier(p.input)}];}catch{return[];}}),grid=project.groundModel?.terrainGrid;
const corner=project.groundModel?.mapLayers?.[0]?.survey_corners[0];const pivot=entries[0]?.model.pivot??{e:grid?.origin[1]??corner?.[1]??0,n:grid?.origin[0]??corner?.[0]??0};let focus:THREE.Object3D=scene,representative:THREE.Box3|null=null;
if(entries.length){const all=buildAllPiers(entries,grid);scene.add(all.piers);focus=all.piers;ground=all.terrain;if(entries.length>2){all.piers.updateWorldMatrix(true,true);representative=new THREE.Box3();for(const entry of entries.slice(0,2))representative.union(new THREE.Box3().setFromObject(all.scenes.get(entry.name)!.root));}}
else if(grid)ground=new THREE.Mesh(terrainGeometry(grid,pivot),new THREE.MeshStandardMaterial({color:0xb5c7ad,side:THREE.DoubleSide}));
if(ground){scene.add(ground);(ground.material as THREE.MeshStandardMaterial).opacity=1;}
const layers=project.groundModel?.mapLayers??[],hasMap=layers.length>0;
const box=new THREE.Box3().setFromObject(focus);if(box.isEmpty()&&grid)box.setFromObject(ground!);
const elevation=entries[0]?.model.groundEl??(box.isEmpty()?0:box.getCenter(new THREE.Vector3()).y);
if(hasMap){maps=buildTerrainMapGroup(layers,pivot,elevation,layers.some(l=>l.id==='satellite')?'satellite':'ground',()=>{if(!disposed)note.textContent='저장 지도 이미지 오류 · 지반/교각 표시';},grid);scene.add(maps);if(box.isEmpty())box.setFromObject(maps);}
if(box.isEmpty())throw new Error('표시할 교각·지반·지도 좌표가 없습니다.');
host.dataset.mapLayers=String(layers.length);host.dataset.terrain=String(!!grid);host.dataset.pierCount=String(entries.length);
note.textContent=`${hasMap?'저장 지도':'지도 미연결'} · ${grid?'원도면 지반':'지형정보 미연결'} · 교각 ${entries.length}기${entries.length>2?' · 대표구간':''}`;
const fullBox=box.clone();if(representative)box.copy(representative);const centre=box.getCenter(new THREE.Vector3()),radius=Math.max(box.getSize(new THREE.Vector3()).length()/2,1),camera=new THREE.PerspectiveCamera(38,2,.1,Math.max(radius,fullBox.getSize(new THREE.Vector3()).length())*25);
const first=entries[0]?.model.pivot,last=entries.at(-1)?.model.pivot,axis=first&&last?new THREE.Vector3(last.e-first.e,0,first.n-last.n):new THREE.Vector3(1,0,0);if(axis.length()<1)axis.set(1,0,0);axis.normalize();
camera.position.copy(centre).add(new THREE.Vector3(axis.z,1.25,-axis.x).normalize().multiplyScalar(radius*3.6));
controls=new OrbitControls(camera,renderer.domElement);controls.target.copy(centre);controls.enableDamping=true;controls.minDistance=radius*.05;controls.maxDistance=Math.max(radius,fullBox.getSize(new THREE.Vector3()).length())*12;controls.update();
let currentBox=box;const direction=new THREE.Vector3(axis.z,1.25,-axis.x).normalize();
const fitBox=(target:THREE.Box3)=>{currentBox=target;const c=target.getCenter(new THREE.Vector3()),r=Math.max(target.getSize(new THREE.Vector3()).length(),1);camera.position.copy(c).addScaledVector(direction,r*4);camera.lookAt(c);camera.updateMatrixWorld();const bounds=new THREE.Box3();for(const x of [target.min.x,target.max.x])for(const y of [target.min.y,target.max.y])for(const z of [target.min.z,target.max.z])bounds.expandByPoint(new THREE.Vector3(x,y,z).applyMatrix4(camera.matrixWorldInverse));const size=bounds.getSize(new THREE.Vector3()),tan=Math.tan(camera.fov*Math.PI/360),distance=(Math.max(size.x/(2*tan*camera.aspect),size.y/(2*tan))+size.z/2)*1.14;camera.position.copy(c).addScaledVector(direction,distance);controls!.target.copy(c);controls!.update();};
scene.add(new THREE.HemisphereLight(0xffffff,0x7b91a7,2));const light=new THREE.DirectionalLight(0xffffff,2);light.position.set(200,400,100);scene.add(light);
const tools=document.createElement('div');tools.className='sample-preview-tools';
for(const [label,action] of [['확대',()=>{host.classList.toggle('is-expanded');}],['위성',()=>setTerrainMapMode(maps,'satellite')],['2D',()=>setTerrainMapMode(maps,'ground')],['지반 투명',()=>{maps?.traverse(o=>{if(o instanceof THREE.Mesh){o.material.transparent=!o.material.transparent;o.material.opacity=o.material.transparent ? .45 : 1;o.material.depthWrite=!o.material.transparent;}});if(ground)(ground.material as THREE.Material).visible=!(ground.material as THREE.Material).visible;}],['전체',()=>fitBox(fullBox)],['맞춤',()=>fitBox(box)]] as const){
const b=document.createElement('button');b.type='button';b.textContent=label;b.disabled=!hasMap&&['위성','2D'].includes(label);b.addEventListener('click',action);tools.append(b);
}host.append(tools);
resize=new ResizeObserver(()=>{if(disposed||!renderer)return;const w=host.clientWidth,h=host.clientHeight;if(w&&h){renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();fitBox(currentBox);}});resize.observe(host);
host.dataset.previewState='ready';const render=()=>{if(disposed)return;frame=requestAnimationFrame(render);if(host.getClientRects().length&&!document.hidden){controls!.update();renderer!.render(scene,camera);}};render();
}catch(error){if(current()&&!disposed){host.dataset.previewState='error';note.textContent=`미리보기 오류: ${(error as Error).message}`;}}
}