From db7004964d20ffe95e0834472a4bf43fe371c104 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 07:49:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=E3=80=8C=EC=A7=81=EC=84=A0=20>=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20>=20=EC=A7=81=EC=84=A0=E3=80=8D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EB=B0=98=EC=A7=80=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07) — 「계획노선은 직선>곡선>직선 형태의 폴리라인임. 반지름은 법정 최소 값을 지키되 기존 예정노선에 가까운 폴리라인을 찾는 게 키임.」 되물어 확정: 반지름 상한은 두지 않고 원본에 가장 가까운 값으로. 옛 방식은 꺾임점마다 법정 하한(R 12m) 원호를 하나씩 끼웠음. 그래서 완만한 긴 곡선이 작은 원호 여러 개로 쪼개져 원본과 벌어졌음. 고친 것 셋 1. 이어진 꺾임을 **한 곡선으로 묶음**(_curve_runs). 같은 쪽으로 도는 꺾임을 모으고, 앞뒤 직선을 늘려 만나는 자리를 그 곡선의 교각점으로 씀. 2. 그 안에서 **반지름을 골라 맞춤**(_fit_radius_m) — 법정 하한 이상에서 예정노선 점들과의 평균 벗어남이 가장 작은 값. 3. 묶는 것이 손해면 **도로 쪼갬**(_split_wide_runs) — 평균이 나빠지거나 한 자리라도 8m 넘게 벌어지면 낱개로 둠. 별표2 의 155°(곡선 생략) 규칙은 **묶은 뒤 전체 교각**에 적용하도록 자리를 옮김. 조각 하나하나에 적용하면 완만한 곡선이 쪼개졌음(내각 156·138·154° 짜리 세 꺾임이 갈려 가운데 하나만 곡선이 됐음). 실측 — 흉내낸 매끄러운 곡선에서 원본 반지름을 되찾음 원본 R 25m → 25.1m (최대 벗어남 0.12m) 전에는 R 12·12·12m, 1.66m 원본 R 40m → 39.8m (0.09m) 전에는 R 12·12·12m, 2.66m 원본 R 80m → 78.2m (0.44m) 전에는 R 12 다섯 개, 1.31m 실제 노선 3개(격자 탐색이 낸 지그재그)는 중립 — 최대 벗어남 3.57m 그대로, 평균 1.082 → 1.100m. 급한 꺾임에서는 하한이 이미 최선이라 그럼. 시험 tmp/tests/test_route_polyline_curve_fit.py 4건 신설 — 반지름 되찾기, 하한 지키기, 직각은 하한 그대로, 손해면 안 묶기. pytest 420 passed. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_route_polyline.py | 425 +++++++++++++++++++--- 1 file changed, 380 insertions(+), 45 deletions(-) diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 8f0dbd11..35c59d9a 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -51,6 +51,22 @@ ARC_STEP_DEG = 5.0 # 이 값 이상으로 펴진 자리는 곡선을 두지 않는다(별표2: 내각 155° 이상). STRAIGHT_INNER_ANGLE_DEG = 155.0 +# 한 곡선으로 **묶을지** 볼 때 쓰는 문턱(도) — 위 155° 와 쓰임이 다르다. +# +# 왜 나눴나(2026-09-07 실측) — 155° 를 묶기 판정에까지 쓰면 **완만한 곡선이 쪼개진다**. +# 반지름 40m 짜리 원호를 점으로 흉내 내 넣으니 꺾임점 셋(내각 156°·138°·154°)이 나왔는데 +# 양 끝 둘이 155° 위라 「직선」으로 갈려 가운데 하나만 곡선이 됐고, 그 짧은 현 사이에서는 +# 반지름을 키울수록 원본에서 멀어졌다(평균 1.75 → 2.19m). 별표2 의 155° 는 **그 곡선의 +# 교각**에 대한 규칙이지, 점군을 쪼갠 조각 하나하나에 대한 규칙이 아니다. +# 그래서 묶을 때는 「조금이라도 같은 쪽으로 돈다」로 모으고, 155° 는 **묶은 뒤 전체 교각**에 +# 적용한다. +CURVE_GROUP_INNER_ANGLE_DEG = 179.0 + +# 여러 꺾임을 한 곡선으로 묶을 때 **허용할 최대 벗어남**(m). 평균이 나아져도 한 자리가 +# 이보다 크게 벌어지면 안 묶고 낱개로 둔다. 꺾임점 뽑기 허용오차(4m)의 두 배 — 노드 자체가 +# 원본에서 4m 안에 있으므로, 곡선이 그보다 크게 벗어나면 다른 모양이 된 것이다. +MERGE_MAX_GAP_M = 8.0 + @dataclass class RouteNode: @@ -201,6 +217,133 @@ def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tup return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length) +def _node_indices( + points: list[tuple[float, float]], nodes: list[tuple[float, float]] +) -> list[int] | None: + """노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다. + + 사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다). + """ + indices: list[int] = [] + cursor = 0 + for node in nodes: + while cursor < len(points) and points[cursor] != node: + cursor += 1 + if cursor >= len(points): + return None + indices.append(cursor) + cursor += 1 + return indices + + +def _point_to_segment_m( + point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] +) -> float: + """점에서 선분까지 거리(m).""" + dx, dy = end[0] - start[0], end[1] - start[1] + if dx == 0 and dy == 0: + return _distance(point, start) + ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy) + ratio = max(0.0, min(1.0, ratio)) + return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio)) + + +def _corner_deviation_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + after: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + center: tuple[float, float], + radius: float, +) -> float: + """예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m). + + 사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다. + 그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다. + + ⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지 + 않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을 + 가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이 + 골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다. + """ + if not samples: + return 0.0 + total = 0.0 + for sample in samples: + gap = min( + _point_to_segment_m(sample, before, start), + _point_to_segment_m(sample, end, after), + # 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워 + # 위 두 값이 이미 그것을 담는다. + abs(_distance(sample, center) - radius), + ) + total += gap + return total / len(samples) + + +def _arc_geometry( + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + radius: float, + half_tan: float, +) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None: + """반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None.""" + tangent = radius * half_tan + to_before = _unit(at, before) + to_after = _unit(at, after) + start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) + end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) + bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) + bisector_length = math.hypot(*bisector) + if bisector_length <= 1e-9: + return None + center_distance = radius / math.sin(math.radians(inner_deg) / 2) + center = ( + at[0] + bisector[0] / bisector_length * center_distance, + at[1] + bisector[1] / bisector_length * center_distance, + ) + return start, end, center + + +def _fit_radius_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + half_tan: float, + min_radius_m: float, + max_radius_m: float, + steps: int = 48, +) -> float: + """법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다. + + 왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존 + 예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만 + (중앙종거 M = R(1/cos(Δ/2) − 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은 + 반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다. + """ + if not samples or max_radius_m <= min_radius_m: + return min_radius_m + best_radius = min_radius_m + best_gap = float("inf") + for step in range(steps + 1): + # 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다. + ratio = step / steps + radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio + geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan) + if geometry is None: + continue + start, end, center = geometry + gap = _corner_deviation_m(samples, before, after, start, end, center, radius) + if gap < best_gap - 1e-9: + best_gap, best_radius = gap, radius + return best_radius + + def _arc_points( center: tuple[float, float], start: tuple[float, float], @@ -230,6 +373,174 @@ def _arc_points( ] +def _turn_sign( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> int: + """도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0.""" + cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) + if abs(cross) <= 1e-9: + return 0 + return 1 if cross > 0 else -1 + + +def _line_intersection( + a1: tuple[float, float], + a2: tuple[float, float], + b1: tuple[float, float], + b2: tuple[float, float], +) -> tuple[float, float] | None: + """두 **직선**(선분 아님)의 교차점. 나란하면 None.""" + dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1] + dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1] + denominator = dx1 * dy2 - dy1 * dx2 + if abs(denominator) <= 1e-12: + return None + t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator + return (a1[0] + dx1 * t, a1[1] + dy1 * t) + + +def _curve_runs( + cleaned: list[tuple[float, float]], straight_inner_angle_deg: float +) -> list[tuple[int, int]]: + """**한 곡선으로 묶을 꺾임점 구간**을 고른다 — `[(첫 꺾임점, 끝 꺾임점)]`. + + 왜 묶나(2026-09-07 실측) — 꺾임점 하나마다 원호를 끼우면 **긴 완만한 곡선을 만들 수 없다**. + 실제로 반지름 40m 짜리 원호를 점으로 흉내 내 넣어 봤더니, 그 곡선이 꺾임점 세 개로 쪼개져 + 각각에 작은 원호가 들어갔고 반지름을 키울수록 원본에서 **멀어졌다**(평균 벗어남 + 1.56m → 1.89m). 한 곡선은 한 원호여야 한다 — 사용자가 말한 「직선 > 곡선 > 직선」이 그것이다. + + 묶는 규칙: 곡선 대상(내각이 기준 미만)이면서 **도는 방향이 같은** 꺾임점이 이어지면 한 묶음. + """ + runs: list[tuple[int, int]] = [] + index = 1 + while index < len(cleaned) - 1: + inner = _inner_angle_deg(cleaned[index - 1], cleaned[index], cleaned[index + 1]) + if inner >= CURVE_GROUP_INNER_ANGLE_DEG: + index += 1 + continue + sign = _turn_sign(cleaned[index - 1], cleaned[index], cleaned[index + 1]) + end = index + while end + 1 < len(cleaned) - 1: + following = end + 1 + inner_next = _inner_angle_deg( + cleaned[following - 1], cleaned[following], cleaned[following + 1] + ) + if inner_next >= CURVE_GROUP_INNER_ANGLE_DEG: + break + if ( + _turn_sign(cleaned[following - 1], cleaned[following], cleaned[following + 1]) + != sign + ): + break + end = following + # 별표2 의 155° 는 **묶은 뒤 전체 교각**에 적용한다 — 앞뒤 직선이 이루는 내각이 + # 그만큼 펴져 있으면 곡선을 두지 않는다. + apex = ( + cleaned[index] + if index == end + else _line_intersection( + cleaned[index - 1], cleaned[index], cleaned[end], cleaned[end + 1] + ) + ) + total_inner = ( + 180.0 if apex is None else _inner_angle_deg(cleaned[index - 1], apex, cleaned[end + 1]) + ) + if total_inner < straight_inner_angle_deg: + runs.append((index, end)) + index = end + 1 + return runs + + +def _run_worst_gap_m( + first: int, + last: int, + cleaned: list[tuple[float, float]], + samples: list[tuple[float, float]], + min_radius_m: float, +) -> tuple[float, float]: + """그 묶음을 **한 곡선**으로 폈을 때 예정노선에서 벗어나는 (최대, 평균) 거리(m). + + 못 끼우면 (무한대, 무한대). + """ + entry_from = cleaned[first - 1] + exit_to = cleaned[last + 1] + apex = ( + cleaned[first] + if first == last + else _line_intersection(entry_from, cleaned[first], cleaned[last], exit_to) + ) + if apex is None: + return float("inf"), float("inf") + inner = _inner_angle_deg(entry_from, apex, exit_to) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 + if half_tan <= 1e-9 or available <= 0: + return float("inf"), float("inf") + radius = min( + _fit_radius_m( + samples, entry_from, apex, exit_to, inner, half_tan, min_radius_m, available / half_tan + ), + available / half_tan, + ) + geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) + if geometry is None: + return float("inf"), float("inf") + start, end, center = geometry + line = [ + entry_from, + start, + *_arc_points(center, start, end, _turn_sign(entry_from, apex, exit_to) < 0), + end, + exit_to, + ] + gaps = [ + min(_point_to_segment_m(sample, a, b) for a, b in zip(line, line[1:])) for sample in samples + ] + return max(gaps), sum(gaps) / len(gaps) + + +def _split_wide_runs( + runs: list[tuple[int, int]], + cleaned: list[tuple[float, float]], + deduped: list[tuple[float, float]], + node_indices: list[int], + min_radius_m: float, +) -> list[tuple[int, int]]: + """묶는 것이 **손해면 도로 쪼갠다** — 한 곡선으로 펴서 원본에서 더 멀어지면 안 묶는다. + + 왜(2026-09-07 실측) — 이어진 꺾임을 한 곡선으로 묶으면 완만한 굴곡은 잘 따라가지만 + (반지름 40m 짜리 시험 곡선: 평균 벗어남 0.385 → 0.250m), 성격이 다른 굴곡이 이어 붙은 + 자리를 통째로 묶으면 **최대 벗어남이 3.6m → 15.2m** 로 벌어졌다. 그래서 묶음마다 + 「묶었을 때」와 「낱개로 뒀을 때」를 재서 **원본에 더 가까운 쪽**을 고른다. + """ + result: list[tuple[int, int]] = [] + for first, last in runs: + if first == last: + result.append((first, last)) + continue + samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] + merged_max, merged_mean = _run_worst_gap_m(first, last, cleaned, samples, min_radius_m) + apart = [ + _run_worst_gap_m( + index, + index, + cleaned, + deduped[node_indices[index - 1] : node_indices[index + 1] + 1], + min_radius_m, + ) + for index in range(first, last + 1) + ] + apart_max = max(item[0] for item in apart) + apart_mean = sum(item[1] for item in apart) / len(apart) + # 평균이 나으면 묶는다 — 「직선 > 곡선 > 직선」이 사용자가 원한 모양이라 조각을 + # 늘리기보다 한 곡선을 우선한다. 다만 **한 자리라도 크게 벌어지면** 안 묶는다. + if merged_mean <= apart_mean and merged_max <= max(apart_max, MERGE_MAX_GAP_M): + result.append((first, last)) + else: + result.extend((index, index) for index in range(first, last + 1)) + return result + + def build_planned_polyline( points: list[tuple[float, float]], *, @@ -251,45 +562,82 @@ def build_planned_polyline( 119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고, 그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.** """ - cleaned = simplify_to_nodes(dedupe_points(points)) if simplify else dedupe_points(points) + deduped = dedupe_points(points) + cleaned = simplify_to_nodes(deduped) if simplify else deduped if len(cleaned) < 3: nodes = [RouteNode(x=x, y=y) for x, y in cleaned] return PlannedPolyline(nodes=nodes, vertices=list(cleaned)) + # 반지름을 예정노선에 맞추려면 **꺾임점 사이의 원본 점**이 있어야 한다. 사용자가 옮긴 + # 노드를 받은 경우(`simplify=False`)나 자리를 못 찾는 경우에는 피팅을 건너뛴다. + node_indices = _node_indices(deduped, cleaned) if simplify else None + nodes = [RouteNode(x=x, y=y) for x, y in cleaned] - vertices: list[tuple[float, float]] = [cleaned[0]] - for index in range(1, len(cleaned) - 1): - before, at, after = cleaned[index - 1], cleaned[index], cleaned[index + 1] - inner = _inner_angle_deg(before, at, after) - node = nodes[index] - node.inner_angle_deg = inner - if inner >= straight_inner_angle_deg: - # 별표2 — 내각 155° 이상은 곡선을 두지 않을 수 있다. 점을 그대로 잇는다. - vertices.append(at) + nodes[index].inner_angle_deg = _inner_angle_deg( + cleaned[index - 1], cleaned[index], cleaned[index + 1] + ) + + runs = _curve_runs(cleaned, straight_inner_angle_deg) + if node_indices is not None: + runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m) + vertices: list[tuple[float, float]] = [cleaned[0]] + cursor = 0 # 아직 선에 안 실은 첫 꺾임점 + + for first, last in runs: + # 곡선 앞뒤의 **직선**. 묶음 안 꺾임점은 그 곡선이 대신하므로 선에 넣지 않는다. + entry_from, entry_to = cleaned[first - 1], cleaned[first] + exit_from, exit_to = cleaned[last], cleaned[last + 1] + # 두 직선을 늘려 만나는 자리가 이 곡선의 꺾임점(IP)이다. 묶음이 하나면 그 노드 자신. + apex = ( + cleaned[first] + if first == last + else _line_intersection(entry_from, entry_to, exit_from, exit_to) + ) + node = nodes[first] + if apex is None: # 두 직선이 나란하다 — 곡선을 못 끼운다. + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last continue - deflection = math.radians(180.0 - inner) # 교각(IA) - half_tan = math.tan(deflection / 2) - if half_tan <= 1e-9: - vertices.append(at) + inner = _inner_angle_deg(entry_from, apex, exit_to) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + # 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 + if half_tan <= 1e-9 or available <= 0: + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last continue - # 접선이 들어갈 자리 — 앞뒤 구간을 이웃 곡선과 나눠 쓰므로 절반까지만 쓴다. - available = min(_distance(before, at), _distance(at, after)) / 2 - # 반지름은 **법정 하한 그대로** 쓴다 — 사용자가 「지식DB 의 R 기준값으로 선을 만들라」고 - # 한 그 값이고, 기하로도 하한이 원본에 가장 가깝다: 중앙종거 M = R(1/cos(Δ/2) − 1) 이라 - # R 이 클수록 원호가 꺾임점에서 멀어진다. 원본 굴곡에서 R 을 재어 키우는 방법도 재 봤으나 - # (2026-09-06) 직각 꺾임에서 R 70m·중앙종거 20m 가 나와 원본과 크게 어긋났다. - # 곡선마다 R 을 다르게 주는 것은 사용자가 노드에서 고르는 별도 기능으로 둔다(PLAN 0-10). + # 반지름은 **법정 하한 이상에서 예정노선에 가장 가까운 값**(2026-09-07 사용자 확정). + # 자리가 모자라면 하한 아래로 줄이되 **막지 않고 위반으로 표시**한다(기존 규칙). radius = min_radius_m + if node_indices is not None: + samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] + radius = _fit_radius_m( + samples, + entry_from, + apex, + exit_to, + inner, + half_tan, + min_radius_m, + available / half_tan, + ) tangent = radius * half_tan if tangent > available: radius = available / half_tan tangent = available - if radius <= 0: - vertices.append(at) + + geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) + if radius <= 0 or geometry is None: + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last continue + start, end, center = geometry if radius < min_radius_m: node.violations.append(f"최소곡선반지름 미달({radius:.1f} < {min_radius_m:.1f}m)") @@ -297,32 +645,19 @@ def build_planned_polyline( node.violations.append( f"배향곡선 하한 미달({radius:.1f} < {hairpin_min_radius_m:.1f}m)" ) - node.radius_m = radius node.tangent_m = tangent - to_before = _unit(at, before) - to_after = _unit(at, after) - start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) - end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) - # 중심은 두 접선의 이등분 방향으로 R/sin(내각/2) 만큼 떨어진 자리다. - bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) - bisector_length = math.hypot(*bisector) - if bisector_length <= 1e-9: # 완전히 되돌아가는 자리 — 원호를 못 끼운다. - node.radius_m = None - node.tangent_m = None - vertices.append(at) - continue - center_distance = radius / math.sin(math.radians(inner) / 2) - center = ( - at[0] + bisector[0] / bisector_length * center_distance, - at[1] + bisector[1] / bisector_length * center_distance, - ) - # 도는 방향 — 진행 방향 기준 외적 부호. - cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) + # 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다(이 묶음 앞까지). + for index in range(cursor + 1, first): + vertices.append(cleaned[index]) + cursor = last + + clockwise = _turn_sign(entry_from, apex, exit_to) < 0 vertices.append(start) - vertices.extend(_arc_points(center, start, end, clockwise=cross < 0)) + vertices.extend(_arc_points(center, start, end, clockwise=clockwise)) vertices.append(end) - vertices.append(cleaned[-1]) + for index in range(cursor + 1, len(cleaned)): + vertices.append(cleaned[index]) return PlannedPolyline(nodes=nodes, vertices=vertices)