auto: 2026-09-02 16:48 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user