Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py
T
eomsangdonandClaude Opus 5.5 3e3215826e fix(B05·B07 평면): 364-3 절/성토선 비탈 끝 모서리 둥글림 · S 자 촘촘히 (TS ↔ 파이썬 짝)
- 까닭 셋: 접힘 버린 자리 건너뜀 모서리(898.6 → 908 m · 139°) · 촘촘히가 가운데 점만 봐 S 자 빠짐(860 → 861 · 70°) · 잔 갈지자
- 촘촘히 = 4분 점도 봄 · 비탈 끝 = 가장 많이 꺾인 점부터 앞뒤 3 m 를 두 접선에 붙는 2차 곡선으로(15° 넘음) · 측점 · 깎기↔쌓기 바뀜 점은 그대로 · 둥글린 자리 눈금 뺌
- 요구 239 세션 자료: 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7° → 0 · 1 · 34.8°(측점 860 · 861 사이 1 m 에 폭 4.4 → 6.0 m)
- 시험 test_364_3_slope_end_round.py(전후 · 측점 그대로 · TS 같은 점) · 자료 fixtures/b05_364_3_footprint_req239.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 23:53:08 +09:00

466 lines
20 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.
"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장).
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut ·
slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단
편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
"""
import math
from bisect import bisect_left, bisect_right
from typing import Any
from config.config_system import DRAWING_SCALE_PLAN
#: 비탈 눈금 간격(1/1,200 실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 · 다른 축척은 종이 간격이
#: 같게(1/6,000 = 10 m · 141-2)
TICK_STEP_M = 2.0
def tick_step_m(scale: int = DRAWING_SCALE_PLAN) -> float:
"""비탈 눈금 간격(실거리 m) — 종이 간격(1/1,200 의 2 m) × 축척."""
return TICK_STEP_M * scale / DRAWING_SCALE_PLAN
# ── 노선 따라가기 ─────────────────────────────────────
def route_frame(
route: list[tuple[float, float, float]], chainage: float
) -> tuple[float, float, float, float]:
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
chainages = [item[0] for item in route]
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
dx, dy = x1 - x0, y1 - y0
length = math.hypot(dx, dy) or 1.0
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
return sorted(
(float(item["offset_m"]), float(item["elevation_m"]))
for item in samples
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
)
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
offsets = [x for x, _z in ground]
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
ground = _ground(samples)
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
def slope_end(
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
) -> float:
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
"""
ground = _ground(samples)
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
outward = (
[p for p in points if p[0] > edge]
if side == "left"
else [p for p in points[::-1] if p[0] < edge]
)
if not ground:
return outward[-1][0] if outward else edge
end, differs = edge, False
for offset, elevation in outward:
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
return offset if differs else end
differs, end = True, offset
return end
def _split_runs(
points: list[list[float]], flags: list[bool]
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
current: list[list[float]] = []
previous: bool | None = None
for point, flag in zip(points, flags):
if flag != previous:
if current:
current.append(point)
current = [point]
runs[flag].append(current)
previous = flag
else:
current.append(point)
return (
[run for run in runs[True] if len(run) >= 2],
[run for run in runs[False] if len(run) >= 2],
)
#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°)
SPLIT_TURN_DEG = 2.0
#: 나눔 깊이 상한(2 m → 12.5 cm)
SPLIT_DEPTH = 4
#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값
NORMAL_PROBE_M = 1.0
def route_normal(
route: list[tuple[float, float, float]], chainage: float
) -> tuple[float, float, float, float]:
"""노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식)."""
x, y, nx, ny = route_frame(route, chainage)
first, last = route[0][0], route[-1][0]
before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M)
if after - before <= 1e-9:
before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3)
x0, y0, _nx, _ny = route_frame(route, before)
x1, y1, _nx, _ny = route_frame(route, after)
length = math.hypot(x1 - x0, y1 - y0)
if length <= 1e-9:
return x, y, nx, ny
return x, y, -(y1 - y0) / length, (x1 - x0) / length
def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]:
"""단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다."""
n = len(xs)
if n < 2:
return [0.0] * n
h = [xs[i + 1] - xs[i] for i in range(n - 1)]
delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)]
slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]]
for k in range(1, n - 1):
if delta[k - 1] * delta[k] > 0:
w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1]
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k])
return slopes
def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float:
"""꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값."""
xs, ys, slopes = knots
if x <= xs[0]:
return ys[0]
if x >= xs[-1]:
return ys[-1]
i = bisect_right(xs, x) - 1
h = xs[i + 1] - xs[i]
t = (x - xs[i]) / h
return (
(1 + 2 * t) * (1 - t) ** 2 * ys[i]
+ t * (1 - t) ** 2 * h * slopes[i]
+ t * t * (3 - 2 * t) * ys[i + 1]
+ t * t * (t - 1) * h * slopes[i + 1]
)
def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]:
return xs, ys, _pchip_slopes(xs, ys)
def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]:
"""한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임)."""
chainages = [record["chainage_m"] for record in records]
edges = [record[f"{side}_edge_m"] for record in records]
widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records]
flags = [bool(record[f"{side}_cut"]) for record in records]
switches: list[float] = []
width_x, width_y = [chainages[0]], [widths[0]]
for i in range(1, len(records)):
c0, c1 = chainages[i - 1], chainages[i]
w0, w1 = abs(widths[i - 1]), abs(widths[i])
switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5)
switches.append(switch)
if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9:
width_x.append(switch)
width_y.append(0.0)
width_x.append(c1)
width_y.append(widths[i])
return {
"chainages": chainages,
"flags": flags,
"switches": switches,
"edge": _knots(chainages, edges),
"width": _knots(width_x, width_y),
}
def _cut_at(curves: dict[str, Any], chainage: float) -> bool:
"""그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐)."""
flags = curves["flags"]
i = bisect_right(curves["chainages"], chainage)
if i == 0:
return flags[0]
if i == len(flags):
return flags[-1]
if flags[i - 1] == flags[i]:
return flags[i]
return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i]
def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]:
"""진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호."""
kept = [0] if points else []
for i in range(1, len(points)):
last = points[kept[-1]]
_x, _y, nx, ny = frames[i]
if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6:
kept.append(i)
return kept
def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float:
u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1])
return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])))
#: 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°)
ROUND_TURN_DEG = 15.0
#: 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다
#: 좁으면 그대로
ROUND_M = 3.0
ROUND_MIN_M = 0.5
#: 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한
ROUND_STEP_DEG = 5.0
ROUND_PASSES = 200
def _dist(a: list[float], b: list[float]) -> float:
return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2)
def _round_corners(
points: list[list[float]], flags: list[bool], pins: list[bool]
) -> tuple[list[list[float]], list[bool], list[int]]:
"""가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈.
측점(pins) · 깎기↔쌓기 바뀜 점 · 양 끝은 그대로 둔다.
돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1).
"""
pts, fl, src = [list(p) for p in points], list(flags), list(range(len(points)))
def pinned(i: int) -> bool:
return src[i] >= 0 and pins[src[i]]
def fixed(i: int) -> bool:
return i == 0 or i == len(pts) - 1 or fl[i] != fl[i - 1] or pinned(i)
def walk(i: int, step: int, length: float) -> tuple[int, list[float], float, list[float]]:
"""i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지).
돌려줌 = (남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향 — i 쪽을 향함).
"""
gone, j = 0.0, i
while True:
k = j + step
seg = _dist(pts[j], pts[k])
toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]]
if gone + seg >= length:
ratio = (length - gone) / seg
at = [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio]
return k, at, length, toward
if fixed(k):
# 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림
return k, pts[k], (gone + seg) / 2.0 if pinned(k) else gone + seg, toward
gone, j = gone + seg, k
def control(a: list[float], ta: list[float], b: list[float], tb: list[float], reach: float):
"""두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 None."""
den = ta[0] * tb[1] - ta[1] * tb[0]
if abs(den) < 1e-12:
return None
s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den
u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den
c = [a[0] + ta[0] * s, a[1] + ta[1] * s]
if s <= 0 or u <= 0 or _dist(a, c) > 2 * reach or _dist(b, c) > 2 * reach:
return None
return c
for _ in range(ROUND_PASSES):
worst, at, reach = ROUND_TURN_DEG, -1, 0.0
for i in range(1, len(pts) - 1):
if fixed(i):
continue
turn = _turn_deg(pts[i - 1], pts[i], pts[i + 1])
if turn > worst:
room = min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2])
if room >= ROUND_MIN_M:
worst, at, reach = turn, i, room
if at < 0:
break
before, a, _gone, ta = walk(at, -1, reach)
after, b, _gone, tb = walk(at, 1, reach)
p = control(a, ta, b, tb, reach) or pts[at]
count = max(2, math.ceil(worst / ROUND_STEP_DEG))
curve = []
for n in range(count + 1):
t = n / count
point = [
round((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4)
for k in (0, 1)
]
if _dist(point, pts[before]) > 1e-6 and _dist(point, pts[after]) > 1e-6:
curve.append(point)
pts[before + 1 : after] = curve
fl[before + 1 : after] = [fl[at]] * len(curve)
src[before + 1 : after] = [-1] * len(curve)
return pts, fl, src
def _densify(
chainages: list[float], frame_of: Any, place: Any
) -> tuple[list[float], list[tuple[float, float, float, float]]]:
"""곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔."""
def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]:
if depth >= SPLIT_DEPTH:
return []
middle = (c0 + c1) / 2.0
pm = place(middle, frame_of(middle))
# 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3)
q1 = place((c0 + middle) / 2.0, frame_of((c0 + middle) / 2.0))
q3 = place((middle + c1) / 2.0, frame_of((middle + c1) / 2.0))
triples = [*zip(p0, pm, p1), *zip(p0, q1, pm), *zip(pm, q3, p1)]
# 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in triples):
return []
return (
split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1)
)
points = [place(chainage, frame_of(chainage)) for chainage in chainages]
out = chainages[:1]
for i in range(1, len(chainages)):
out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)
out.append(chainages[i])
return out, [frame_of(chainage) for chainage in out]
def slope_pieces(
route: list[tuple[float, float, float]],
records: list[dict[str, Any]],
start_m: float,
end_m: float,
step_m: float = TICK_STEP_M,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차
보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는
점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). 버린 자리 · 잔
갈지자에 남은 비탈 끝 모서리는 측점을 두고 둥글린다(364-3).
비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
"""
sorted_records = sorted(
(
{**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}}
for record in records
),
key=lambda record: record["chainage_m"],
)
if len(route) < 2 or not sorted_records:
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
# 같은 누가거리 측점은 뒤 것 하나만
records = []
for record in sorted_records:
if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9:
records[-1] = record
else:
records.append(record)
sides = ("left", "right")
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
curves = {side: _side_curves(records, side) for side in sides}
by_chainage = {round(record["chainage_m"], 3): record for record in records}
base = {
round(record["chainage_m"], 3)
for record in records
if start_m <= record["chainage_m"] <= end_m
}
step = start_m
while step <= end_m:
base.add(round(step, 3))
step += step_m
switches = {
at
for side in sides
for i, at in enumerate(curves[side]["switches"])
if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m
}
def frame_of(chainage: float) -> tuple[float, float, float, float]:
record = by_chainage.get(round(chainage, 3))
if record and all(k in record for k in ("x", "y", "nx", "ny")):
return record["x"], record["y"], record["nx"], record["ny"]
return route_normal(route, chainage)
def values_at(chainage: float) -> dict[str, float]:
values: dict[str, float] = {}
for side in sides:
edge = _pchip(curves[side]["edge"], chainage)
values[f"{side}_edge_m"] = edge
values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage)
return values
def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]:
x, y, nx, ny = frame
values = values_at(chainage)
return [[x + nx * values[key], y + ny * values[key]] for key in keys]
chainages, frames = _densify(sorted(base | switches), frame_of, place)
offsets = [values_at(chainage) for chainage in chainages]
raw: dict[str, list[list[float]]] = {key: [] for key in keys}
for (x, y, nx, ny), values in zip(frames, offsets):
for key in keys:
raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)])
kept = {key: _forward_only(raw[key], frames) for key in keys}
lines = {key: [raw[key][i] for i in kept[key]] for key in keys}
cut = {
side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides
}
kept_sets = {key: set(kept[key]) for key in keys}
for side in sides:
key = f"{side}_end_m"
pins = [round(chainages[i], 3) in by_chainage for i in kept[key]]
lines[key], cut[side], src = _round_corners(lines[key], cut[side], pins)
kept_sets[key] = {kept[key][j] for j in src if j >= 0}
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
# 선다(납품 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌.
ticks: list[list[list[float]]] = []
position = {chainage: i for i, chainage in enumerate(chainages)}
for index, chainage in enumerate(sorted(base)):
i = position[chainage]
x, y, nx, ny = frames[i]
for side in sides:
edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"]
if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]:
continue
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]])
cut_lines: list[list[list[float]]] = []
fill_lines: list[list[list[float]]] = []
for side in sides:
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
cut_lines += cut_runs
fill_lines += fill_runs
return (
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
{"lines": fill_lines},
{"lines": cut_lines},
{"ticks": ticks},
)