fix(B04): 도엽 서피스를 2D 거리보간으로 만든다 — TIN 제거

메시(TIN)로 먼저 만들고 거기서 등고선을 뽑던 순서가 계단의 원인이었다.
사용자 지시대로 2D를 먼저 완결한다:
- Delaunay TIN 제거. 표고별 거리장에서 각 셀의 가장 가까운 두 '서로 다른'
  등고 라인을 찾아 z=(L1·d2+L2·d1)/(d1+d2)로 보간한다. 같은 표고 정점
  3개짜리 평탄 삼각형이 없으므로 계단이 원리적으로 생기지 않는다.
- 폐합 등고선 안쪽은 거리보간이 분화구를 만들므로, 안에 다른 제약이 없는
  영역을 마루/웅덩이로 보고 바깥 사면 경사로 ±(간격-0.5m)까지 연장한다.
- 계곡 구조선 앵커는 1m 양자화해 같은 격자에 제약으로 굽는다.
- 등고 라벨 40개 상한(긴 것부터) — 1m 등고선이 잘게 쪼개져 라벨 수백 개가
  매 프레임 재계산되며 화면이 멈췄다(사용자 보고). 61 FPS 회복.

실측(c1bb453f): 평탄 셀 2.48%→0.02%, 최대 평탄 덩어리 2406→912㎡,
경사 2% 미만 셀 17145→14444, 생성 36.5s→8.0s, 노선 |Δz| 2.76m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 15:33:56 +09:00
co-authored by Claude Opus 5
parent cb1c5261e2
commit 31e3eb4ecf
2 changed files with 245 additions and 249 deletions
+105 -52
View File
@@ -6,7 +6,10 @@ 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 type {
SurfaceBounds,
SurfaceModelSummary,
} from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
@@ -18,6 +21,10 @@ import {
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];
}
@@ -225,7 +232,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
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));
@@ -262,7 +274,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
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());
@@ -303,8 +316,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
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;
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);
@@ -366,12 +382,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// 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 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()));
Boolean(
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
);
return typeMatches && filterMatches;
});
@@ -382,7 +401,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
const isSmooth =
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -429,7 +449,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
child.material.vertexColors =
child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
@@ -442,7 +463,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
statusSpan.textContent =
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
@@ -498,6 +520,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 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;
@@ -512,47 +539,63 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
bucket.push(points[i], points[i + 1]);
}
// 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다
// 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다
// (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다.
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;
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 },
@@ -632,18 +675,26 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
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`;
prettyMeters >= 1000
? `${(prettyMeters / 1000).toFixed(0)} km`
: `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
if (
labelsDirty ||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
@@ -685,7 +736,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
return;
intervalSubmit.disabled = true;
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
@@ -739,7 +791,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
if (Number.isFinite(interval) && interval > 0)
intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);