Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py
T
eomsangdonandClaude Opus 5 cb1c5261e2 feat(B04): 사이 등고선을 2D 가상 등고라인으로 뽑아 반복 이분한다
앞서는 등거리 '점'을 5셀 간격으로 흩뿌려 점 사이가 벌어진 곳에 평탄
삼각형이 남았고 한 단계(+2.5m)만 넣어 그 안에서 계단이 남았다.
사용자 지시(2D에서 사이 등고선을 먼저 만들고 3D화)대로 등거리선을
연속 셀 선으로 뽑아 새 레벨로 등록하고, 그 선들 사이를 다시 이분한다
(SHEET_SURFACE_MIDLINE_ROUNDS=2 → 5m 주곡선이 2.5m·1.25m 가상 등고로).
정점 간격도 5셀에서 2m로 좁혔다. 거리장은 라운드 간 재사용한다.

실측(c1bb453f): 평탄 셀 3.96%→2.48%(최초 9.35%), 경사 2% 미만 셀
27163→17145, 노선 |Δz| 2.56m 유지. 생성 16.4s→36.5s — 라운드 수는
config로 조절 가능.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:22:30 +09:00

582 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""도엽등고선 3D 서피스 — 1:5,000 수치지형도 등고선으로 DTM 격자를 만든다.
LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 있어도 참고용으로
같이 만들어 두는 서피스다. 산출 형식은 LAS 파이프라인의 DTM과 완전히 같게 맞춘다
(`dtm_sheet.npz`: x/y/z/valid_mask) — 종·횡단·배수 세부설계가 쓰는
`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다.
절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정),
격자는 `SHEET_SURFACE_GRID_M`(1m). 등고선→정점 구름→Delaunay TIN 보간은 배수유역
엔진(`_Watershed_Grid`)의 검증된 경로를 그대로 쓴다.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
write_glb,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
build_contour_cloud,
grid_spec_from_bounds,
interpolate_elevation,
)
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_MIDLINE_MAX_M,
SHEET_SURFACE_MIDLINE_ROUNDS,
SURFACE_MAX_PREVIEW_VERTICES,
)
logger = logging.getLogger(__name__)
# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값)
_CONTOUR_FILE = "도엽_등고선.geojson"
_STREAM_FILE = "도엽_하천중심선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
def _load_features_metric(
processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE
) -> list[dict[str, Any]]:
"""병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다."""
path = processed_dir / filename
if not path.exists():
logger.warning("도엽 서피스: 도엽 레이어 파일이 없습니다: %s", path)
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 서피스: 등고선 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
if not isinstance(features, list):
return []
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
def _map(coords: Any) -> Any:
if not isinstance(coords, list):
return coords
if coords and isinstance(coords[0], (int, float)):
x, y = transformer.transform(coords[0], coords[1])
return [x, y, *coords[2:]]
return [_map(item) for item in coords]
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry") or {}
coordinates = _map(geometry.get("coordinates"))
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다."""
stride = 1
while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES:
stride += 1
px, py = x[::stride], y[::stride]
pz, pv = z[::stride, ::stride], valid[::stride, ::stride]
vertices = grid_vertices(px, py, pz.astype(np.float64))
faces = grid_faces(len(py), len(px))
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def _stream_breakline_vertices(
spec: Any, burned: np.ndarray, stream_features: list[dict[str, Any]]
) -> tuple[np.ndarray, np.ndarray] | None:
"""계곡 기준선(하천중심선)을 따라 등고 교차점 사이를 보간한 정점열을 만든다.
구조선 기반 보간(2026-08-30 사용자 확정): 계곡 축을 먼저 세우고, 축이 등고선과
만나는 점을 Z 앵커로 삼아 앵커 사이를 선 길이 비례로 보간한다. 계곡 바닥이
등고선 사이에서도 연속으로 내려가는 가상 종단이 되어 골짜기 평탄·역경사가 준다.
"""
from shapely import segmentize
from shapely.geometry import shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import iter_linestrings
sample_step_m = 2.0
vertex_stride = 3 # 2m 샘플 → 6m 간격 정점 (등고 정점 재샘플 밀도와 유사)
collected_xy: list[np.ndarray] = []
collected_z: list[np.ndarray] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in iter_linestrings(parsed):
coords = np.asarray(segmentize(line, sample_step_m).coords, dtype=np.float64)
if len(coords) < 3:
continue
xy = coords[:, :2]
arc = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
levels_at = np.full(len(xy), np.nan, dtype=np.float32)
levels_at[inside] = burned[row[inside], col[inside]]
hit = np.isfinite(levels_at)
if hit.sum() < 2:
continue
# 등고선 통과 구간(연속 같은 표고 샘플 묶음) 하나 = 앵커 하나.
anchor_arc: list[float] = []
anchor_z: list[float] = []
run_start: int | None = None
for i in range(len(xy) + 1):
is_hit = i < len(xy) and hit[i]
if is_hit and run_start is not None and levels_at[i] != levels_at[run_start]:
is_hit = False # 표고가 바뀌면 묶음을 끊는다
if is_hit:
if run_start is None:
run_start = i
elif run_start is not None:
anchor_arc.append(float(arc[run_start : i if i <= len(xy) else len(xy)].mean()))
anchor_z.append(float(levels_at[run_start]))
run_start = i if i < len(xy) and hit[i] else None
if len(anchor_arc) < 2:
continue
# 앵커 사이 구간만 채택 — 첫 앵커 이전·마지막 앵커 이후는 근거가 없다.
span = (arc >= anchor_arc[0]) & (arc <= anchor_arc[-1]) & inside & ~hit
take = np.flatnonzero(span)[::vertex_stride]
if not len(take):
continue
collected_xy.append(xy[take])
collected_z.append(np.interp(arc[take], anchor_arc, anchor_z))
if not collected_xy:
return None
return np.vstack(collected_xy), np.concatenate(collected_z)
def _densify_between_contours(
spec: Any, burned: np.ndarray, present: list[float], cloud: Any
) -> Any:
"""등고선 사이에 2D 가상 등고라인을 반복 이분으로 만들어 정점으로 추가한다.
등고선 정점만의 Delaunay TIN은 같은 표고 정점 3개짜리 평탄 삼각형이 굴곡부에
계단(terrace)을 만든다. 인접 표고 라인 쌍의 등거리선(사이 등고선)을 **연속 셀
선**으로 뽑아 새 레벨로 등록하고, 그 선들 사이를 다시 이분하는 식으로
`SHEET_SURFACE_MIDLINE_ROUNDS`회 반복한다 — 5m 주곡선이면 2.5m, 1.25m 가상
등고가 생겨 TIN이 어디서든 서로 다른 표고를 잇는다(2026-08-30 사용자 지시:
2D에서 사이 등고선을 먼저 만들고 3D화).
등거리 조건은 멀리 있는 다른 표고 쌍에도 우연히 성립하므로(예: 525~530
골짜기에 555/560 중간점), "그 지점의 최근접 라인이 바로 그 쌍"일 때만 인정한다.
"""
from scipy.ndimage import distance_transform_edt
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ContourCloud
if len(present) < 2:
return cloud
# 라운드마다 거리장을 다시 구하되, 직전 라운드 것을 재사용한다 — 새 라인 사이의
# 이분은 (직전 레벨 거리장, 새 중간선 거리장)만 있으면 되므로 전량 재계산이 필요없다.
levels = list(present)
masks = [burned == level for level in levels]
distances = [
distance_transform_edt(~mask, sampling=spec.cell_m).astype(np.float32) for mask in masks
]
virtual: dict[float, np.ndarray] = {}
for _ in range(max(SHEET_SURFACE_MIDLINE_ROUNDS, 0)):
if len(levels) < 2:
break
best_distance = np.full(burned.shape, np.inf, dtype=np.float32)
best_index = np.full(burned.shape, -1, dtype=np.int16)
for index, distance in enumerate(distances):
take = distance < best_distance
best_distance[take] = distance[take]
best_index[take] = index
next_levels: list[float] = []
next_masks: list[np.ndarray] = []
next_distances: list[np.ndarray] = []
added_any = False
for index in range(len(levels)):
next_levels.append(levels[index])
next_masks.append(masks[index])
next_distances.append(distances[index])
if index == 0:
continue
lower, upper = distances[index - 1], distances[index]
near = (lower < SHEET_SURFACE_MIDLINE_MAX_M) & (upper < SHEET_SURFACE_MIDLINE_MAX_M)
pair_is_nearest = (best_index == index - 1) | (best_index == index)
midline = near & pair_is_nearest & (np.abs(lower - upper) <= spec.cell_m)
midline &= ~masks[index - 1] & ~masks[index]
if not midline.any():
continue
mid_level = (levels[index - 1] + levels[index]) / 2.0
virtual[mid_level] = midline
# 새 라인은 마지막(=자기 레벨) 자리 앞에 끼워 정렬을 유지한다.
next_levels.insert(-1, mid_level)
next_masks.insert(-1, midline)
next_distances.insert(
-1, distance_transform_edt(~midline, sampling=spec.cell_m).astype(np.float32)
)
added_any = True
levels, masks, distances = next_levels, next_masks, next_distances
if not added_any:
break
if not virtual:
return cloud
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
extra_xy: list[np.ndarray] = []
extra_z: list[np.ndarray] = []
for level, mask in virtual.items():
rows, cols = np.nonzero(mask)
rows, cols = rows[::2], cols[::2] # 1m 셀 → 2m 간격 정점 (TIN 비용 절제)
extra_xy.append(np.column_stack([xs[cols], ys[rows]]))
extra_z.append(np.full(len(rows), level))
added = int(sum(len(a) for a in extra_z))
logger.info(
"도엽 서피스: 가상 등고라인 %d단, 정점 %d개 추가 (원 등고 %d단)",
len(virtual),
added,
len(present),
)
return ContourCloud(
xy=np.vstack([cloud.xy, *extra_xy]),
z=np.concatenate([cloud.z, *extra_z]),
)
def _cap_flat_summits(surface: np.ndarray, cell_m: float, interval_m: float) -> int:
"""마지막 등고선 안쪽 평탄 마루를 완만한 능선 돔으로 올린다 (in-place).
다음 등고선이 없는 봉우리·능선 마루는 TIN이 마지막 등고 표고로 평탄하게 채운다
('ㅜ'가 갑자기 'ㅡ'로 변하는 단 — 2026-08-30 사용자 지적). 실제 마루는 그 표고와
+간격 사이이므로, 마지막 등고선 바깥 사면의 국소 경사를 안쪽으로 연장해 올린다
(상한 +간격−0.5m — 다음 등고선이 없다는 사실과 모순되지 않게). 바깥 경사를
구할 수 없으면 +간격/2 돔으로 폴백한다(2026-08-30 사용자 확정 ②a).
적용 조건: 등표고 평탄 컴포넌트이고 바깥 유효 이웃이 전부 더 낮을 때(=마루).
골짜기 바닥은 배수 방향을 모르므로 건드리지 않는다. 돋운 컴포넌트 수를 반환.
"""
from scipy.ndimage import binary_dilation, distance_transform_edt, label
finite = np.isfinite(surface)
quantized = np.round(surface * 100.0)
capped = 0
# 평탄 후보: 상하좌우 이웃 중 같은 표고가 있는 셀만 모아 컴포넌트를 짠다.
same_right = np.zeros_like(finite)
same_right[:, :-1] = finite[:, :-1] & finite[:, 1:] & (quantized[:, :-1] == quantized[:, 1:])
same_down = np.zeros_like(finite)
same_down[:-1, :] = finite[:-1, :] & finite[1:, :] & (quantized[:-1, :] == quantized[1:, :])
flat = same_right | same_down
flat[:, 1:] |= same_right[:, :-1]
flat[1:, :] |= same_down[:-1, :]
# 서로 다른 표고의 평탄면이 맞닿아 있을 수 있으므로 표고값별로 컴포넌트를 짠다.
# 값별 셀 수 25 미만은 컴포넌트도 25 미만이므로 라벨링 전에 거른다(속도).
flat_values, flat_counts = np.unique(quantized[flat], return_counts=True)
for value in flat_values[flat_counts >= 25]:
value_labels, value_count = label(flat & (quantized == value))
sizes = np.bincount(value_labels.reshape(-1))
for component_id in range(1, value_count + 1):
if sizes[component_id] < 25: # 25㎡ 미만은 시각적으로 단이 아니다
continue
rows, cols = np.nonzero(value_labels == component_id)
if (
rows.min() == 0
or cols.min() == 0
or rows.max() == surface.shape[0] - 1
or cols.max() == surface.shape[1] - 1
):
continue # 격자 가장자리에 닿으면 바깥을 모른다
# 이후 연산은 컴포넌트 bbox 창(+경사 밴드 여유)에서만 — 전체 격자 반복 회피.
band_cells = 8
window = (
slice(
max(rows.min() - band_cells, 0),
min(rows.max() + band_cells + 1, surface.shape[0]),
),
slice(
max(cols.min() - band_cells, 0),
min(cols.max() + band_cells + 1, surface.shape[1]),
),
)
component = value_labels[window] == component_id
ring = binary_dilation(component) & ~component & finite[window]
if not ring.any():
continue
level = float(surface[rows[0], cols[0]])
patch = surface[window]
if float(patch[ring].max()) >= level - 1e-3:
continue # 더 높은 이웃이 있으면 마루가 아니다(사면 벤치·골짜기)
inner = distance_transform_edt(component, sampling=cell_m)
peak = float(inner.max())
if peak <= 0.0:
continue
# 바깥 사면 경사 추정 — 마지막 등고선 밖 밴드의 (표고 낙차 / 거리) 평균을
# 안쪽으로 연장한다(2026-08-30 사용자 확정 ②a). 상한 +간격−0.5m:
# 다음 등고선이 없다는 사실(마루 < L+간격)과 모순되지 않게.
outer_distance = distance_transform_edt(~component, sampling=cell_m)
band = (~component) & finite[window] & (outer_distance <= band_cells * cell_m)
band &= outer_distance > 0
if band.any():
drops = level - patch[band].astype(np.float64)
slope = float(np.mean(drops / outer_distance[band]))
else:
slope = 0.0
if slope > 1e-3:
rise = np.minimum(slope * inner[component], interval_m - 0.5)
else: # 경사를 못 구하면 +간격/2 돔 폴백
rise = (interval_m / 2.0) * (inner[component] / peak)
patch[component] += rise.astype(surface.dtype)
capped += 1
if capped:
logger.info(
"도엽 서피스: 평탄 마루 %d곳을 +%.1fm 골격 캡으로 돋움", capped, interval_m / 2.0
)
return capped
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
models_dir: Path,
route_xy: np.ndarray,
epsg: int,
) -> 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
# 저지대 제거(floor) 없이 전부 쓴다 — 종·횡단은 낮은 지반도 필요하다.
cloud = build_contour_cloud(features, None, (x_min, y_min, x_max, y_max))
if cloud.is_empty:
logger.warning("도엽 서피스: 절취 범위 안에 등고선 정점이 없습니다.")
return None
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
# 등고 간격(m) — 마루 캡 크기의 근거. 레벨이 하나뿐이면 5m(1:5,000 주곡선) 폴백.
contour_levels = np.unique(cloud.z)
interval_m = float(np.diff(contour_levels).min()) if len(contour_levels) > 1 else 5.0
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Descent import rasterize_contours
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ContourCloud
burned, levels = rasterize_contours(spec, features, None)
present = [level for level in sorted(levels) if bool((burned == level).any())]
# 계단(평탄 삼각형) 해소 — 인접 등고선 사이 중간 보간선을 정점으로 추가.
cloud = _densify_between_contours(spec, burned, present, cloud)
# 계곡 구조선 — 하천중심선을 따라 등고 교차점 사이를 보간한 정점 추가.
stream_features = _load_features_metric(processed_dir, epsg, _STREAM_FILE)
if stream_features:
stream_vertices = _stream_breakline_vertices(spec, burned, stream_features)
if stream_vertices is not None:
logger.info("도엽 서피스: 계곡 구조선 정점 %d개 추가", len(stream_vertices[1]))
cloud = ContourCloud(
xy=np.vstack([cloud.xy, stream_vertices[0]]),
z=np.concatenate([cloud.z, stream_vertices[1]]),
)
surface = interpolate_elevation(spec, cloud) # (R, C), 북→남 행 순서, 외부 NaN
# 마지막 등고선 안쪽 평탄 마루를 완만한 돔으로 (2026-08-30 사용자 확정).
_cap_flat_summits(surface, spec.cell_m, interval_m)
# 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("도엽 서피스: 유효 표고 셀이 없습니다.")
return None
stem = f"dtm_{SHEET_SOURCE_FILTER}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
finite_z = z_grid[valid_grid]
# bounds를 npz에 같이 넣는다 — 등고선 API가 이 값을 화면 원점으로 쓴다. 없으면
# LAS structured.npz로 폴백해 메시(glb) 원점과 어긋난다(LAS 없는 설계는 아예 실패).
bounds = np.array(
[
[x_coords[0], x_coords[-1]],
[y_coords[0], y_coords[-1]],
[float(finite_z.min()), float(finite_z.max())],
]
)
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
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,
cloud.xy.shape[0],
time.monotonic() - started,
)
return {
"model_type": "dtm",
"source_filter": SHEET_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,
"representation": "regular_grid",
"source": "map_sheet_contours",
"margin_m": SHEET_SURFACE_MARGIN_M,
},
"layers": [
{
"layer_name": f"dtm_{SHEET_SOURCE_FILTER}_preview",
"geometry_type": "MESH",
"file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"),
"file_format": "glb",
}
],
}
def build_sheet_surface_from_route(
project_root: Path, processed_dir: Path, models_dir: Path
) -> dict[str, Any] | None:
"""B03 업로드 계획노선 CSV를 찾아 도엽 서피스를 만든다. 없거나 실패하면 None."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return None
planned = read_planned_route_csv(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return None
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
)
def run_sheet_surface_analysis(
project_root: Path,
route_csv_path: Path,
*,
on_progress: Any = None,
) -> dict[str, Any]:
"""LAS 없는 WF1 — 도엽 확보 후 도엽등고선 서피스만으로 분석 결과를 만든다.
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
on_progress(percent, stage, message)
stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
bounds_dict = {
"x": [float(route_xy[:, 0].min()), float(route_xy[:, 0].max())],
"y": [float(route_xy[:, 1].min()), float(route_xy[:, 1].max())],
"z": [
float(min(v.z for v in planned.vertices)),
float(max(v.z for v in planned.vertices)),
],
}
# VWorld 지도·GIS 벡터·도엽 확보 — LAS 경로와 같은 공용 블록 (지연 import로 순환 회피)
_report(30, "download_maps", "VWorld 지도 및 수치지형도 도엽 확보 중")
from B04_PreProcess.B04_PreProcess_Engine import download_geodata
download_geodata(
project_root,
processed_dir,
bounds_dict,
route_csv_path.parent,
rebuild=False,
default_epsg=f"EPSG:{epsg}",
report=_report,
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
model = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
if model is None:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
_report(95, "saving", "결과 저장 중")
return {
"processed": {
"processed_file_path": str(
(processed_dir / _CONTOUR_FILE).relative_to(project_root)
).replace("\\", "/"),
"converted_file_path": None,
"point_count": int(len(route_xy)),
"bounds": {
"x_min": bounds_dict["x"][0],
"x_max": bounds_dict["x"][1],
"y_min": bounds_dict["y"][0],
"y_max": bounds_dict["y"][1],
},
"statistics": {
"min_z": bounds_dict["z"][0],
"max_z": bounds_dict["z"][1],
"mean_z": None,
},
},
"ground_summary": {},
"manifest": {"status": "sheet_only"},
"models": [model],
}