refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
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";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
getTopFitDistance,
|
||||
niceScaleDistance,
|
||||
SURFACE_CAMERA_FOV,
|
||||
targetPlaneMetersPerPixel,
|
||||
type SurfaceCameraState,
|
||||
} from "./B04_PreProcess_UI_Camera";
|
||||
|
||||
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;
|
||||
setSelection: (sourceFilter: string, method: string) => void;
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
const getFitParams = (object: THREE.Object3D) => {
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
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);
|
||||
camera.position.set(0, distance, 0.001);
|
||||
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[] = [];
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
if (isMajor && points.length > 4) {
|
||||
const labelPos = points[Math.floor(points.length / 2)];
|
||||
const labelDiv = document.createElement("div");
|
||||
labelDiv.className = "contour-label";
|
||||
labelDiv.innerText = `${Math.round(c.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;
|
||||
},
|
||||
setSelection(sourceFilter, method) {
|
||||
activeFilter = sourceFilter;
|
||||
activeMethod = method;
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user