/* ============================================================================= * B04_PreProcess_UI_TerrainViewer_Contours.ts * 지표면 3D 뷰어의 **등고선 적재** — 서버(또는 보관함)에서 등고선을 받아 THREE 라인으로 * 얹고, 표고 범례(최고·최저)를 갱신한다. * * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). * 본문 로직·수치는 그대로이고, 뷰어 클로저가 쥐고 있던 값만 `ctx` 로 받는다. * ========================================================================== */ import * as THREE from "three"; import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedJson } from "../A00_Common/b_asset_cache"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ const MAX_CONTOUR_LABELS = 40; /** 뷰어 본체가 쥔 값 중 등고선 적재에 필요한 것들. */ export interface ContourLoadContext { viewerArea: HTMLElement; legendBar: HTMLElement; maxValSpan: HTMLElement; minValSpan: HTMLElement; intervalInput: HTMLInputElement; camera: THREE.PerspectiveCamera; contourGroup: THREE.Group; contourCheck: HTMLInputElement; /** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */ projectId: () => string; currentModelId: () => number | null; currentModelSmooth: () => boolean; /** 등고 라벨 DOM 목록(뷰어와 **같은 배열**을 공유한다)과 다시 배치하라는 표시. */ labelElements: HTMLDivElement[]; setLabelsDirty: (dirty: boolean) => void; clearContours: () => void; } export async function loadContourLinesInto( ctx: ContourLoadContext, modelId: number, isSmooth: boolean, recalculate = false, ): Promise { const interval = parseFloat(ctx.intervalInput.value) || 1.0; const projectId = ctx.projectId(); const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. const data = await fetchCachedJson(projectId, contourUrl); if ( ctx.projectId() !== projectId || ctx.currentModelId() !== modelId || ctx.currentModelSmooth() !== isSmooth || (parseFloat(ctx.intervalInput.value) || 1.0) !== interval ) { return false; } ctx.clearContours(); const bounds = data.bounds; if (!bounds) return false; 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; const transform = (coords: [number, number, number][]) => { return coords.map(([x_model, y_model, z_model]) => { const x_scene = x_model - cx; const y_scene = z_model - cz; const z_scene = -(y_model - cy); return new THREE.Vector3(x_scene, y_scene, z_scene); }); }; let minH = Infinity; let maxH = -Infinity; // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). const majorPoints: THREE.Vector3[] = []; const minorPoints: THREE.Vector3[] = []; const labelCandidates: { level: number; position: THREE.Vector3; length: number; }[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; if (c.level > maxH) maxH = c.level; const points = transform(c.coordinates); if (points.length < 2) return; const isMajor = c.level % (interval * 5) === 0; const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); } // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. if (isMajor && points.length > 4) { let length = 0; for (let i = 0; i < points.length - 1; i++) { length += points[i].distanceTo(points[i + 1]); } labelCandidates.push({ level: c.level, position: points[Math.floor(points.length / 2)], length, }); } }); labelCandidates.sort((a, b) => b.length - a.length); for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { const labelPos = candidate.position; const labelDiv = document.createElement("div"); labelDiv.className = "contour-label"; labelDiv.innerText = `${Math.round(candidate.level)}m`; labelDiv.style.position = "absolute"; labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; labelDiv.style.border = "1px solid #d97706"; labelDiv.style.color = "#b45309"; labelDiv.style.padding = "1px 4px"; labelDiv.style.borderRadius = "3px"; labelDiv.style.fontSize = "9px"; labelDiv.style.fontWeight = "bold"; labelDiv.style.pointerEvents = "none"; labelDiv.style.zIndex = "5"; labelDiv.style.transform = "translate(-50%, -50%)"; (labelDiv as any).__updateLabelPos = () => { if (!ctx.contourCheck.checked) { labelDiv.style.display = "none"; return; } const proj = labelPos.clone().project(ctx.camera); const x = (proj.x * 0.5 + 0.5) * ctx.viewerArea.clientWidth; const y = (-(proj.y * 0.5) + 0.5) * ctx.viewerArea.clientHeight; if (proj.z > 1) { labelDiv.style.display = "none"; } else { labelDiv.style.display = "block"; labelDiv.style.left = `${x}px`; labelDiv.style.top = `${y}px`; } }; ctx.viewerArea.appendChild(labelDiv); ctx.labelElements.push(labelDiv); ctx.setLabelsDirty(true); } // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. [ { points: minorPoints, color: 0xf59e0b }, { points: majorPoints, color: 0xd97706 }, ].forEach(({ points, color }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, }); ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); }); if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; ctx.maxValSpan.textContent = `${nearestMax10}m`; ctx.minValSpan.textContent = `${nearestMin10}m`; ctx.legendBar.style.display = "flex"; } else { ctx.legendBar.style.display = "none"; } return true; } catch (e) { ctx.legendBar.style.display = "none"; return false; } }