- 측점 오프셋 = 측점 값 그대로 지나는 단조 3차(PCHIP) · 비탈은 폭으로 보간 - 사이 점 법선 = 측점 횡단 틀과 같은 앞뒤 ±1 m 식(route_normal · routeNormal 새 함수 · route_frame 그대로) - 네 선 중 하나라도 가운데서 2° 넘게 꺾이는 사이를 반씩 나눔(깊이 4 · 2 m → 12.5 cm) - 진행 거스르는 점 버림(안쪽 비탈 끝 접힘) · 그 자리 눈금 뺌 · 눈금은 측점 · 2 m 자리만 - 깎기↔쌓기 바뀜 자리 = 폭 0(비탈 끝이 도로 끝으로 모임) · 바뀜 점은 앞뒤 조각이 나눠 가짐 - TS ↔ 파이썬(B07 계획평면도) 짝 · 거울 시험 test_353_1 · 옛 계산은 꺾임 · 접힘 · 바뀜 자리 시험 셋 모두 떨어짐 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
362 lines
15 KiB
Python
362 lines
15 KiB
Python
"""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])))
|
||
|
||
|
||
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))
|
||
# 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
|
||
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)):
|
||
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).
|
||
비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||
"""
|
||
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
|
||
}
|
||
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
|
||
# 선다(납품 도면 관례) · 접혀 버린 자리는 뺌.
|
||
ticks: list[list[list[float]]] = []
|
||
kept_sets = {key: set(kept[key]) for key in keys}
|
||
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},
|
||
)
|