From 45abce48a3077fb8786ad0e6a130116dcc8628c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 10:00:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=ED=95=A9=EC=B9=98=EA=B8=B0):=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EC=AA=BD=20=EC=9D=B4=EC=9B=83=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=ED=95=A9=EC=B9=98=EA=B8=B0=20=EB=8B=A8=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=9E=BC=20(406-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 Engine_RouteInitial_Merge — 같은 쪽 이웃 꺾임 둘 → 바깥 두 직선 교점 하나 · 되풀이 · S자 안 합침 - 직접 잼(Guard) — 탐색 복도 안 · 계류 나란히 · 기울기 넘음 · 토공 가늠 안 나빠짐 · 비중 계산은 예상노선 이격도 - 합친 곡선은 MC 가 측점 사이 어디여도 IP 밀어 측점에 놓은 꼴까지 잼 통과 · 노선 전체 잣대(넘은 곳 · 최대 기울기 +0.5 %p · 절성토 +2 %) 안 나빠짐 - 시험 test_406_1 — 같은 쪽 합침 · S자 · 기울기 · 복도 · 계류 · 전체 잣대 막힘 · 큰 R 직접 잼 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial_Merge.py | 311 ++++++++++++++++++ resources/tester/test_406_1_route_merge.py | 124 +++++++ 2 files changed, 435 insertions(+) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py create mode 100644 resources/tester/test_406_1_route_merge.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py new file mode 100644 index 000000000..b7a39eb59 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py @@ -0,0 +1,311 @@ +"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(PLAN 406 · 요구 251-2). + +`B05_Profile_Engine_RouteInitial_Pool._trace` 가 큰 R 판 뒤 · 측점 맞춤 앞에서 부름. 노선 추천 +후보만 — 손 편집 · 옛 노선 · 예상노선 추적(전처리 초기값) · 공용 선 짜기 짝은 안 거침. + +합치기(`merge_same_side`) = 같은 쪽으로 도는 이웃 꺾임점 둘 → 바깥 두 직선 교점 하나(큰 단곡선 · +사이 직선 없어짐) · 되풀이. 반대 방향(S자) 이웃은 안 합침(사이 직선 남김). 접선 규칙은 그대로 +(앞뒤 직선 절반 · 398-4 안 함). 고르는 차례 = 지금 선에서 덜 벗어나는 합치기부터. + +거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`): + · 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상. + · 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공 + 가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`). + · 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함 + (`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음). + · 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대 + 기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨). +큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신). +""" + +from __future__ import annotations + +import copy +import math +from collections.abc import Callable +from typing import Any + +import numpy as np +import shapely +from shapely.geometry import LineString + +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( + CORRIDOR_M, + ROAD_WIDTH_M, + STREAM_CROSS_COS, + SearchGrid, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _corner, _Reference +from common_util.common_util_route_polyline import ( + PlannedPolyline, + _inner_angle_deg, + _line_intersection, + _turn_sign, + build_planned_polyline, +) + +Xy = tuple[float, float] +Route = tuple[Any, dict[str, Any]] + +# 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각) +EARTH_TOL = 0.05 +EARTH_FLOOR_M3 = 5.0 +# 옮긴 조각 지반 기울기가 상한을 더 넘어도 「안 늘어남」으로 볼 잴 잡음(비율 · 0.2 %p) +GRADE_NOISE = 0.002 +# 합치기 노선 전체 잣대 — 최대 기울기(%p) · 절성토 늘어도 되는 몫 +GRADE_TOL_PCT = 0.5 +TOTAL_EARTH_TOL = 0.02 +# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터) +RADIUS_STEPS = 12 +# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t) +# 놓기 하나는 직접 잼을 지나야 함(측점 맞춤이 IP 를 들어오는 접선 따라 밂 · 합치기 풀기 없음) +PLACE_OFFSETS_M = (0.0, 5.0, 10.0, 15.0) +# 잰 조각 기억 수 — 측점 맞춤 단계 · 되돌림이 같은 조각을 자주 다시 잼 +SEEN_LIMIT = 200_000 + + +def _mid(a: Xy, b: Xy) -> Xy: + return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) + + +class Guard: + """거리 한도 대신 직접 잼 — 옮긴 조각이 같은 자리 옛 조각보다 나빠지지 않나(탐색 격자 잣대).""" + + def __init__(self, grid: SearchGrid, limit: float) -> None: + self.grid, self.limit = grid, limit + stream = np.where(np.isfinite(grid.d_stream), grid.d_stream, 1e9) + # 겹쳐 놓은 칸 값(잼마다 보간기를 새로 안 짬) + expected = np.minimum(grid.d_expected, CORRIDOR_M) + self.fields = np.stack( + [grid.z, grid.grad_x, grid.grad_y, grid.away_x, grid.away_y, stream, expected] + ) + # 예상노선 이격도 잼(비중 계산 · 예상 몫 있을 때 — 그 갈래 잣대 · `keeping_offset`) + self.offset = False + self.seen: dict[bytes, tuple[float, float, float, bool]] = {} # 같은 조각 다시 안 잼 + + def _sample(self, xy: np.ndarray) -> np.ndarray: + """칸 값 [7, n] — 쌍선형(격자 밖은 가장자리 칸).""" + grid, fields = self.grid, self.fields + rows_n, cols_n = fields.shape[1:] + r = np.clip((xy[:, 1] - grid.ys[0]) / grid.cell, 0.0, rows_n - 1.0) + c = np.clip((xy[:, 0] - grid.xs[0]) / grid.cell, 0.0, cols_n - 1.0) + r0 = np.minimum(r.astype(int), rows_n - 2) + c0 = np.minimum(c.astype(int), cols_n - 2) + fr, fc = r - r0, c - c0 + return ( + fields[:, r0, c0] * (1 - fr) * (1 - fc) + + fields[:, r0 + 1, c0] * fr * (1 - fc) + + fields[:, r0, c0 + 1] * (1 - fr) * fc + + fields[:, r0 + 1, c0 + 1] * fr * fc + ) + + def keeping_offset(self) -> Guard: + """예상노선 이격도 안 늘게 재는 판(잰 조각 기억은 함께 씀).""" + twin = copy.copy(self) + twin.offset = True + return twin + + def _look(self, points: list[Xy]) -> tuple[float, float, float, bool, float]: + """(기울기 넘은 만큼 · 토공 가늠 ㎥ · 계류 버퍼 안 나란히 m · 복도 안 · 예상노선 이격 ㎡) — + 탐색 몫과 같은 셈(`SearchGrid.objectives` 이격 · 토공 · 계류 몫) · 기울기는 20 m 사이 고른 + 지반(비교값 잣대).""" + key = np.round(np.asarray(points, dtype=float), 3).tobytes() + if key in self.seen: + return self.seen[key] + if len(self.seen) >= SEEN_LIMIT: + self.seen.clear() + grid = self.grid + xy, chain = _resample(points, SAMPLE_M) + if len(xy) < 2: + return 0.0, 0.0, 0.0, True, 0.0 + z = self._sample(xy)[0] + span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1) + run = np.maximum(chain[span:] - chain[:-span], 1e-9) + excess = max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - self.limit) + cols = np.rint((xy[:, 0] - grid.xs[0]) / grid.cell).astype(int) + rows = np.rint((xy[:, 1] - grid.ys[0]) / grid.cell).astype(int) + rows_n, cols_n = grid.corridor.shape + inside = bool( + ((cols >= 0) & (cols < cols_n) & (rows >= 0) & (rows < rows_n)).all() + and grid.corridor[rows, cols].all() + ) + step = np.diff(xy, axis=0) + length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9) + _, gx, gy, ax, ay, stream, offset = self._sample(0.5 * (xy[1:] + xy[:-1])) + over = np.maximum(0.0, np.abs(np.diff(z)) / length - self.limit) + cross = np.abs(-gx * step[:, 1] + gy * step[:, 0]) / length + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 + size = np.hypot(ax, ay) + across = np.abs(ax * step[:, 0] + ay * step[:, 1]) >= STREAM_CROSS_COS * length * size + along = (stream < grid.stream_offset_m) & ~across & (size > 1e-6) + found = ( + excess, + float((earth * length).sum()), + float(length[along].sum()), + inside, + float((offset * length).sum()), + ) + self.seen[key] = found + return found + + def judge( + self, local: list[Xy], reference: list[Xy] + ) -> tuple[bool, tuple[bool, float, float, float]]: + """(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 기울기 넘음 안 늘어남 · 토공 + `EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭). 잣대(작을수록 좋음) = 복도 밖 · + 늘어난 계류 나란히(m) · 더 넘은 기울기 · 늘어난 토공 가늠(㎥).""" + excess, earth, along, inside, offset = self._look(local) + old_excess, old_earth, old_along, _, old_offset = self._look(reference) + passed = ( + inside + and excess <= old_excess + GRADE_NOISE + and along <= old_along + 1e-6 + and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3 + and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3) + ) + cost = ( + not inside, + round(max(0.0, along - old_along), 3), + round(max(0.0, excess - old_excess), 4), + earth - old_earth, + ) + return passed, cost + + def ok(self, local: list[Xy], reference: list[Xy]) -> bool: + return self.judge(local, reference)[0] + + @staticmethod + def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None: + """곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙).""" + corner = _corner(before, apex, after, radius) + if corner is None: + return None + return [_mid(before, apex), *corner.arc, _mid(apex, after)] + + def curve_ok(self, before: Xy, apex: Xy, after: Xy, radius: float, reference: list[Xy]) -> bool: + local = self.piece(before, apex, after, radius) + return local is not None and self.ok(local, reference) + + +def _join(a: Xy, p: Xy, q: Xy, b: Xy) -> Xy | None: + """같은 쪽으로 도는 이웃 꺾임 p · q → 바깥 두 직선(a-p · q-b) 교점 · S자 · 뒤집힘이면 None.""" + side = _turn_sign(a, p, q) + if side == 0 or side != _turn_sign(p, q, b): + return None + cross = _line_intersection(a, p, q, b) + if cross is None: + return None + ahead = (cross[0] - a[0]) * (p[0] - a[0]) + (cross[1] - a[1]) * (p[1] - a[1]) > 0 and ( + b[0] - cross[0] + ) * (b[0] - q[0]) + (b[1] - cross[1]) * (b[1] - q[1]) > 0 + return (round(cross[0], 4), round(cross[1], 4)) if ahead else None + + +def _radius( + guard: Guard, before: Xy, apex: Xy, after: Xy, min_radius_m: float, reference: _Reference +) -> tuple[float, list[Xy]] | None: + """접선 자리 안 · 직접 잼을 지나는 가장 큰 R(최소 R 이상)과 그 조각 · 없으면 None. + + MC 가 측점 사이 어디에 떨어져도 교점을 들어오는 접선 따라 밀어 놓을 수 있어야 함 + (`PLACE_OFFSETS_M` · 측점 맞춤이 MC 를 늘 측점에).""" + u = ( + (apex[0] - before[0]) / math.dist(before, apex), + (apex[1] - before[1]) / math.dist(before, apex), + ) + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + if half_tan <= 1e-9: + return None + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan + if room < min_radius_m: + return None + steps = np.geomspace(room, min_radius_m, RADIUS_STEPS) + for radius in sorted({max(float(math.floor(r)), min_radius_m) for r in steps}, reverse=True): + local = guard.piece(before, apex, after, radius) + if local is None or not guard.ok(local, reference.window(local)): + continue + seen: dict[float, bool] = {0.0: True} + + def placed(d: float) -> bool: + if d not in seen: + at = (apex[0] + u[0] * d, apex[1] + u[1] * d) + moved = guard.piece(before, at, after, radius) + seen[d] = moved is not None and guard.ok(moved, reference.window(moved)) + return seen[d] + + if all(placed(-t) or placed(STATION_M - t) for t in PLACE_OFFSETS_M): + return radius, local + return None + + +def _earth(metrics: dict[str, Any]) -> float: + return metrics["cut_m3"] + metrics["fill_m3"] + + +def _worse(new: dict[str, Any], base: dict[str, Any]) -> bool: + """노선 전체 잣대가 합치기 전보다 나빠짐(허용 폭 밖).""" + over, was = new["over_limit"], base["over_limit"] + return ( + over["total"] > was["total"] + or over["grade"] > was["grade"] + or new["max_grade_pct"] > base["max_grade_pct"] + GRADE_TOL_PCT + or _earth(new) > _earth(base) * (1 + TOTAL_EARTH_TOL) + ) + + +def merge_same_side( + route: Route, + guard: Guard, + min_radius_m: float, + measure: Callable[[PlannedPolyline], dict[str, Any]], +) -> Route: + """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.""" + outline, base = route + if len(outline.curves) < 2: + return route + points, flags, radii = _flat(outline) + points = [(round(x, 4), round(y, 4)) for x, y in points] + current = route + blocked: set[tuple[Xy, ...]] = set() + while True: + reference = _Reference(current[0].vertices) + found: list[tuple[float, int, Xy, float]] = [] + for k in range(1, len(points) - 2): + key = tuple(points[k - 1 : k + 3]) + if key in blocked: + continue + a, p, q, b = key + cross = _join(a, p, q, b) + fitted = None if cross is None else _radius(guard, a, cross, b, min_radius_m, reference) + if fitted is None: + blocked.add(key) + continue + radius, local = fitted + gap = float( + shapely.hausdorff_distance( + LineString(local), LineString(reference.window(local)), densify=0.1 + ) + ) + found.append((gap, k, cross, radius)) + merged = None + for _, k, cross, radius in sorted(found): + trial_points = points[:k] + [cross] + points[k + 2 :] + trial_flags = flags[:k] + [True] + flags[k + 2 :] + trial_radii = radii[:k] + [radius] + radii[k + 2 :] + line = build_planned_polyline( + trial_points, + min_radius_m=min_radius_m, + simplify=False, + curve_flags=trial_flags, + radii=trial_radii, + ) + metrics = measure(line) + if _worse(metrics, base): + blocked.add(tuple(points[k - 1 : k + 3])) + continue + merged = (trial_points, trial_flags, trial_radii, (line, metrics)) + break + if merged is None: + return current + points, flags, radii, current = merged diff --git a/resources/tester/test_406_1_route_merge.py b/resources/tester/test_406_1_route_merge.py new file mode 100644 index 000000000..742ea0bc7 --- /dev/null +++ b/resources/tester/test_406_1_route_merge.py @@ -0,0 +1,124 @@ +"""406 추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼 — `B05_Profile_Engine_RouteInitial_Merge`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_406_1_route_merge.py -q + +· 같은 쪽으로 도는 이웃 곡선 둘 → 바깥 두 직선 교점 하나(곡선 하나) · S자는 안 합침. +· 막힘 — 지반 기울기(골짜기) · 복도 밖(지형 없음) · 계류 버퍼 안 나란히 · 노선 전체 잣대. +· 큰 R(`widen`) 직접 잼 = 평지면 이탈 한도(4 m) 판보다 큼. +""" + +import numpy as np +from shapely.geometry import LineString, Point + +from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import ( + GRADE_NOISE, + Guard, + merge_same_side, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference +from common_util.common_util_route_polyline import _line_intersection, build_planned_polyline + +RADIUS = 12.0 +GRADE = 0.14 +SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)] # 둘 다 왼쪽으로 돎 +S_CURVE = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (360.0, 80.0)] # 왼쪽 → 오른쪽 +FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0, "cut_m3": 0.0, "fill_m3": 0.0} + + +def _outline(points): + return build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(points) + ) + + +def _guard(points, z=None, valid=None, streams=(), stream_offset_m=100.0): + x = np.arange(0.0, 402.0, 2.0) + y = np.arange(0.0, 262.0, 2.0) + xx, yy = np.meshgrid(x, y) + terrain = Terrain( + x, + y, + np.zeros(xx.shape) if z is None else z(xx, yy), + np.ones(xx.shape, dtype=bool) if valid is None else valid(xx, yy), + ) + grid = SearchGrid(points, terrain, list(streams), stream_offset_m=stream_offset_m) + return Guard(grid, GRADE) + + +def _distance(outline): + line = LineString(outline.vertices) + return np.vectorize(lambda px, py: line.distance(Point(px, py))) + + +def _merge(points, guard, measure=lambda line: FLAT): + outline = _outline(points) + return merge_same_side((outline, FLAT), guard, RADIUS, measure)[0] + + +def test_same_side_pair_merges_to_outer_intersection() -> None: + merged = _merge(SAME, _guard(SAME)) + assert len(merged.curves) == 1 + cross = _line_intersection(*SAME) + assert merged.curves[0].apex == (round(cross[0], 4), round(cross[1], 4)) + assert merged.curves[0].radius_m > RADIUS + + +def test_s_curve_keeps_straight() -> None: + merged = _merge(S_CURVE, _guard(S_CURVE)) + assert len(merged.curves) == 2 + assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0] + + +def test_valley_grade_blocks() -> None: + """옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음.""" + distance = _distance(_outline(SAME)) + guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy)) + assert len(_merge(SAME, guard).curves) == 2 + # 막은 까닭 = 기울기 — 합친 꼴 조각이 옛 자리보다 상한을 더 넘음 + cross = _line_intersection(*SAME) + piece = guard.piece(SAME[0], cross, SAME[-1], 40.0) + window = _Reference(_outline(SAME).vertices).window(piece) + assert guard._look(piece)[0] > guard._look(window)[0] + GRADE_NOISE + + +def test_outside_corridor_blocks() -> None: + distance = _distance(_outline(SAME)) + guard = _guard(SAME, valid=lambda xx, yy: distance(xx, yy) <= 4.0) + assert len(_merge(SAME, guard).curves) == 2 + + +def test_stream_buffer_blocks() -> None: + """평지에서 합친 큰 곡선 가운데에 나란한 계류 — 그 곡선만 버퍼 안을 나란히 지남.""" + merged = _merge(SAME, _guard(SAME)) + arc = LineString(merged.vertices) + middle = arc.interpolate(arc.project(Point(_line_intersection(*SAME)))) + ahead = arc.interpolate(arc.project(middle) + 1.0) + unit = np.subtract(ahead.coords[0], middle.coords[0]) + stream = [tuple(np.add(middle.coords[0], unit * s)) for s in (-12.0, 12.0)] + gap = LineString(_outline(SAME).vertices).distance(middle) + assert gap > 5.0 + guard = _guard(SAME, streams=[stream], stream_offset_m=0.5 * gap) + original = _outline(SAME).vertices + assert guard._look(original)[2] == 0.0 # 옛 선은 버퍼 밖 + # 버퍼 안을 나란히 지나는 큰 R 은 막힘 — 버퍼를 비키는 작은 R 로 합치거나 안 합침 + kept = _merge(SAME, guard) + assert len(kept.curves) == 2 or kept.curves[0].radius_m < merged.curves[0].radius_m + assert guard._look(kept.vertices)[2] == 0.0 + + +def test_route_yardstick_blocks() -> None: + """노선 전체 잣대(넘은 곳 · 최대 기울기 · 절성토)가 합치기 전보다 나빠지면 안 합침.""" + worse = {**FLAT, "max_grade_pct": 5.0} + assert len(_merge(SAME, _guard(SAME), measure=lambda line: worse).curves) == 2 + + +def test_widen_guard_larger_than_gap_limit() -> None: + """곧은 두 직선이 직각으로 만나는 탐색 경로 — 이탈 4 m 면 R 이 못 커짐 · 평지 직접 잼은 큼.""" + path = [(float(x), 20.0) for x in np.arange(20.0, 220.0, 2.0)] + path += [(220.0, float(y)) for y in np.arange(20.0, 222.0, 2.0)] + fitted = build_planned_polyline(path, min_radius_m=RADIUS) + gap = widen(fitted, path, RADIUS) + direct = widen(fitted, path, RADIUS, guard=_guard(path)) + assert direct.curves[0].radius_m > gap.curves[0].radius_m + 20.0