"""계획노선이 지표면 밖으로 나갈 때의 트림 규칙. 배경(2026-09-01): 실무 자료 `용화.las`가 계획노선 2,136m 중 일부만 덮는다. 자르지 않으면 자동 설계 체인이 서피스 밖 정점에서 끊긴다. 서피스 가장자리는 점 밀도가 떨어져 불규칙하므로, 잘라 낸 쪽 끝에서 30m를 더 깎는다. """ import math import numpy as np import pytest from common_util.common_util_route_geometry import densify_route, trim_route_to_surface class BoxSampler: """x가 [x_min, x_max] 안일 때만 유효한 가짜 지표면.""" def __init__(self, x_min: float, x_max: float) -> None: self.x_min = x_min self.x_max = x_max def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: xs = np.asarray(xy)[:, 0] valid = (xs >= self.x_min) & (xs <= self.x_max) return np.zeros(len(xs)), valid def _line(length_m: float, step_m: float = 10.0) -> list[tuple[float, float]]: count = int(length_m / step_m) + 1 return [(index * step_m, 0.0) for index in range(count)] def _length(points: list[tuple[float, float]]) -> float: return sum(math.dist(points[i], points[i + 1]) for i in range(len(points) - 1)) def test_route_fully_inside_is_untouched() -> None: """전 구간이 지표면 안이면 손대지 않는다 — 멀쩡한 노선을 깎을 이유가 없다.""" points = _line(500.0) assert trim_route_to_surface(points, BoxSampler(-100.0, 1000.0)) == points def test_tail_outside_is_cut_with_edge_margin() -> None: """뒤쪽이 잘리면 그 끝에서 30m를 더 깎는다. 시점은 원래 자리 그대로.""" points = _line(1000.0) kept = trim_route_to_surface(points, BoxSampler(-100.0, 600.0), edge_trim_m=30.0) assert kept[0] == points[0], "시점은 지표면 안이므로 깎으면 안 된다" assert kept[-1][0] == pytest.approx(570.0, abs=1.0), "600m 경계에서 30m 더 깎는다" assert _length(kept) == pytest.approx(570.0, abs=1.0) def test_both_ends_outside_are_cut() -> None: points = _line(1000.0) kept = trim_route_to_surface(points, BoxSampler(200.0, 800.0), edge_trim_m=30.0) assert kept[0][0] == pytest.approx(230.0, abs=1.0) assert kept[-1][0] == pytest.approx(770.0, abs=1.0) def test_longest_valid_run_wins() -> None: """가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다.""" class HoleSampler: def sample_xy(self, xy): xs = np.asarray(xy)[:, 0] # 0~20m 짧은 조각, 200~800m 긴 조각 valid = ((xs <= 20.0) | ((xs >= 200.0) & (xs <= 800.0))) & (xs <= 800.0) return np.zeros(len(xs)), valid kept = trim_route_to_surface(_line(1000.0), HoleSampler(), edge_trim_m=30.0) assert kept[0][0] == pytest.approx(230.0, abs=1.0), "긴 구간을 골라야 한다" assert kept[-1][0] == pytest.approx(770.0, abs=1.0) def test_route_entirely_outside_returns_empty() -> None: assert trim_route_to_surface(_line(500.0), BoxSampler(9000.0, 9999.0)) == [] def test_edge_margin_eating_everything_returns_empty() -> None: """남는 구간이 여유보다 짧으면 빈 목록 — 못 미더운 구간으로 설계하지 않는다.""" kept = trim_route_to_surface(_line(1000.0), BoxSampler(500.0, 540.0), edge_trim_m=30.0) assert kept == [] def test_sampler_failure_keeps_route() -> None: """지표면을 못 읽으면 노선을 자르지 않는다 — 트림 실패가 설계를 막으면 안 된다.""" class BrokenSampler: def sample_xy(self, xy): raise OSError("DTM 손상") points = _line(300.0) assert trim_route_to_surface(points, BrokenSampler()) == points def test_densify_keeps_shape_and_original_vertices() -> None: """조밀화는 같은 직선 위에 점을 더 찍을 뿐 — 형상도 끝점도 바뀌지 않는다.""" points = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)] dense = densify_route(points, 4.0) assert dense[0] == points[0] and dense[-1] == points[-1] for original in points: assert original in dense, "원래 정점은 모두 남아야 한다" gaps = [math.dist(dense[i], dense[i + 1]) for i in range(len(dense) - 1)] assert max(gaps) <= 4.0 + 1e-9 assert _length(dense) == pytest.approx(_length(points)), "연장이 바뀌면 형상이 바뀐 것" def test_densify_leaves_close_vertices_alone() -> None: """이미 문턱 이하면 손대지 않는다 — 쓸데없이 정점을 불리지 않는다.""" points = [(0.0, 0.0), (2.0, 0.0), (4.0, 0.0)] assert densify_route(points, 4.0) == points