계획노선은 최고 표고 평면에 있어 지형 상자 위로 걸친다. 지형만 보고 카메라를 맞추면 노선이 프레임 밖으로 밀려 보이지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
961 lines
34 KiB
TypeScript
961 lines
34 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 };
|
|
};
|
|
|
|
const fitCamera = (object: THREE.Object3D) => {
|
|
const { span } = getFitParams(object);
|
|
const aspect =
|
|
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
|
const distance = referenceBounds
|
|
? getTopFitDistance(referenceBounds, 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();
|
|
},
|
|
};
|
|
}
|