import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; import { createRouteMarkers, sceneToModel, type ModelBounds, type RouteMarkers, type RoutePointKind, type SectionStationMarker, } from "./B05_wf2_Route_UI_Markers"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; function disposeObject(object: THREE.Object3D | null): void { object?.traverse((child) => { if ( child instanceof THREE.Mesh || child instanceof THREE.Points || child instanceof THREE.Line ) { child.geometry.dispose(); const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((material) => material.dispose()); } }); } export interface RouteViewer { root: HTMLElement; markers: RouteMarkers; loadSurface: ( projectId: string, modelId: number, method: string, smooth: boolean, interval: number, bounds: ModelBounds, ) => Promise; reloadContours: (interval: number) => Promise; setSurfaceVisible: (visible: boolean) => void; setContoursVisible: (visible: boolean) => void; setAxesVisible: (visible: boolean) => void; setStationLinesVisible: (visible: boolean) => void; renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; beginMoveSelected: () => void; dispose: () => void; } export function createRouteViewer(): RouteViewer { const root = document.createElement("div"); root.className = "b05-route__viewport"; const status = document.createElement("div"); status.className = "b05-route__viewer-status"; status.textContent = "확정 지표면을 불러오는 중입니다."; const canvas = document.createElement("canvas"); root.append(canvas, status); const scene = new THREE.Scene(); const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); function updateSceneBackground(): void { const theme = document.documentElement.getAttribute("data-theme"); const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches); if (!dark) { scene.background = new THREE.Color(LIGHT_VIEWER_BACKGROUND); return; } const surfaceRaised = getComputedStyle(document.documentElement) .getPropertyValue("--color-surface-raised") .trim(); scene.background = new THREE.Color( surfaceRaised && CSS.supports("color", surfaceRaised) ? surfaceRaised : DARK_VIEWER_BACKGROUND, ); } const themeObserver = new MutationObserver(updateSceneBackground); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); camera.position.set(100, 120, 100); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); const directional = new THREE.DirectionalLight(0xffffff, 2.2); directional.position.set(100, 200, 100); scene.add(directional); const axes = new THREE.AxesHelper(30); axes.visible = false; scene.add(axes); let terrain: THREE.Object3D | null = null; const contours = new THREE.Group(); scene.add(contours); let bounds: ModelBounds | null = null; let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = null; let movingSelected = false; let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; const markers = createRouteMarkers(scene, () => bounds); function clearContours(): void { disposeObject(contours); contours.clear(); } function resize(): void { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); function fit(view: "iso" | "top" | "front" | "side" = "top"): void { if (!bounds) return; const width = bounds.x[1] - bounds.x[0]; const depth = bounds.y[1] - bounds.y[0]; const distance = Math.max(width, depth, 20) * 1.35; controls.target.set(0, 0, 0); const positions = { iso: [distance, distance, distance], top: [0, distance, 0.001], front: [0, distance * 0.25, distance], side: [distance, distance * 0.25, 0], } as const; const [x, y, z] = positions[view]; camera.position.set(x, y, z); camera.near = Math.max(0.1, distance / 1000); camera.far = distance * 10; camera.updateProjectionMatrix(); controls.update(); } async function reloadContours(interval: number): Promise { if (!current || !bounds) return; current.interval = interval; const response = await fetch( `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, { credentials: "include", cache: "no-store" }, ); if (!response.ok) throw new Error("등고선 조회에 실패했습니다."); const data = (await response.json()) as { contours: Array<{ level: number; coordinates: [number, number, number][] }>; }; clearContours(); data.contours.forEach((contour) => { const points = contour.coordinates.map(([x, y, z]) => { const cx = (bounds!.x[0] + bounds!.x[1]) / 2; const cy = (bounds!.y[0] + bounds!.y[1]) / 2; const cz = (bounds!.z[0] + bounds!.z[1]) / 2; return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); }); if (points.length > 1) { contours.add( new THREE.Line( new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b, transparent: true, opacity: 0.75, }), ), ); } }); } function terrainPoint( event: PointerEvent | DragEvent, ): { x: number; y: number; z: number } | null { if (!terrain || !bounds) return null; const rect = canvas.getBoundingClientRect(); const pointer = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1, ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointer, camera); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds) : null; } canvas.addEventListener("dragover", (event) => event.preventDefault()); canvas.addEventListener("drop", (event) => { event.preventDefault(); const kind = event.dataTransfer?.getData("pointType") as RoutePointKind; const point = terrainPoint(event); if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); }); function markerHit(event: PointerEvent): THREE.Object3D | undefined { const rect = canvas.getBoundingClientRect(); const pointer = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1, ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointer, camera); return raycaster.intersectObject(markers.group, true)[0]?.object; } function finishMarkerInteraction(selectCandidate: boolean): void { if (dragCandidate && (draggingMarker || selectCandidate)) { markers.selectPoint(dragCandidate.id); } if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) { canvas.releasePointerCapture(dragCandidate.pointerId); } controls.enabled = true; dragCandidate = null; draggingMarker = false; lastDragPoint = null; } function handlePointerDown(event: PointerEvent): void { if (event.button !== 0) return; const hit = markerHit(event); const pointId = markers.pointIdForObject(hit); if (pointId) { dragCandidate = { id: pointId, pointerId: event.pointerId, x: event.clientX, y: event.clientY, }; draggingMarker = false; lastDragPoint = null; canvas.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); return; } if (hit) { markers.selectObject(hit); event.stopPropagation(); return; } if (movingSelected) { const point = terrainPoint(event); if (point) markers.moveSelected(point); movingSelected = false; } else { markers.selectObject(undefined); } } function handlePointerMove(event: PointerEvent): void { if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; event.preventDefault(); event.stopPropagation(); if ( !draggingMarker && Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3 ) { draggingMarker = true; controls.enabled = false; } if (!draggingMarker) return; const point = terrainPoint(event); if (!point) return; lastDragPoint = point; markers.movePoint(dragCandidate.id, point); } function handlePointerUp(event: PointerEvent): void { if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; event.preventDefault(); event.stopPropagation(); if (draggingMarker) { const point = terrainPoint(event) ?? lastDragPoint; if (point) markers.movePoint(dragCandidate.id, point); } finishMarkerInteraction(!draggingMarker); } function handlePointerExit(event: PointerEvent): void { if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; event.preventDefault(); event.stopPropagation(); finishMarkerInteraction(false); } canvas.addEventListener("pointerdown", handlePointerDown, true); canvas.addEventListener("pointermove", handlePointerMove, true); canvas.addEventListener("pointerup", handlePointerUp, true); canvas.addEventListener("pointerleave", handlePointerExit, true); canvas.addEventListener("pointercancel", handlePointerExit, true); let frame = 0; function animate(): void { frame = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); return { root, markers, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { bounds = nextBounds; current = { projectId, modelId, smooth, interval }; if (terrain) { scene.remove(terrain); disposeObject(terrain); } const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; terrain = await new Promise((resolve, reject) => { if (method === "meshfree") { new PLYLoader().load( url, (geometry) => resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))), undefined, reject, ); } else { new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); } }); terrain.traverse((child) => { if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; }); scene.add(terrain); fit("top"); markers.renderMarkers(); await reloadContours(interval); status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요."; }, reloadContours, setSurfaceVisible(visible) { if (terrain) terrain.visible = visible; }, setContoursVisible(visible) { contours.visible = visible; }, setAxesVisible(visible) { axes.visible = visible; }, setStationLinesVisible: markers.setStationLinesVisible, renderStationLines: markers.renderStationLines, setView: fit, beginMoveSelected() { movingSelected = true; status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; }, dispose() { cancelAnimationFrame(frame); resizeObserver.disconnect(); themeObserver.disconnect(); systemDarkTheme.removeEventListener("change", updateSceneBackground); canvas.removeEventListener("pointerdown", handlePointerDown, true); canvas.removeEventListener("pointermove", handlePointerMove, true); canvas.removeEventListener("pointerup", handlePointerUp, true); canvas.removeEventListener("pointerleave", handlePointerExit, true); canvas.removeEventListener("pointercancel", handlePointerExit, true); markers.dispose(); clearContours(); disposeObject(terrain); controls.dispose(); renderer.dispose(); }, }; }