Files
Aislo/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts
T
eomsangdonandClaude Opus 5 4cb9b15939 style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량
재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만.

- 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100)
- `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100).
  `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외
- 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경)

두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물
폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가
`core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가
실제 포맷 차이를 가리고 있었음.

검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped /
0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음).
포맷터 재실행 시 prettier·biome 모두 변경 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:08:24 +09:00

947 lines
36 KiB
TypeScript

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 { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
getTopFitDistance,
TOP_VIEW_TILT,
niceScaleDistance,
SURFACE_CAMERA_FOV,
targetPlaneMetersPerPixel,
type SurfaceCameraState,
} from "./B04_PreProcess_UI_Camera";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서
* 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */
const MAX_CONTOUR_LABELS = 40;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface SurfaceTerrainViewer {
root: HTMLElement;
/** 표시 토글(축·서피스·등고선·간격) + 상태 줄. 페이지가 "모델 표시 옵션"에 넣는다. */
optionsContent: HTMLElement;
/** 스무딩 드롭다운 한 줄. 페이지가 "지표면 분석"에 넣는다. */
smoothingField: HTMLElement;
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
setReferenceBounds: (bounds: SurfaceBounds) => void;
/** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
/** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */
setSmoothing: (enabled: boolean) => void;
getContourInterval: () => number;
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
setContourInterval: (interval: number) => void;
resetOptions: () => void;
dispose: () => void;
}
export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const root = document.createElement("div");
root.className = "terrain-model-group";
const statusSpan = document.createElement("span");
statusSpan.className = "terrain-status";
statusSpan.style.fontSize = "var(--text-caption)";
statusSpan.style.color = "var(--color-text-secondary)";
statusSpan.textContent = "모델 선택 대기 중...";
let activeFilter = "csf";
let activeMethod = "dtm";
const axesCheck = document.createElement("input");
axesCheck.type = "checkbox";
axesCheck.checked = false;
const rightControls = document.createElement("div");
rightControls.className = "viewer-options model-display-options";
// Surface Toggle
const surfLabel = document.createElement("label");
surfLabel.className = "toggle-label toggle-button";
const surfCheck = document.createElement("input");
surfCheck.type = "checkbox";
surfCheck.checked = true;
surfLabel.append(surfCheck, document.createTextNode(" 서피스"));
// 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다.
// 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시).
// 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다.
const smoothLabel = document.createElement("label");
smoothLabel.className = "b04-surface__field";
const smoothCaption = document.createElement("span");
smoothCaption.textContent = L("B04_Surface_Field_Smoothing");
const smoothSelect = document.createElement("select");
smoothSelect.className = "b04-surface__select";
(
[
["on", "B04_Surface_Smoothing_On"],
["off", "B04_Surface_Smoothing_Off"],
] as const
).forEach(([value, key]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = L(key);
smoothSelect.append(option);
});
smoothSelect.value = "on";
smoothLabel.append(smoothCaption, smoothSelect);
// Contour Toggle
const contourLabel = document.createElement("label");
contourLabel.className = "toggle-label toggle-button";
const contourCheck = document.createElement("input");
contourCheck.type = "checkbox";
contourCheck.checked = true;
contourLabel.append(contourCheck, document.createTextNode(" 등고선"));
// Contour Interval input form
const intervalForm = document.createElement("form");
intervalForm.className = "contour-interval-form";
const intervalInput = document.createElement("input");
intervalInput.type = "number";
intervalInput.value = "1.0";
intervalInput.step = "0.5";
intervalInput.min = "0.5";
intervalInput.className = "contour-interval-input";
const intervalSubmit = document.createElement("button");
intervalSubmit.type = "submit";
intervalSubmit.textContent = "적용";
intervalSubmit.className = "contour-interval-submit";
intervalForm.append(
document.createTextNode("간격 "),
intervalInput,
document.createTextNode("m "),
intervalSubmit,
);
const axesLabel = document.createElement("label");
axesLabel.className = "toggle-label toggle-button";
axesLabel.append(axesCheck, document.createTextNode(" 축"));
rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm);
// 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다.
const optionsContent = document.createElement("div");
optionsContent.className = "terrain-options-content";
optionsContent.append(rightControls, statusSpan);
// 3D View container
const viewerArea = document.createElement("div");
viewerArea.className = "three-viewer";
viewerArea.style.position = "relative";
viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)";
viewerArea.style.overflow = "hidden";
const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__canvas";
viewerArea.append(canvas);
root.append(viewerArea);
// Scale bar overlay
const scaleBar = document.createElement("div");
scaleBar.className = "b04-surface__scale";
scaleBar.hidden = true;
const scaleLabel = document.createElement("span");
scaleBar.append(scaleLabel);
viewerArea.append(scaleBar);
// Elevation bounds legend bar overlay (I-403)
const legendBar = document.createElement("div");
legendBar.style.position = "absolute";
legendBar.style.top = "16px";
legendBar.style.right = "16px";
legendBar.style.background = "rgba(255, 255, 255, 0.9)";
legendBar.style.border = "1px solid #cbd5e1";
legendBar.style.borderRadius = "6px";
legendBar.style.padding = "8px";
legendBar.style.width = "50px";
legendBar.style.display = "none"; // hidden until contours are loaded
legendBar.style.flexDirection = "column";
legendBar.style.alignItems = "center";
legendBar.style.zIndex = "10";
legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)";
legendBar.style.pointerEvents = "none";
const maxValSpan = document.createElement("span");
maxValSpan.style.fontSize = "10px";
maxValSpan.style.fontWeight = "bold";
maxValSpan.style.color = "#b91c1c";
maxValSpan.style.marginBottom = "4px";
const gradientDiv = document.createElement("div");
gradientDiv.style.width = "12px";
gradientDiv.style.height = "120px";
gradientDiv.style.background =
"linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)";
gradientDiv.style.borderRadius = "2px";
gradientDiv.style.border = "1px solid #94a3b8";
const minValSpan = document.createElement("span");
minValSpan.style.fontSize = "10px";
minValSpan.style.fontWeight = "bold";
minValSpan.style.color = "#1d4ed8";
minValSpan.style.marginTop = "4px";
legendBar.append(maxValSpan, gradientDiv, minValSpan);
viewerArea.append(legendBar);
// 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
/** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
// Three.js context variables
let currentProjectId = "";
let currentModelsList: readonly SurfaceModelSummary[] = [];
let referenceBounds: SurfaceBounds | null = null;
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
let axesVisibilityListener: ((visible: boolean) => void) | null = null;
let suppressCameraEvent = false;
let smoothPreferred = true;
let currentModelId: number | null = null;
let currentModelSmooth = false;
// 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다.
let loadGeneration = 0;
const scene = new THREE.Scene();
const releaseTheme = bindSurfaceViewerTheme((color) => {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8));
const directional = new THREE.DirectionalLight(0xffffff, 1.5);
directional.position.set(100, 180, 120);
scene.add(directional);
const axes = new THREE.AxesHelper(25);
axes.visible = axesCheck.checked;
scene.add(axes);
const contourGroup = new THREE.Group();
contourGroup.visible = contourCheck.checked;
scene.add(contourGroup);
// 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다
// (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라
// 지형을 따라 오르내리면 오히려 판단이 어렵다.
const routeGroup = new THREE.Group();
scene.add(routeGroup);
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let terrainMesh: THREE.Object3D | null = null;
const labelElements: HTMLDivElement[] = [];
// 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다.
let labelsDirty = true;
// 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸).
const releaseCursorPivot = bindCursorPivotControls({
camera,
controls,
element: renderer.domElement,
pickables: () => (terrainMesh ? [terrainMesh] : []),
scene,
});
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
renderable.geometry?.dispose();
const material = renderable.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material?.dispose();
});
}
function clearMesh() {
if (terrainMesh) {
scene.remove(terrainMesh);
disposeObject(terrainMesh);
terrainMesh = null;
}
}
// ── 겹쳐 보기 메시 ─────────────────────────────────────────────────────────
// 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다
// (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다.
let overlayMesh: THREE.Object3D | null = null;
let overlayGeneration = 0;
function clearOverlay() {
if (overlayMesh) {
scene.remove(overlayMesh);
disposeObject(overlayMesh);
overlayMesh = null;
}
}
async function loadOverlay(
projectId: string,
models: readonly SurfaceModelSummary[],
sourceFilter: string,
method: string,
smooth: boolean,
): Promise<boolean> {
const generation = ++overlayGeneration;
clearOverlay();
const match = models.find((model) => {
const configured = model.generation_params?.source_filter;
return (
model.model_type.toLowerCase() === method.toLowerCase() &&
typeof configured === "string" &&
configured.toLowerCase() === sourceFilter.toLowerCase()
);
});
if (!match) return false;
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`;
try {
const buffer = await fetchCachedBytes(projectId, url);
if (generation !== overlayGeneration) return false;
return await new Promise<boolean>((resolve) => {
new GLTFLoader().parse(
buffer,
"",
(gltf) => {
if (generation !== overlayGeneration) {
disposeObject(gltf.scene);
resolve(false);
return;
}
// 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다.
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshStandardMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.45,
side: THREE.DoubleSide,
flatShading: false,
});
}
});
overlayMesh = gltf.scene;
scene.add(gltf.scene);
resolve(true);
},
() => resolve(false),
);
});
} catch {
return false;
}
}
function clearContours() {
while (contourGroup.children.length > 0) {
const child = contourGroup.children[0];
contourGroup.remove(child);
if (child instanceof THREE.LineSegments) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
labelElements.forEach((el) => el.remove());
labelElements.length = 0;
labelsDirty = true;
legendBar.style.display = "none";
}
function drawRoute(): void {
while (routeGroup.children.length > 0) {
const child = routeGroup.children[0];
routeGroup.remove(child);
if (child instanceof THREE.Line) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
if (routePoints.length < 2 || !referenceBounds) return;
// 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y.
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2;
const planeY = referenceBounds.z_max - cz;
const vertices = routePoints.map(
(point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)),
);
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(routeLineColor()),
});
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
if (terrainMesh) fitCamera(terrainMesh);
}
const getFitParams = (object: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(object);
// 계획노선은 최고 표고 평면에 있어 지형 상자 위·밖으로 걸친다. 지형만 보고 맞추면
// 노선이 화면 밖으로 밀려 보이지 않는다 — 프레임에 같이 넣는다.
if (routeGroup.children.length > 0) box.expandByObject(routeGroup);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const span = Math.max(size.x, size.y, size.z, 1);
return { center, span };
};
/** 화면맞춤 기준 범위 — 지표면 범위에 계획노선까지 담는다.
*
* 카메라 타깃은 늘 지표면 중심(0,0,0)이라 범위를 한쪽으로만 늘려서는 소용이 없다.
* 중심에서 가장 먼 노선 정점까지를 반폭으로 잡아 **대칭으로** 넓힌다. 그러지 않으면
* 라이다가 노선의 일부만 덮을 때 나머지가 화면 밖으로 잘린다(2026-09-02 용화 실측:
* 노선 2,136m 중 라이다는 1,400m만 덮어 오른쪽이 캔버스 밖으로 나갔다). */
const fitBounds = (): SurfaceBounds | null => {
if (!referenceBounds || routePoints.length < 2) return referenceBounds;
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
let halfX = (referenceBounds.x_max - referenceBounds.x_min) / 2;
let halfY = (referenceBounds.y_max - referenceBounds.y_min) / 2;
for (const point of routePoints) {
halfX = Math.max(halfX, Math.abs(point.x - cx));
halfY = Math.max(halfY, Math.abs(point.y - cy));
}
return {
...referenceBounds,
x_min: cx - halfX,
x_max: cx + halfX,
y_min: cy - halfY,
y_max: cy + halfY,
};
};
const fitCamera = (object: THREE.Object3D) => {
const { span } = getFitParams(object);
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const bounds = fitBounds();
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
controls.target.set(0, 0, 0);
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
camera.position.set(0, distance, distance * TOP_VIEW_TILT);
camera.near = Math.max(distance / 10000, 0.01);
camera.far = distance * 100;
camera.updateProjectionMatrix();
controls.update();
};
function emitCameraState(): void {
if (suppressCameraEvent || !cameraListener) return;
const offset = camera.position.clone().sub(controls.target);
const distance = Math.max(offset.length(), 0.001);
offset.normalize();
cameraListener({
direction: [offset.x, offset.y, offset.z],
distanceMeters: distance,
targetMeters: [controls.target.x, controls.target.y, controls.target.z],
});
}
function applyCameraState(state: SurfaceCameraState): void {
suppressCameraEvent = true;
controls.target.set(...state.targetMeters);
camera.position
.set(...state.direction)
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
.add(controls.target);
camera.lookAt(controls.target);
controls.update();
suppressCameraEvent = false;
}
function syncSmoothingSupport(): void {
const supported = activeMethod === "tin" || activeMethod === "dtm";
smoothSelect.disabled = !supported;
smoothSelect.value = supported && smoothPreferred ? "on" : "off";
smoothLabel.classList.toggle("is-disabled", !supported);
smoothLabel.title = supported ? "" : L("B04_Surface_Smoothing_Unsupported");
}
/** 지금 스무딩을 적용하는 상태인가. */
function smoothingOn(): boolean {
return !smoothSelect.disabled && smoothSelect.value === "on";
}
// Load mesh and contours
async function updateSelectedModel() {
if (!currentProjectId || currentModelsList.length === 0) return;
clearMesh();
clearContours();
currentModelId = null;
scaleBar.hidden = true;
statusSpan.textContent = "모델 조회 중...";
showProgress(null, "모델 조회 중…");
// 1. Find matching model in list
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
const match = currentModelsList.find((m) => {
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const configuredFilter = m.generation_params?.source_filter;
const filterMatches =
(typeof configuredFilter === "string" &&
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
return typeMatches && filterMatches;
});
if (!match) {
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
showProgress(null, null);
return;
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
showProgress(0, "3D 메쉬 내려받는 중…");
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
try {
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(currentProjectId, previewUrl, {
onProgress: (ratio) => {
if (generation !== loadGeneration) return;
showProgress(ratio, "3D 메쉬 내려받는 중…");
},
});
if (generation !== loadGeneration) return;
if (activeMethod === "meshfree") {
const geometry = new PLYLoader().parse(buffer);
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 0.35,
vertexColors: geometry.hasAttribute("color"),
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
points.visible = surfCheck.checked;
terrainMesh = points;
scene.add(points);
fitCamera(points);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
} else {
new GLTFLoader().parse(
buffer,
"",
async (gltf) => {
if (generation !== loadGeneration) {
disposeObject(gltf.scene);
return;
}
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
}
} catch (e) {
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
showProgress(null, null);
}
}
async function loadContourLines(
modelId: number,
isSmooth: boolean,
recalculate = false,
): Promise<boolean> {
const interval = parseFloat(intervalInput.value) || 1.0;
const projectId = currentProjectId;
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
try {
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
const data = await fetchCachedJson<any>(projectId, contourUrl);
if (
currentProjectId !== projectId ||
currentModelId !== modelId ||
currentModelSmooth !== isSmooth ||
(parseFloat(intervalInput.value) || 1.0) !== interval
) {
return false;
}
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 (!contourCheck.checked) {
labelDiv.style.display = "none";
return;
}
const proj = labelPos.clone().project(camera);
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
const y = (-(proj.y * 0.5) + 0.5) * 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`;
}
};
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = 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,
});
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;
maxValSpan.textContent = `${nearestMax10}m`;
minValSpan.textContent = `${nearestMin10}m`;
legendBar.style.display = "flex";
} else {
legendBar.style.display = "none";
}
return true;
} catch (e) {
legendBar.style.display = "none";
return false;
}
}
async function loadSelectedContours(
modelId: number,
isSmooth: boolean,
recalculate = false,
): Promise<void> {
const projectId = currentProjectId;
const interval = parseFloat(intervalInput.value) || 1.0;
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
const loaded = await loadContourLines(modelId, isSmooth, recalculate);
if (
currentProjectId === projectId &&
currentModelId === modelId &&
currentModelSmooth === isSmooth &&
(parseFloat(intervalInput.value) || 1.0) === interval
) {
statusSpan.textContent = loaded
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
: "등고선 계산 또는 조회에 실패했습니다.";
}
}
// Animation render loop
let animationFrameId = 0;
let hasConnected = false;
// 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세.
const cameraMatrixSnapshot = new THREE.Matrix4();
function animate() {
if (!root.isConnected) {
if (!hasConnected) {
animationFrameId = requestAnimationFrame(animate);
} else {
cancelAnimationFrame(animationFrameId);
clearMesh();
clearContours();
releaseTheme();
releaseCursorPivot();
controls.dispose();
renderer.dispose();
}
return;
}
hasConnected = true;
controls.update();
// Render scale bar dynamically
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
const roughMeters = 100 * metersPerPixel;
const prettyMeters = niceScaleDistance(roughMeters);
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
scaleLabel.textContent =
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
if (typeof (label as any).__updateLabelPos === "function") {
(label as any).__updateLabelPos();
}
});
}
renderer.render(scene, camera);
animationFrameId = requestAnimationFrame(animate);
}
// Event Listeners
axesCheck.addEventListener("change", () => {
axes.visible = axesCheck.checked;
axesVisibilityListener?.(axesCheck.checked);
});
surfCheck.addEventListener("change", () => {
if (terrainMesh) {
terrainMesh.visible = surfCheck.checked;
}
});
smoothSelect.addEventListener("change", () => {
smoothPreferred = smoothSelect.value === "on";
updateSelectedModel();
});
contourCheck.addEventListener("change", () => {
contourGroup.visible = contourCheck.checked;
labelElements.forEach((el) => {
el.style.display = contourCheck.checked ? "block" : "none";
});
labelsDirty = true;
});
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
intervalSubmit.disabled = true;
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
});
controls.addEventListener("change", emitCameraState);
// Resize handler
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width || 800;
const height = entry.contentRect.height || 520;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
});
resizeObserver.observe(viewerArea);
animationFrameId = requestAnimationFrame(animate);
return {
root,
optionsContent,
smoothingField: smoothLabel,
render(projectId, models) {
currentProjectId = projectId;
currentModelsList = models;
updateSelectedModel();
},
setReferenceBounds(bounds) {
referenceBounds = bounds;
drawRoute();
},
setRoute(points) {
routePoints = points;
drawRoute();
},
setSelection(sourceFilter, method) {
activeFilter = sourceFilter;
activeMethod = method;
syncSmoothingSupport();
},
showOverlay(sourceFilter, method, smooth) {
if (!sourceFilter) {
clearOverlay();
return Promise.resolve(false);
}
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
},
applyCameraState,
onCameraChange(listener) {
cameraListener = listener;
},
onAxesVisibilityChange(listener) {
axesVisibilityListener = listener;
},
isSmoothingEnabled() {
return smoothingOn();
},
setSmoothing(enabled) {
smoothPreferred = enabled;
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
},
resetOptions() {
axesCheck.checked = false;
axes.visible = false;
axesVisibilityListener?.(false);
surfCheck.checked = true;
smoothPreferred = true;
syncSmoothingSupport();
contourCheck.checked = true;
contourGroup.visible = true;
intervalInput.value = "1.0";
if (terrainMesh) terrainMesh.visible = true;
void updateSelectedModel();
},
dispose() {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
releaseTheme();
releaseCursorPivot();
clearMesh();
clearContours();
controls.dispose();
renderer.dispose();
},
};
}