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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user