"""D 예정노선 수동 직선 그리기 셈 — [저장] 정본 쪽 (PLAN 562-1 · 요구 389 12 · 13). 구간 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `height_at(x, y)`(nan = 지형 밖). ⚠ 파이썬·TS 짝 — `logic/D03_PlanAnalysis_Draw.ts` 같은 이름(camel). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 `resources/tester/test_562_1_plan_draw.py`. """ from __future__ import annotations import math from collections.abc import Callable, Sequence Vertex = tuple[float, float] HeightAt = Callable[[float, float], float] # 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수 — 짝 TS 상수 SLOPE_STEP_M = 1 BISECT = 40 COMPASS_SAMPLES = 360 EPS = 1e-9 def _finite(value: float) -> bool: return math.isfinite(value) def segment_length(a: Sequence[float], b: Sequence[float]) -> float: """수평 길이(m).""" dx = b[0] - a[0] dy = b[1] - a[1] return math.sqrt(dx * dx + dy * dy) def ground_slope_pct(a: Sequence[float], b: Sequence[float], height_at: HeightAt) -> float | None: """원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · None = 지형 밖 · 길이 0.""" length = segment_length(a, b) if length < EPS: return None za = height_at(a[0], a[1]) zb = height_at(b[0], b[1]) if not _finite(za) or not _finite(zb): return None return ((zb - za) / length) * 100 def fixed_length_point( start: Sequence[float], cursor: Sequence[float], length_m: float ) -> Vertex | None: """길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 None.""" d = segment_length(start, cursor) if d < EPS: return None return ( start[0] + ((cursor[0] - start[0]) / d) * length_m, start[1] + ((cursor[1] - start[1]) / d) * length_m, ) def fixed_slope_point( start: Sequence[float], cursor: Sequence[float], slope_pct: float, height_at: HeightAt, max_m: float = 1000, ) -> Vertex | None: """기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 자리 중 마우스까지 거리에 가장 가까운 자리(1 m 걸음 · 이분 · 같으면 앞 자리) · `max_m` 안에 없으면 None · 지형 밖에 닿으면 거기까지의 자리만.""" d = segment_length(start, cursor) z0 = height_at(start[0], start[1]) if d < EPS or not _finite(z0): return None ux = (cursor[0] - start[0]) / d uy = (cursor[1] - start[1]) / d grade = abs(slope_pct) / 100 def at(t: float) -> Vertex: return (start[0] + ux * t, start[1] + uy * t) def f(t: float) -> float: p = at(t) return abs(height_at(p[0], p[1]) - z0) - grade * t roots: list[float] = [] lo = 0.0 flo = math.nan i = 1 while i * SLOPE_STEP_M <= max_m: t = i * SLOPE_STEP_M i += 1 ft = f(t) if not _finite(ft): break if ft == 0: roots.append(t) lo = t flo = math.nan continue if not math.isnan(flo) and (flo < 0) != (ft < 0): a, b, fa = lo, t, flo ok = True for _ in range(BISECT): mid = (a + b) / 2 fm = f(mid) if not _finite(fm): ok = False break if (fm < 0) == (fa < 0): a = mid fa = fm else: b = mid if ok: roots.append((a + b) / 2) lo = t flo = ft if not roots: return None return at(min(roots, key=lambda r: abs(r - d))) def compass_candidates( start: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt ) -> list[Vertex]: """둘 다(양각기) — 반지름 `length_m` 원 위에서 |기울기| = 목표인 후보들 (방위 표본 · 이분 · 동쪽부터 반시계).""" z0 = height_at(start[0], start[1]) if not length_m > EPS or not _finite(z0): return [] rise = (abs(slope_pct) / 100) * length_m step = (2 * math.pi) / COMPASS_SAMPLES def at(angle: float) -> Vertex: return (start[0] + math.cos(angle) * length_m, start[1] + math.sin(angle) * length_m) def h(angle: float) -> float: p = at(angle) return abs(height_at(p[0], p[1]) - z0) - rise values = [h(k * step) for k in range(COMPASS_SAMPLES)] out: list[Vertex] = [] for k in range(COMPASS_SAMPLES): hk = values[k] hn = values[(k + 1) % COMPASS_SAMPLES] if not _finite(hk) or not _finite(hn): continue if hk == 0: out.append(at(k * step)) continue if hn == 0 or (hk < 0) == (hn < 0): continue lo = k * step hi = lo + step flo = hk ok = True for _ in range(BISECT): mid = (lo + hi) / 2 fm = h(mid) if not _finite(fm): ok = False break if (fm < 0) == (flo < 0): lo = mid flo = fm else: hi = mid if ok: out.append(at((lo + hi) / 2)) return out def compass_point( start: Sequence[float], cursor: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt, ) -> Vertex | None: """둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 None.""" best: Vertex | None = None best_d = math.inf for p in compass_candidates(start, length_m, slope_pct, height_at): d = segment_length(p, cursor) if d < best_d: best = p best_d = d return best def line_length(line: Sequence[Sequence[float]]) -> float: """선 길이(m) — 앞에서부터 더함.""" total = 0.0 for i in range(1, len(line)): total += segment_length(line[i - 1], line[i]) return total def point_at_distance(line: Sequence[Sequence[float]], m: float) -> Vertex: """선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상.""" if m <= 0: return (line[0][0], line[0][1]) acc = 0.0 for i in range(1, len(line)): a = line[i - 1] b = line[i] seg = segment_length(a, b) if seg > 0 and acc + seg >= m: t = (m - acc) / seg return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) acc += seg return (line[-1][0], line[-1][1]) def distance_along(line: Sequence[Sequence[float]], point: Sequence[float]) -> float: """점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간).""" best = 0.0 best_d = math.inf acc = 0.0 for i in range(1, len(line)): a = line[i - 1] b = line[i] seg = segment_length(a, b) t = 0.0 if seg > 0: t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / ( seg * seg ) t = min(1.0, max(0.0, t)) p = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) d = segment_length(p, point) if d < best_d: best_d = d best = acc + seg * t acc += seg return best def slice_line(line: Sequence[Sequence[float]], from_m: float, to_m: float) -> list[Vertex]: """선 조각 — `from_m`~`to_m` 사이(끝점 + 그 사이 꺾임점).""" lo = min(from_m, to_m) hi = max(from_m, to_m) out = [point_at_distance(line, lo)] acc = 0.0 for i in range(1, len(line) - 1): acc += segment_length(line[i - 1], line[i]) if lo + EPS < acc < hi - EPS: out.append((line[i][0], line[i][1])) out.append(point_at_distance(line, hi)) return out def station_chainages(length_m: float, interval_m: float = 20) -> list[float]: """측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터).""" out: list[float] = [0] k = 1 while k * interval_m < length_m - 1e-6: out.append(k * interval_m) k += 1 if length_m > 1e-6: out.append(length_m) return out