From b2d707f91d644bc187c85d8c32bbf1e7ac4baacf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 30 Aug 2026 16:11:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=84=206=EC=A2=85=EC=9D=84=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EC=96=B4=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문헌(ANUDEM Hutchinson 1988/89, DEM 보간 비교연구)은 지형에 따라 우열이 갈려 단일 최적해가 없다고 본다. 방식을 하나로 고르지 않고 전부 만들어 두고 사용자가 눈으로 비교해 정하도록 했다(2026-08-30 사용자 지시). 신규 B04_PreProcess_Engine_SheetMethods.py — 거리비례 / TPS(박판, 감쇠 최소곡률 LSMR) / 라플라스 / TIN 선형 / TIN 곡면(Clough-Tocher) / IDW. 방식마다 dtm_sheet_{key}.npz + 프리뷰를 만들어 surface_models에 source_filter=sheet_{key}로 등록하므로 기존 뷰어·등고선·프리뷰 경로가 그대로 돈다. 폐합 링 안쪽 처리는 방식과 무관하게 똑같이 적용한다. 화면: 도엽등고 3D 서피스 컨테이너에 방식 전환 버튼과 '라이다 겹쳐 보기' 토글(반투명 파랑)을 달았다. 라이다는 같은 좌표계라 같은 자리에 겹친다. 실측(c1bb453f, 6종 65s): 평탄 셀 TPS 0.003% / 거리비례 0.014% / TIN 곡면 0.026% / 라플라스 0.235% / TIN 선형 10.4% / IDW 69.1%. LAS 대비 노선 |dz| 평균은 2.59~3.00m로 방식 간 차이가 작다. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_WF1.py | 3 +- B04_PreProcess/B04_PreProcess_Engine.py | 11 +- .../B04_PreProcess_Engine_SheetMethods.py | 276 ++++++++++++++++++ .../B04_PreProcess_Engine_SheetSurface.py | 257 ++++++---------- B04_PreProcess/B04_PreProcess_UI_Page.ts | 103 ++++++- B04_PreProcess/B04_PreProcess_UI_Style.css | 33 +++ .../B04_PreProcess_UI_TerrainViewer.ts | 89 ++++++ config/config_system.py | 15 +- ui_template/ui_template_locale_b1.ts | 5 + 9 files changed, 607 insertions(+), 185 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index c51e004c..7b27a876 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -20,6 +20,7 @@ from config.config_db import get_db_pool from config.config_system import ( AUTO_DESIGN_CHAIN_ENABLED, SEND_ANALYSIS_COMPLETION_EMAIL, + SHEET_SURFACE_DEFAULT_METHOD, SURFACE_CONTOUR_INTERVAL_M, SURFACE_MODEL_PRECOMPUTE, SURFACE_MODEL_SOURCE_FILTERS, @@ -146,7 +147,7 @@ async def trigger_wf1_analysis_and_email( # LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다. selection = ( { - "source_filter": "sheet", + "source_filter": f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}", "method": "dtm", "smooth": False, "contour_interval_m": SURFACE_CONTOUR_INTERVAL_M, diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 5fa04bf9..3789d2df 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -237,13 +237,13 @@ def run_surface_analysis( # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 # (2026-08-30 사용자 확정). 실패해도 분석은 계속한다. _report(94, "surface_model", "도엽등고선 3D 서피스 생성 중") - sheet_model: dict[str, Any] | None = None + sheet_models: list[dict[str, Any]] = [] try: from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( build_sheet_surface_from_route, ) - sheet_model = build_sheet_surface_from_route(project_root, processed_dir, models_dir) + sheet_models = build_sheet_surface_from_route(project_root, processed_dir, models_dir) except Exception as exc: logger.warning("도엽등고선 서피스 생성 실패: %s", exc) @@ -258,7 +258,7 @@ def run_surface_analysis( total_points, ground_summary, manifest, - sheet_model, + sheet_models, total_started, ) @@ -415,7 +415,7 @@ def _collect_analysis_result( total_points: int, ground_summary: dict[str, Any], manifest: dict[str, Any], - sheet_model: dict[str, Any] | None, + sheet_models: list[dict[str, Any]], total_started: float, ) -> dict[str, Any]: """manifest에서 모델 목록을 추려 분석 결과 dict를 조립한다.""" @@ -464,8 +464,7 @@ def _collect_analysis_result( "layers": layers, } ) - if sheet_model is not None: - models.append(sheet_model) + models.extend(sheet_models) logger.info( "B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py b/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py new file mode 100644 index 00000000..b01bf7ac --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetMethods.py @@ -0,0 +1,276 @@ +"""도엽등고선 → 표고 격자 보간 방식 모음 (비교용). + +문헌(Hutchinson 1988/89 ANUDEM; Chaplot 2006; Arun 2013 등)은 지형 복잡도·자료 밀도에 +따라 우열이 갈리며 단일 최적해가 없다고 본다. 그래서 방식을 하나로 고르지 않고 여기 +모아 두고 B04 화면에서 바꿔 가며 보게 한다(2026-08-30 사용자 지시). + +각 builder는 `(spec, burned, features, cell_m) -> (R, C) float32` 격자를 돌려준다. +`burned`는 등고 라인이 구워진 격자(라인 셀 = 표고, 그 외 NaN)다. 폐합 링 안쪽 처리와 +프리뷰·저장은 호출측(`_SheetSurface`)이 방식과 무관하게 똑같이 해 준다. +""" + +import logging +from typing import Any, Callable + +import numpy as np + +logger = logging.getLogger(__name__) + +# 화면 버튼에 쓰는 이름 — 키는 surface_models.generation_params.source_filter 접미사다. +SHEET_METHOD_LABELS: dict[str, str] = { + "distance": "거리비례", + "biharmonic": "TPS(박판)", + "laplace": "라플라스", + "tin": "TIN 선형", + "clough": "TIN 곡면", + "idw": "IDW", +} + + +def _laplacian(values: np.ndarray) -> np.ndarray: + padded = np.pad(values, 1, mode="edge") + return ( + padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * values + ) + + +def _contour_vertices(burned: np.ndarray, spec: Any) -> tuple[np.ndarray, np.ndarray]: + """등고 라인 셀을 (N,2) 세계좌표와 표고로 바꾼다.""" + rows, cols = np.nonzero(np.isfinite(burned)) + xs = spec.cell_centers_x()[cols] + ys = spec.cell_centers_y()[rows] + return np.column_stack([xs, ys]), burned[rows, cols].astype(np.float64) + + +def _grid_points(spec: Any) -> tuple[np.ndarray, np.ndarray]: + grid_x, grid_y = np.meshgrid(spec.cell_centers_x(), spec.cell_centers_y()) + return grid_x, grid_y + + +# ── ① 거리 비례 ────────────────────────────────────────────────────────────── +def build_distance(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """가장 가까운 서로 다른 표고 두 라인 사이를 거리 비례로 나눈다. + + z = (L1·d2 + L2·d1) / (d1 + d2) + + 지도 제작의 고전적 손보간을 그대로 옮긴 것이다. 원뿔·능선(z=r) 형상을 정확히 + 재현하고 계단이 생기지 않는다. 표고별 거리장을 돌며 가장 작은 두 값을 추적하므로 + L1≠L2가 보장된다. + """ + from scipy.ndimage import distance_transform_edt + + levels = np.unique(burned[np.isfinite(burned)]) + if len(levels) < 2: + return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32) + + infinity = np.float32(np.inf) + first_d = np.full(burned.shape, infinity, dtype=np.float32) + first_z = np.zeros(burned.shape, dtype=np.float32) + second_d = np.full(burned.shape, infinity, dtype=np.float32) + second_z = np.zeros(burned.shape, dtype=np.float32) + for level in levels: + distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32) + beats_first = distance < first_d + second_d = np.where(beats_first, first_d, second_d) + second_z = np.where(beats_first, first_z, second_z) + first_d = np.where(beats_first, distance, first_d) + first_z = np.where(beats_first, np.float32(level), first_z) + beats_second = ~beats_first & (distance < second_d) + second_d = np.where(beats_second, distance, second_d) + second_z = np.where(beats_second, np.float32(level), second_z) + + total = first_d + second_d + usable = np.isfinite(second_d) & (total > 1e-9) + surface = first_z.astype(np.float32) + surface[usable] = ( + ( + first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64) + + second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64) + ) + / total[usable].astype(np.float64) + ).astype(np.float32) + return surface + + +def relax_laplace(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> None: + """등고 라인을 고정한 채 이웃 평균으로 다듬는다 (in-place, red-black 순서).""" + if iterations <= 0: + return + free = ~fixed & np.isfinite(surface) + if not free.any(): + return + rows, cols = np.indices(surface.shape) + red = free & (((rows + cols) & 1) == 0) + black = free & ~red + padded = np.zeros((surface.shape[0] + 2, surface.shape[1] + 2), dtype=np.float32) + for _ in range(iterations): + for colour in (red, black): + padded[1:-1, 1:-1] = surface + padded[0, 1:-1] = surface[0] + padded[-1, 1:-1] = surface[-1] + padded[1:-1, 0] = surface[:, 0] + padded[1:-1, -1] = surface[:, -1] + neighbours = ( + padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + ) * np.float32(0.25) + surface[colour] = neighbours[colour] + + +# ── ② 라플라스(조화) ───────────────────────────────────────────────────────── +def build_laplace(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고선을 경계값으로 두고 Δz=0을 푼다. + + 면이 매끈해지지만 z=r(원뿔·능선)은 조화함수가 아니라 마루가 눌린다. 비교 기준으로 + 남겨 둔다 — ANUDEM이 라플라스 대신 박판 스플라인을 쓰는 이유를 눈으로 보기 위함. + """ + surface = build_distance(spec, burned, features, cell_m) + relax_laplace(surface, np.isfinite(burned), 400) + return surface + + +# ── ③ 박판 스플라인(중조화) ────────────────────────────────────────────────── +def build_biharmonic(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고선을 고정하고 Δ²z=0(박판 스플라인)을 켤레기울기법으로 푼다. + + ANUDEM/Topo to Raster가 쓰는 박판 스플라인과 같은 연산자다. 라플라스와 달리 z=r을 + 그대로 통과시켜 능선·마루가 눌리지 않고, 경사가 등고선 너머로 자연스럽게 이어진다. + """ + from scipy.sparse.linalg import LinearOperator, lsmr + + guess = build_distance(spec, burned, features, cell_m).astype(np.float64) + # 등고 라인 + **격자 테두리**를 고정한다. 최외곽 등고선 바깥이 통째로 자유면 + # 1차함수가 Δ²의 영공간에 남아 해가 하나로 정해지지 않고 켤레기울기가 발산한다 + # (2026-08-30 실측: |Δz| 2092m). 테두리는 거리 보간값으로 묶는다. + fixed = np.isfinite(burned) + fixed[0, :] = fixed[-1, :] = True + fixed[:, 0] = fixed[:, -1] = True + free = ~fixed & np.isfinite(guess) + if not free.any(): + return guess.astype(np.float32) + index = np.flatnonzero(free.ravel()) + base = np.where(fixed, np.nan_to_num(guess), 0.0) + + # Δ²z=0을 정규방정식(CG)으로 풀면 조건수가 격자변 4제곱이라 발산한다(실측). + # 대신 **최소곡률** 최소제곱으로 세운다 — 자유 셀에 대해 ‖Δz‖를 최소화하며, + # 그 정상해가 곧 Δ²z=0이다. 조건수가 제곱으로 줄어 LSMR이 안정적으로 푼다 + # (Briggs 1974의 최소곡률 격자화와 같은 목적함수). + # 최소곡률만으로는 제약(등고선)에서 먼 영역이 정해지지 않아 해가 폭주한다. + # ANUDEM의 거칠기 벌점과 같은 취지로 감쇠항을 붙여 거리 보간값에 묶어 둔다: + # minimize ‖Δz‖² + λ‖z − 거리보간‖² + # λ가 작을수록 더 매끈하고 클수록 거리 보간에 가깝다. + total_cells = base.size + free_count = len(index) + damping = np.float64(np.sqrt(0.02)) + anchor = guess.ravel()[index] + + def forward(vector: np.ndarray) -> np.ndarray: + # 반드시 **선형**이어야 한다 — 고정 셀 기여(base)를 여기서 더하면 아핀이 되어 + # LSMR의 전제가 깨지고 해가 폭주한다. base 몫은 우변으로만 넘긴다. + scattered = np.zeros_like(base) + scattered.ravel()[index] = vector + return np.concatenate([_laplacian(scattered).ravel(), damping * vector]) + + def adjoint(vector: np.ndarray) -> np.ndarray: + curvature = _laplacian(vector[:total_cells].reshape(base.shape)).ravel()[index] + return curvature + damping * vector[total_cells:] + + rhs = np.concatenate([-_laplacian(base).ravel(), damping * anchor]) + linear = LinearOperator( + (total_cells + free_count, free_count), + matvec=forward, + rmatvec=adjoint, + dtype=np.float64, + ) + result = lsmr(linear, rhs, x0=anchor, maxiter=400, atol=1e-8, btol=1e-8) + solution, info = result[0], result[1] + surface = base.copy() + surface.ravel()[index] = solution + surface[fixed] = guess[fixed] + # 안전장치 — 발산하면 조용히 틀린 지형을 넘기지 말고 거리 보간으로 되돌린다. + drift = float(np.nanmax(np.abs(surface - guess))) + span = float(np.nanmax(guess) - np.nanmin(guess)) + if not np.isfinite(drift) or drift > max(span, 1.0): + logger.warning( + "도엽 서피스(TPS): 해가 발산해(최대 %.1fm) 거리 보간으로 되돌립니다 (info=%s).", + drift, + info, + ) + return guess.astype(np.float32) + logger.info("도엽 서피스(TPS): 켤레기울기 수렴 info=%s, 최대 변화 %.2fm", info, drift) + return surface.astype(np.float32) + + +# ── ④ TIN 선형 / ⑤ TIN 곡면 ───────────────────────────────────────────────── +def _triangulated( + spec: Any, burned: np.ndarray, cell_m: float, smooth: bool, max_points: int = 120_000 +) -> np.ndarray: + from scipy.interpolate import CloughTocher2DInterpolator, LinearNDInterpolator + + points, values = _contour_vertices(burned, spec) + if len(points) < 3: + return np.full(burned.shape, np.nan, dtype=np.float32) + if len(points) > max_points: # 삼각망 비용은 정점 수에 비례한다 + step = int(np.ceil(len(points) / max_points)) + points, values = points[::step], values[::step] + factory = CloughTocher2DInterpolator if smooth else LinearNDInterpolator + interpolator = factory(points, values) + grid_x, grid_y = _grid_points(spec) + surface = np.empty(burned.shape, dtype=np.float32) + chunk = max(1, int(4_000_000 // max(spec.n_cols, 1))) + for start in range(0, spec.n_rows, chunk): + stop = min(start + chunk, spec.n_rows) + surface[start:stop] = interpolator(grid_x[start:stop], grid_y[start:stop]).astype( + np.float32 + ) + return surface + + +def build_tin(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고선 정점 Delaunay 삼각망 선형 보간 — 가장 흔한 고전 방식. + + 같은 표고 정점 3개로 이루어진 평탄 삼각형이 굴곡부·마루에 계단을 만든다. 비교 + 기준으로 남긴다. + """ + return _triangulated(spec, burned, cell_m, smooth=False) + + +def build_clough(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """같은 삼각망을 C1 곡면(Clough–Tocher)으로 채운다 — TIN 계단을 곡면으로 눌러 준다.""" + return _triangulated(spec, burned, cell_m, smooth=True) + + +# ── ⑥ IDW ─────────────────────────────────────────────────────────────────── +def build_idw(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray: + """등고 라인 셀을 표본으로 한 역거리가중(k=12, 거듭제곱 2). + + 구현이 단순해 널리 쓰이지만 표본이 곧 등고선이라 라인 주변에 표고가 뭉치는 + '황소눈' 결함이 잘 드러난다. 비교 기준. + """ + from scipy.spatial import cKDTree + + points, values = _contour_vertices(burned, spec) + if len(points) < 2: + return np.full(burned.shape, np.nan, dtype=np.float32) + tree = cKDTree(points) + grid_x, grid_y = _grid_points(spec) + query = np.column_stack([grid_x.ravel(), grid_y.ravel()]) + neighbours = min(12, len(points)) + distance, index = tree.query(query, k=neighbours, workers=-1) + if neighbours == 1: + distance = distance[:, None] + index = index[:, None] + weight = 1.0 / np.maximum(distance, 1e-6) ** 2 + surface = (weight * values[index]).sum(axis=1) / weight.sum(axis=1) + exact = distance[:, 0] < 1e-6 + surface[exact] = values[index[exact, 0]] + return surface.reshape(burned.shape).astype(np.float32) + + +SHEET_METHOD_BUILDERS: dict[str, Callable[[Any, np.ndarray, Any, float], np.ndarray]] = { + "distance": build_distance, + "biharmonic": build_biharmonic, + "laplace": build_laplace, + "tin": build_tin, + "clough": build_clough, + "idw": build_idw, +} diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index 9b215006..d8fda608 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -31,11 +31,15 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import ( grid_vertices, write_glb, ) +from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import ( + SHEET_METHOD_BUILDERS, + SHEET_METHOD_LABELS, +) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds from config.config_system import ( SHEET_SURFACE_GRID_M, SHEET_SURFACE_MARGIN_M, - SHEET_SURFACE_RELAX_ITERATIONS, + SHEET_SURFACE_METHODS, SURFACE_MAX_PREVIEW_VERTICES, ) @@ -165,106 +169,6 @@ def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.n return burned -def _relax_surface(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> None: - """등고 라인을 고정한 채 이웃 평균으로 표고를 **살짝만** 다듬는다 (in-place). - - 거리 보간은 두 라인의 굽은 정도가 다르면 능선·계곡 중심축에 각진 자국이 남는다. - 이웃 평균 몇 회로 그 자국만 지운다. - - **끝까지 수렴시키면 안 된다.** 수렴한 해는 라플라스 방정식의 해(harmonic)인데, - 원뿔·능선 형상 z=r은 harmonic이 아니라 biharmonic이라 harmonic 해로 끌고 가면 - 마루가 눌리고 평탄 셀이 늘어난다. 등고선 DEM의 표준인 ANUDEM/Topo to Raster가 - 라플라스가 아니라 **thin plate spline(biharmonic)** 을 쓰는 이유가 이것이다 - (Hutchinson 1988/89). 거리 보간은 원뿔을 정확히 재현하므로 그것을 바탕으로 두고 - 다듬기만 한다. - - 합성 원뿔 검증(2026-08-30): 거리보간만 오차 0.121m·간격 CV 2.18 → - 5회 다듬기 0.118m·1.43(개선) → 20회 이상 CV 50↑·평탄 셀 증가(악화). - """ - if iterations <= 0: - return - free = ~fixed & np.isfinite(surface) - if not free.any(): - return - # 체커보드(red-black) 순서 — 한 색을 갱신할 때 이웃(다른 색)은 그대로라 제자리 - # 갱신이 안전하다. 과완화는 쓰지 않는다(omega=1): 빨리 수렴시킬수록 harmonic 해에 - # 가까워져 능선이 눌린다. 여기서 원하는 건 수렴이 아니라 자국 제거다. - omega = np.float32(1.0) - rows, cols = np.indices(surface.shape) - red = free & (((rows + cols) & 1) == 0) - black = free & ~red - padded = np.zeros((surface.shape[0] + 2, surface.shape[1] + 2), dtype=np.float32) - for _ in range(iterations): - for colour in (red, black): - padded[1:-1, 1:-1] = surface - padded[0, 1:-1] = surface[0] - padded[-1, 1:-1] = surface[-1] - padded[1:-1, 0] = surface[:, 0] - padded[1:-1, -1] = surface[:, -1] - neighbours = ( - padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - ) * np.float32(0.25) - surface[colour] += omega * (neighbours[colour] - surface[colour]) - logger.info("도엽 서피스: 등고선 고정 완화 %d회 (자유 셀 %d개)", iterations, int(free.sum())) - - -def _interpolate_between_contours(burned: np.ndarray, cell_m: float) -> np.ndarray: - """2D 거리 보간 — 셀마다 가장 가까운 서로 다른 표고 두 라인 사이를 선형 보간한다. - - 등고선 정점 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 계단을 만든다. - 여기서는 삼각망을 쓰지 않고, 지도 제작의 표준인 **등고선 사이 비례 보간**을 - 2D에서 직접 한다(2026-08-30 사용자 지시 — 메시는 맨 마지막): - - z = (L1·d2 + L2·d1) / (d1 + d2) - - d1·L1 = 가장 가까운 등고 라인까지의 거리·표고, d2·L2 = **표고가 다른 것 중** - 가장 가까운 라인. 두 라인 사이에서 z가 거리에 비례해 연속으로 변하므로 계단이 - 원리적으로 생기지 않는다. 라인 위(d1=0)에서는 그 표고 그대로다. - - 표고별 거리장을 한 번씩 구하며 **가장 작은 두 값**을 추적한다 — 표고가 서로 다른 - 것끼리 비교하므로 L1≠L2가 보장된다. (KD-tree로 k개 이웃을 뽑는 방식은 이웃이 - 전부 같은 라인의 셀이라 다른 표고를 못 찾는다.) - - `burned`: 라인 셀 = 표고, 그 외 NaN. 계곡 구조선 앵커도 같은 격자에 구워 두면 - 같은 규칙으로 제약이 된다. - """ - from scipy.ndimage import distance_transform_edt - - levels = np.unique(burned[np.isfinite(burned)]) - if len(levels) < 2: - return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32) - - infinity = np.float32(np.inf) - first_d = np.full(burned.shape, infinity, dtype=np.float32) - first_z = np.zeros(burned.shape, dtype=np.float32) - second_d = np.full(burned.shape, infinity, dtype=np.float32) - second_z = np.zeros(burned.shape, dtype=np.float32) - for level in levels: - distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32) - beats_first = distance < first_d - # 1등이 2등으로 밀린다. - second_d = np.where(beats_first, first_d, second_d) - second_z = np.where(beats_first, first_z, second_z) - first_d = np.where(beats_first, distance, first_d) - first_z = np.where(beats_first, np.float32(level), first_z) - beats_second = ~beats_first & (distance < second_d) - second_d = np.where(beats_second, distance, second_d) - second_z = np.where(beats_second, np.float32(level), second_z) - - total = first_d + second_d - usable = np.isfinite(second_d) & (total > 1e-9) - surface = first_z.astype(np.float32) - surface[usable] = ( - ( - first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64) - + second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64) - ) - / total[usable].astype(np.float64) - ).astype(np.float32) - logger.info("도엽 서피스: 2D 거리보간 %d셀 (제약 표고 %d단)", surface.size, len(levels)) - return surface - - def _resolve_enclosed_interiors( burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float ) -> np.ndarray: @@ -327,60 +231,25 @@ def _resolve_enclosed_interiors( return handled -def build_sheet_surface_model( +def _write_method_model( project_root: Path, - processed_dir: Path, models_dir: Path, - route_xy: np.ndarray, - epsg: int, + spec: Any, + surface: np.ndarray, + method_key: str, ) -> dict[str, Any] | None: - """도엽등고선으로 DTM npz·프리뷰 glb를 만들고 surface_models 등록용 dict를 돌려준다. - - `route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). 실패하면 None — 호출측은 - 분석을 계속한다(도엽 미확보 지역 폴백). - """ - started = time.monotonic() - features = _load_features_metric(processed_dir, epsg) - if not features: - return None - - x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M - x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M - y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M - y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M - - spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M) - - # ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN). - burned = _rasterize_contour_levels(spec, features) - present = sorted(np.unique(burned[np.isfinite(burned)]).tolist()) - if len(present) < 2: - logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.") - return None - # 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백. - interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0 - - # ② 2D — 등고선 사이 거리 비례 보간(메시 없음). 여기서 나온 격자에서 1m 등고선을 - # 뽑으므로 화면 등고선이 곧 2D 보간선이다(2026-08-30 사용자 지시). - # 제약은 **등고선만** 쓴다 — 계곡 구조선 앵커를 1m로 양자화해 섞었더니 제약 - # 표고가 29단→121단이 되어 계곡 주변만 1m 간격이 되고 보간선이 등간격을 - # 잃었다(2026-08-30 사용자 지적). V자 등고선이 계곡 하강을 이미 담고 있다. - surface = _interpolate_between_contours(burned, spec.cell_m) - # ③ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장 (2026-08-30 사용자 확정). - summits = _resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m) - # ④ 등고 라인(과 마루)을 고정한 채 완화 — 중심축 뭉침을 풀어 간격을 고르게 한다. - _relax_surface(surface, np.isfinite(burned) | summits, SHEET_SURFACE_RELAX_ITERATIONS) - + """방식 하나의 표고 격자를 npz·프리뷰 glb로 저장하고 등록용 dict를 만든다.""" # DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다. x_coords = spec.cell_centers_x() y_coords = spec.cell_centers_y()[::-1] z_grid = surface[::-1, :].astype(np.float32) valid_grid = np.isfinite(z_grid) if not valid_grid.any(): - logger.warning("도엽 서피스: 유효 표고 셀이 없습니다.") + logger.warning("도엽 서피스(%s): 유효 표고 셀이 없습니다.", method_key) return None - stem = f"dtm_{SHEET_SOURCE_FILTER}" + source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}" + stem = f"dtm_{source_filter}" model_path = models_dir / f"{stem}.npz" preview_path = models_dir / f"{stem}_preview.glb" finite_z = z_grid[valid_grid] @@ -402,32 +271,25 @@ def build_sheet_surface_model( bounds=bounds, resolution=np.array([SHEET_SURFACE_GRID_M], np.float32), ) - vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid) write_glb(preview_path, vertices, faces, bounds) - - logger.info( - "도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단 (%.1fs)", - spec.n_rows, - spec.n_cols, - len(present), - time.monotonic() - started, - ) return { "model_type": "dtm", - "source_filter": SHEET_SOURCE_FILTER, + "source_filter": source_filter, "representation": "regular_grid", "model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"), "resolution_m": SHEET_SURFACE_GRID_M, "generation_params": { - "source_filter": SHEET_SOURCE_FILTER, + "source_filter": source_filter, "representation": "regular_grid", "source": "map_sheet_contours", + "interpolation": method_key, + "interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key), "margin_m": SHEET_SURFACE_MARGIN_M, }, "layers": [ { - "layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview", + "layer_name": f"{stem}_preview", "geometry_type": "MESH", "file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"), "file_format": "glb", @@ -436,10 +298,79 @@ def build_sheet_surface_model( } +def build_sheet_surface_model( + project_root: Path, + processed_dir: Path, + models_dir: Path, + route_xy: np.ndarray, + epsg: int, + methods: list[str] | None = None, +) -> list[dict[str, Any]]: + """도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다. + + 방식을 하나로 고르지 않고 전부 만들어 두는 이유: 문헌상 지형에 따라 우열이 갈려 + 화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 — + 호출측은 분석을 계속한다(도엽 미확보 지역 폴백). + + `route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m). + """ + started = time.monotonic() + features = _load_features_metric(processed_dir, epsg) + if not features: + return [] + + x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M + x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M + y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M + y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M + spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M) + + # ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN). + burned = _rasterize_contour_levels(spec, features) + present = sorted(np.unique(burned[np.isfinite(burned)]).tolist()) + if len(present) < 2: + logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.") + return [] + # 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백. + interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0 + + selected = methods or list(SHEET_SURFACE_METHODS) + models: list[dict[str, Any]] = [] + for method_key in selected: + builder = SHEET_METHOD_BUILDERS.get(method_key) + if builder is None: + logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key) + continue + step_started = time.monotonic() + try: + # ② 2D 보간 — 여기서 나온 격자에서 1m 등고선을 뽑으므로 화면 등고선이 곧 + # 2D 보간선이다. 메시(glb)는 그 격자의 표현일 뿐이다(사용자 지시). + surface = builder(spec, burned, features, spec.cell_m) + # ③ 폐합 등고선 안쪽(마루·웅덩이)은 방식과 무관하게 같은 규칙으로 채운다. + _resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m) + except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다 + logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc) + continue + model = _write_method_model(project_root, models_dir, spec, surface, method_key) + if model is not None: + models.append(model) + logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started) + + logger.info( + "도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단, 방식 %d개 (%.1fs)", + spec.n_rows, + spec.n_cols, + len(present), + len(models), + time.monotonic() - started, + ) + return models + + def build_sheet_surface_from_route( project_root: Path, processed_dir: Path, models_dir: Path -) -> dict[str, Any] | None: - """B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None.""" +) -> list[dict[str, Any]]: + """B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록.""" from common_util.common_util_route_geometry import ( find_planned_route_file, read_planned_route_csv, @@ -448,11 +379,11 @@ def build_sheet_surface_from_route( route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") if route_file is None: logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.") - return None + return [] planned = read_planned_route_csv(route_file) if planned is None or len(planned.vertices) < 2: logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name) - return None + return [] route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) return build_sheet_surface_model( project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186 @@ -511,8 +442,8 @@ def run_sheet_surface_analysis( ) _report(70, "surface_model", "도엽등고선 3D 서피스 생성 중") - model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg) - if model is None: + models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg) + if not models: raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.") _report(95, "saving", "결과 저장 중") @@ -537,5 +468,5 @@ def run_sheet_surface_analysis( }, "ground_summary": {}, "manifest": {"status": "sheet_only"}, - "models": [model], + "models": models, } diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 8fd19a77..45ced88b 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -43,6 +43,15 @@ const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; const DEFAULT_FILTER = "csf"; const DEFAULT_METHOD = "dtm"; const ROUTE_STAGE = ROUTES.B05_PROFILE; +// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다. +const SHEET_METHOD_ORDER = [ + "distance", + "biharmonic", + "laplace", + "tin", + "clough", + "idw", +]; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -157,7 +166,49 @@ export async function renderB04Surface(root: HTMLElement): Promise { const sheetTitle = document.createElement("h3"); sheetTitle.className = "b04-surface__panel-title"; sheetTitle.textContent = L("B04_Surface_SheetSurface"); - sheetSection.append(sheetTitle, sheetViewer.root); + // 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다 + // (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다. + const sheetToolbar = document.createElement("div"); + sheetToolbar.className = "b04-surface__sheet-toolbar"; + const sheetMethodButtons = new Map(); + let sheetMethod = ""; + + function selectSheetMethod(method: string): void { + sheetMethod = method; + for (const [key, button] of sheetMethodButtons) { + button.classList.toggle("is-active", key === method); + } + const projectId = getProjectId(); + if (!projectId) return; + sheetViewer.setSelection(`sheet_${method}`, "dtm"); + sheetViewer.render(projectId, models); + } + + // 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다. + const lidarLabel = document.createElement("label"); + lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar"; + const lidarCheck = document.createElement("input"); + lidarCheck.type = "checkbox"; + lidarLabel.append( + lidarCheck, + document.createTextNode(` ${L("B04_Surface_SheetLidar")}`), + ); + lidarCheck.addEventListener("change", () => { + void sheetViewer + .showOverlay( + lidarCheck.checked ? filterGroup.select.value : "", + "dtm", + terrainViewer.isSmoothingEnabled(), + ) + .then((loaded) => { + if (lidarCheck.checked && !loaded) { + showToast(L("B04_Surface_SheetLidar_Missing"), "warning"); + lidarCheck.checked = false; + } + }); + }); + + sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root); sheetSection.hidden = true; let syncingCamera = false; @@ -437,17 +488,47 @@ export async function renderB04Surface(root: HTMLElement): Promise { renderInputInfo(); updateSelectedModel(); - // 도엽등고 3D 서피스 — sheet/dtm 모델이 있으면 별도 컨테이너로 보여준다. - const hasSheetModel = models.some( - (model) => - model.model_type.toLowerCase() === "dtm" && - getModelFilter(model) === "sheet", - ); - sheetSection.hidden = !hasSheetModel; - if (hasSheetModel) { - sheetViewer.setSelection("sheet", "dtm"); + // 도엽등고 3D 서피스 — sheet_* 모델이 있으면 별도 컨테이너로 보여준다. + // 보간 방식마다 모델이 하나씩 있으므로 버튼으로 갈아 끼운다. + const sheetMethods = models + .filter( + (model) => + model.model_type.toLowerCase() === "dtm" && + getModelFilter(model).startsWith("sheet_"), + ) + .map((model) => ({ + key: getModelFilter(model).slice("sheet_".length), + label: + typeof model.generation_params?.interpolation_label === "string" + ? (model.generation_params.interpolation_label as string) + : getModelFilter(model).slice("sheet_".length), + })) + // 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다. + .sort( + (a, b) => + (SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) - + (SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99), + ); + sheetSection.hidden = sheetMethods.length === 0; + if (sheetMethods.length) { + sheetToolbar.replaceChildren(); + sheetMethodButtons.clear(); + for (const method of sheetMethods) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-surface__sheet-method"; + button.textContent = method.label; + button.addEventListener("click", () => selectSheetMethod(method.key)); + sheetToolbar.append(button); + sheetMethodButtons.set(method.key, button); + } + sheetToolbar.append(lidarLabel); sheetViewer.setSmoothing(false); - sheetViewer.render(projectId, models); + selectSheetMethod( + sheetMethods.some((method) => method.key === sheetMethod) + ? sheetMethod + : sheetMethods[0].key, + ); } } diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index c3b73a52..ee130ad7 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -804,3 +804,36 @@ .b04-surface__sheet-section > .terrain-model-group { margin-top: var(--spacing-12); } + +/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ +.b04-surface__sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-12) 0; +} + +.b04-surface__sheet-method { + padding: 4px 10px; + font-size: var(--text-caption); + color: var(--color-text-body); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + cursor: pointer; +} + +.b04-surface__sheet-method:hover { + border-color: var(--color-primary); +} + +.b04-surface__sheet-method.is-active { + color: var(--color-on-primary, #fff); + background: var(--color-primary); + border-color: var(--color-primary); +} + +.b04-surface__sheet-lidar { + margin-left: auto; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index a3f96c9d..8f1d8fa3 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -38,6 +38,12 @@ export interface SurfaceTerrainViewer { render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; setReferenceBounds: (bounds: SurfaceBounds) => void; setSelection: (sourceFilter: string, method: string) => void; + /** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */ + showOverlay: ( + sourceFilter: string, + method: string, + smooth: boolean, + ) => Promise; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; @@ -291,6 +297,76 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } + // ── 겹쳐 보기 메시 ───────────────────────────────────────────────────────── + // 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다 + // (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다. + let overlayMesh: THREE.Object3D | null = null; + let overlayGeneration = 0; + + function clearOverlay() { + if (overlayMesh) { + scene.remove(overlayMesh); + disposeObject(overlayMesh); + overlayMesh = null; + } + } + + async function loadOverlay( + projectId: string, + models: readonly SurfaceModelSummary[], + sourceFilter: string, + method: string, + smooth: boolean, + ): Promise { + const generation = ++overlayGeneration; + clearOverlay(); + const match = models.find((model) => { + const configured = model.generation_params?.source_filter; + return ( + model.model_type.toLowerCase() === method.toLowerCase() && + typeof configured === "string" && + configured.toLowerCase() === sourceFilter.toLowerCase() + ); + }); + if (!match) return false; + const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`; + try { + const buffer = await fetchCachedBytes(projectId, url); + if (generation !== overlayGeneration) return false; + return await new Promise((resolve) => { + new GLTFLoader().parse( + buffer, + "", + (gltf) => { + if (generation !== overlayGeneration) { + disposeObject(gltf.scene); + resolve(false); + return; + } + // 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다. + gltf.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.material = new THREE.MeshStandardMaterial({ + color: 0x60a5fa, + transparent: true, + opacity: 0.45, + side: THREE.DoubleSide, + flatShading: false, + }); + } + }); + overlayMesh = gltf.scene; + scene.add(gltf.scene); + resolve(true); + }, + () => resolve(false), + ); + }); + } catch { + return false; + } + } + function clearContours() { while (contourGroup.children.length > 0) { const child = contourGroup.children[0]; @@ -776,6 +852,19 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { activeMethod = method; syncSmoothingSupport(); }, + showOverlay(sourceFilter, method, smooth) { + if (!sourceFilter) { + clearOverlay(); + return Promise.resolve(false); + } + return loadOverlay( + currentProjectId, + currentModelsList, + sourceFilter, + method, + smooth, + ); + }, applyCameraState, onCameraChange(listener) { cameraListener = listener; diff --git a/config/config_system.py b/config/config_system.py index 64ed7dac..6bda0157 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -188,10 +188,17 @@ SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLU SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) # 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) -# 등고선 고정 다듬기 반복 횟수. 거리 보간이 능선·계곡 중심축에 남기는 각진 자국만 -# 지우는 용도라 **적을수록 좋다** — 수렴시키면 harmonic 해가 되어 마루가 눌린다 -# (합성 원뿔 검증: 5회가 최적, 20회 이상 악화). 0이면 다듬지 않는다. -SHEET_SURFACE_RELAX_ITERATIONS = int(os.getenv("SHEET_SURFACE_RELAX_ITERATIONS", "5")) +# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다 +# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py. +SHEET_SURFACE_METHODS = [ + method.strip() + for method in os.getenv( + "SHEET_SURFACE_METHODS", "distance,biharmonic,laplace,tin,clough,idw" + ).split(",") + if method.strip() +] +# 확정에 쓸 기본 방식 — 비교 후 사용자가 정한다. +SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "distance") # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 753aaa47..6a3bbcb0 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -440,6 +440,11 @@ export const ui_locales_b1 = { "도엽등고 3D 서피스", "Map-sheet contour 3D surface", ], + B04_Surface_SheetLidar: ["라이다 겹쳐 보기", "Overlay LiDAR"], + B04_Surface_SheetLidar_Missing: [ + "겹쳐 볼 라이다 지표면 모델이 없습니다.", + "No LiDAR surface model available to overlay.", + ], B04_Surface_Group_ViewControls: ["뷰어 시점 제어", "Viewer camera"], B04_Surface_Field_Smoothing: ["스무딩", "Smoothing"], B04_Surface_Smoothing_On: ["적용", "On"],