feat(B04): 계획노선을 3D 최고 표고 평면에 그리고, 지표면 밖 구간은 잘라 낸다

실무 자료 용화.las가 계획노선 2,136m 중 일부만 덮는다. 좌표 문제가 아니라 측량
범위 자체다 — LAS(VLR EPSG 5176)와 정사영상 용화.tif 범위가 서로 일치하고, 위경도로
datum 보정까지 태워도 위도는 완전히 포함되며 경도만 동쪽 431m 초과한다.

노선 3D 표시
- 계획노선을 지표면에 드리우지 않고 데이터 최고 표고(bounds.z_max) 평면에 수평으로
  얹는다. 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라 지형을 따라
  오르내리면 오히려 판단이 어렵다.
- 색은 2D 지도·B05 배수유역도가 쓰는 routeLineColor()를 그대로 쓴다. 같은 선을 두
  화면에서 다른 색으로 그리면 같은 것인지 알아볼 수 없다.

노선 트림
- trim_route_to_surface(): DtmGridSampler 의 valid_mask 로 판정한다. bounds 사각형이
  아니라 불규칙한 실제 외곽이다. 가장 긴 연속 유효 구간을 남긴다.
- 가장자리 여유 SURFACE_ROUTE_EDGE_TRIM_M(30m)은 잘라 낸 쪽 끝에만 적용한다. 노선
  본래 끝점이 지표면 안이면 깎지 않는다.
- 자르는 자리는 _planned_route_points_in_project_crs() 한 곳이다. 체인의 BP·EP·CP가
  전부 이 함수를 지나므로 여기서 한 번 자르면 하류가 모두 유효해진다.
- 지표면을 못 열면 자르지 않는다. 트림 실패가 설계를 막으면 안 된다.

실측(용화 노선 2,136m):
  csf/dtm/smooth            -> 1,310m (61%)
  classification/dtm/smooth -> 1,070m (50%)
bounds 사각형 기준 추정치 1,400m보다 짧다 — 실제 외곽이 사각형보다 작기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:46:48 +09:00
co-authored by Claude Opus 5
parent e588c73aa7
commit 8ac892d97d
5 changed files with 173 additions and 7 deletions
+6
View File
@@ -26,6 +26,7 @@ import {
analyzeSurface,
confirmSurfaceModel,
fetchConfirmedSurface,
fetchPlannedRoute,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
@@ -536,6 +537,11 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
// 계획노선을 3D 최고 표고 평면에도 얹는다 — 라이다가 노선을 어디까지 덮는지
// 평면상으로 바로 보인다(2026-09-01 사용자 지시).
void fetchPlannedRoute(projectId)
.then((route) => terrainViewer.setRoute(route.points ?? []))
.catch(() => terrainViewer.setRoute([]));
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
mapViewer.render(projectId, confirmed.route_bounds);
} catch {
@@ -6,6 +6,8 @@ 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";
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type {
SurfaceBounds,
SurfaceModelSummary,
@@ -37,6 +39,8 @@ export interface SurfaceTerrainViewer {
smoothingField: HTMLElement;
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
setReferenceBounds: (bounds: SurfaceBounds) => void;
/** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (
@@ -265,6 +269,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
contourGroup.visible = contourCheck.checked;
scene.add(contourGroup);
// 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다
// (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라
// 지형을 따라 오르내리면 오히려 판단이 어렵다.
const routeGroup = new THREE.Group();
scene.add(routeGroup);
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let terrainMesh: THREE.Object3D | null = null;
const labelElements: HTMLDivElement[] = [];
// 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다.
@@ -382,6 +393,32 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
legendBar.style.display = "none";
}
function drawRoute(): void {
while (routeGroup.children.length > 0) {
const child = routeGroup.children[0];
routeGroup.remove(child);
if (child instanceof THREE.Line) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
if (routePoints.length < 2 || !referenceBounds) return;
// 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y.
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2;
const planeY = referenceBounds.z_max - cz;
const vertices = routePoints.map(
(point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)),
);
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(routeLineColor()),
});
routeGroup.add(
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
);
}
const getFitParams = (object: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(object);
const center = box.getCenter(new THREE.Vector3());
@@ -846,6 +883,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
setReferenceBounds(bounds) {
referenceBounds = bounds;
drawRoute();
},
setRoute(points) {
routePoints = points;
drawRoute();
},
setSelection(sourceFilter, method) {
activeFilter = sourceFilter;