auto: 2026-09-02 16:48 (EOMSANGDON-HOME)

This commit is contained in:
2026-09-02 16:48:22 +09:00
parent b4c7a7eedb
commit 397a135073
192 changed files with 6816 additions and 6014 deletions
@@ -8,10 +8,7 @@ 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 type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
@@ -43,11 +40,7 @@ export interface SurfaceTerrainViewer {
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (
sourceFilter: string,
method: string,
smooth: boolean,
) => Promise<boolean>;
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;
@@ -242,12 +235,7 @@ 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));
@@ -291,8 +279,7 @@ 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());
@@ -414,9 +401,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(routeLineColor()),
});
routeGroup.add(
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
);
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
if (terrainMesh) fitCamera(terrainMesh);
}
@@ -432,13 +417,36 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
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 distance = referenceBounds
? getTopFitDistance(referenceBounds, aspect)
: span * 1.2;
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);
@@ -500,15 +508,12 @@ 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;
});
@@ -519,8 +524,7 @@ 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;
@@ -567,8 +571,7 @@ 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;
@@ -581,8 +584,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent =
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
@@ -793,26 +795,18 @@ 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) => {
@@ -854,8 +848,7 @@ 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;
@@ -904,13 +897,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
clearOverlay();
return Promise.resolve(false);
}
return loadOverlay(
currentProjectId,
currentModelsList,
sourceFilter,
method,
smooth,
);
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
},
applyCameraState,
onCameraChange(listener) {
@@ -927,8 +914,7 @@ 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);