From 8ac892d97dea50b1753b2288a93d3ef69290a1b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 1 Sep 2026 13:46:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9D=84=203D=20=EC=B5=9C=EA=B3=A0=20=ED=91=9C?= =?UTF-8?q?=EA=B3=A0=20=ED=8F=89=EB=A9=B4=EC=97=90=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0,=20=EC=A7=80=ED=91=9C=EB=A9=B4=20=EB=B0=96=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=EC=9D=80=20=EC=9E=98=EB=9D=BC=20=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 자료 용화.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) --- B03_FileInput/B03_FileInput_Service_Chain.py | 45 ++++++++-- B04_PreProcess/B04_PreProcess_UI_Page.ts | 6 ++ .../B04_PreProcess_UI_TerrainViewer.ts | 42 ++++++++++ common_util/common_util_route_geometry.py | 82 +++++++++++++++++++ config/config_system.py | 5 ++ 5 files changed, 173 insertions(+), 7 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index b7b69883..8b21e1af 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -25,16 +25,23 @@ from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) -def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, float]] | None: +def _planned_route_points_in_project_crs( + project_root: Path, surface: dict[str, Any] | None = None +) -> list[dict[str, float]] | None: """계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고 프로젝트 좌표계와 다르면 한 번 옮긴다. + + `surface`(확정 필터·방식·스무딩)를 받으면 지표면이 덮지 못하는 구간을 잘라 낸다. + 라이다 측량이 노선 전 구간을 덮지 않는 현장이 있다(용화: 2,136m 중 1,400m). + 자르지 않으면 체인이 서피스 밖 정점에서 끊긴다. """ from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj from common_util.common_util_route_geometry import ( find_planned_route_file, read_planned_route_csv, + trim_route_to_surface, ) route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") @@ -49,6 +56,28 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) points = [transformer.transform(x, y) for x, y in points] + + if surface: + from common_util.common_util_surface_sampler import build_surface_sampler + + try: + sampler = build_surface_sampler( + project_root / "B04_PreProcess" / "models", + str(surface["source_filter"]), + str(surface["method"]), + bool(surface["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError) as exc: + logger.warning("자동 설계 체인 노선 트림 건너뜀 — 지표면을 열지 못했습니다: %s", exc) + else: + points = trim_route_to_surface(points, sampler) + if len(points) < 2: + logger.warning( + "자동 설계 체인 중단(노선이 지표면 밖): 계획노선과 라이다 측량 범위가" + " 겹치지 않습니다 — %s", + project_root.name, + ) + return None return [{"x": x, "y": y} for x, y in points] @@ -172,16 +201,18 @@ async def run_auto_design_chain( # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) - points = _planned_route_points_in_project_crs(project_root) + # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 + # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 + # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. + async with pool.acquire() as connection: + defaults = await get_surface_confirmation_params(connection, str(project_id)) + + points = _planned_route_points_in_project_crs(project_root, defaults) if not points: logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) return None - # 3) B05 경로 계산 — WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. - # config 기본값(csf/dtm)을 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 - # 가리켜 404로 체인이 끊긴다(2026-08-30 실사고). - async with pool.acquire() as connection: - defaults = await get_surface_confirmation_params(connection, str(project_id)) + # 3) B05 경로 계산 request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), method=str(defaults["method"]), diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 9b730325..f3154c41 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -26,6 +26,7 @@ import { analyzeSurface, confirmSurfaceModel, fetchConfirmedSurface, + fetchPlannedRoute, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, @@ -536,6 +537,11 @@ export async function renderB04Surface(root: HTMLElement): Promise { ); 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 { diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 8f1d8fa3..2369e528 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -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; diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 22913bcd..5297b7e6 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -20,6 +20,8 @@ from typing import Any from shapely.geometry import LineString, Point, shape +from config.config_system import SURFACE_ROUTE_EDGE_TRIM_M + logger = logging.getLogger(__name__) # 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다. @@ -122,6 +124,86 @@ def find_planned_route_file(input_dir: Path) -> Path | None: return candidates[0] if candidates else None +def trim_route_to_surface( + points: list[tuple[float, float]], + sampler: Any, + edge_trim_m: float = SURFACE_ROUTE_EDGE_TRIM_M, +) -> list[tuple[float, float]]: + """지표면이 덮는 구간만 남기고 계획노선을 자른다. + + 판정은 sampler가 돌려주는 `valid` — 확정 DTM의 valid_mask가 곧 불규칙한 실제 + 외곽이다(bounds 사각형이 아니다). 가장 긴 연속 유효 구간을 남긴다. + + `edge_trim_m`은 **잘라 낸 쪽 끝에만** 적용한다. 서피스 가장자리는 점 밀도가 떨어져 + 지반고가 못 미덥기 때문이다. 노선 본래 끝점이 서피스 안이면 깎지 않는다 — 멀쩡한 + 구간을 짧게 만들 이유가 없다 (2026-09-01 사용자 확정). + + 전부 유효하면 입력을 그대로 돌려준다. 남는 구간이 2점 미만이면 빈 목록. + """ + import numpy as np + + if len(points) < 2: + return list(points) + xy = np.asarray(points, dtype=np.float64) + try: + _, valid = sampler.sample_xy(xy) + except (ValueError, OSError) as exc: + logger.warning("노선 트림: 지표면 샘플링 실패 — %s", exc) + return list(points) + valid = np.asarray(valid, dtype=bool) + if valid.all(): + return list(points) + if not valid.any(): + logger.warning("노선 트림: 노선 전체가 지표면 밖입니다.") + return [] + + # 가장 긴 연속 유효 구간 — 가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다. + best_start = best_end = start = -1 + for index, ok in enumerate([*valid.tolist(), False]): + if ok and start < 0: + start = index + elif not ok and start >= 0: + if index - start > best_end - best_start: + best_start, best_end = start, index + start = -1 + kept = [(float(x), float(y)) for x, y in xy[best_start:best_end]] + + trim_head = best_start > 0 + trim_tail = best_end < len(xy) + kept = _trim_ends(kept, edge_trim_m if trim_head else 0.0, edge_trim_m if trim_tail else 0.0) + logger.info( + "노선 트림: 정점 %d개 → %d개 (지표면 밖 %d개, 가장자리 여유 %.0fm %s)", + len(points), + len(kept), + int((~valid).sum()), + edge_trim_m, + "앞뒤" if trim_head and trim_tail else ("앞" if trim_head else "뒤"), + ) + return kept + + +def _trim_ends( + points: list[tuple[float, float]], head_m: float, tail_m: float +) -> list[tuple[float, float]]: + """폴리라인 앞뒤에서 지정 길이만큼 잘라 낸다. 남는 게 2점 미만이면 빈 목록.""" + if len(points) < 2 or (head_m <= 0 and tail_m <= 0): + return points + line = LineString(points) + start = min(head_m, line.length) + end = max(start, line.length - tail_m) + if end - start <= 0: + logger.warning("노선 트림: 여유를 깎고 나니 남는 구간이 없습니다.") + return [] + cumulative = 0.0 + kept: list[tuple[float, float]] = [line.interpolate(start).coords[0]] + for index in range(1, len(points)): + cumulative += math.dist(points[index - 1], points[index]) + if start < cumulative < end: + kept.append(points[index]) + kept.append(line.interpolate(end).coords[0]) + return kept if len(kept) >= 2 else [] + + def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" vertices: list[RouteVertex] = [] diff --git a/config/config_system.py b/config/config_system.py index a0673e08..67750843 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -137,6 +137,11 @@ SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( # 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) +# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). +# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 +# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). +SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) + # ───────────────────────────────────────────────────────────────────────── # 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) # ─────────────────────────────────────────────────────────────────────────