From ae24e29d124b982573cf6d2229db1665271ee8aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:58:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(562-1):=20D03=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EC=85=88=20=EC=A7=9D=20=E2=80=94?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EC=9B=90=EC=A7=80=EB=B0=98=20?= =?UTF-8?q?=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=C2=B7=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=96=91=EA=B0=81=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20=EC=9C=84=20=EA=B1=B0=EB=A6=AC=20=E2=86=94?= =?UTF-8?q?=20=EC=A0=90=20=C2=B7=20=EC=B8=A1=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/D03_PlanAnalysis_Draw.ts ↔ D03_PlanAnalysis_Draw.py 같은 이름 짝 · 높이는 인자 heightAt - 거울 시험 test_562_1_plan_draw.py — 두 쪽 값 대조 + 손셈 값 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Draw.py | 253 ++++++++++++++++++ .../logic/D03_PlanAnalysis_Draw.ts | 236 ++++++++++++++++ resources/tester/test_562_1_plan_draw.py | 203 ++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Draw.py create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts create mode 100644 resources/tester/test_562_1_plan_draw.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py new file mode 100644 index 000000000..d30968ecf --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py @@ -0,0 +1,253 @@ +"""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 + + lo = 0.0 + flo = math.nan + i = 1 + while i * SLOPE_STEP_M <= max_m: + t = i * SLOPE_STEP_M + ft = f(t) + if not _finite(ft): + return None + if ft == 0: + return at(t) + if i > 1 and (flo < 0) != (ft < 0): + hi = t + for _ in range(BISECT): + mid = (lo + hi) / 2 + fm = f(mid) + if not _finite(fm): + return None + if (fm < 0) == (flo < 0): + lo = mid + flo = fm + else: + hi = mid + return at((lo + hi) / 2) + lo = t + flo = ft + i += 1 + return None + + +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 diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts new file mode 100644 index 000000000..daa3a7fcd --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_Draw.ts + * D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 · + * 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖). + * DOM · fetch · sessionStorage 없음. + * + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * ========================================================================== */ + +export type Vertex = [number, number]; +export type HeightAt = (x: number, y: number) => number; + +/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */ +const SLOPE_STEP_M = 1; +const BISECT = 40; +const COMPASS_SAMPLES = 360; +const EPS = 1e-9; + +/** 수평 길이(m). */ +export function segmentLength(a: Vertex, b: Vertex): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */ +export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null { + const length = segmentLength(a, b); + if (length < EPS) return null; + const za = heightAt(a[0], a[1]); + const zb = heightAt(b[0], b[1]); + if (!Number.isFinite(za) || !Number.isFinite(zb)) return null; + return ((zb - za) / length) * 100; +} + +/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */ +export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { + const d = segmentLength(from, cursor); + if (d < EPS) return null; + return [ + from[0] + ((cursor[0] - from[0]) / d) * lengthM, + from[1] + ((cursor[1] - from[1]) / d) * lengthM, + ]; +} + +/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · + * `maxM` 안에 없거나 지형 밖에 닿으면 null. */ +export function fixedSlopePoint( + from: Vertex, + cursor: Vertex, + slopePct: number, + heightAt: HeightAt, + maxM: number = 1000, +): Vertex | null { + const d = segmentLength(from, cursor); + const z0 = heightAt(from[0], from[1]); + if (d < EPS || !Number.isFinite(z0)) return null; + const ux = (cursor[0] - from[0]) / d; + const uy = (cursor[1] - from[1]) / d; + const grade = Math.abs(slopePct) / 100; + const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t]; + const f = (t: number): number => { + const p = at(t); + return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t; + }; + let lo = 0; + let flo = NaN; + for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) { + const t = i * SLOPE_STEP_M; + const ft = f(t); + if (!Number.isFinite(ft)) return null; + if (ft === 0) return at(t); + if (i > 1 && flo < 0 !== ft < 0) { + let hi = t; + for (let k = 0; k < BISECT; k += 1) { + const mid = (lo + hi) / 2; + const fm = f(mid); + if (!Number.isFinite(fm)) return null; + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + return at((lo + hi) / 2); + } + lo = t; + flo = ft; + } + return null; +} + +/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터 + * 반시계). */ +export function compassCandidates( + from: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex[] { + const z0 = heightAt(from[0], from[1]); + if (!(lengthM > EPS) || !Number.isFinite(z0)) return []; + const rise = (Math.abs(slopePct) / 100) * lengthM; + const step = (2 * Math.PI) / COMPASS_SAMPLES; + const at = (angle: number): Vertex => [ + from[0] + Math.cos(angle) * lengthM, + from[1] + Math.sin(angle) * lengthM, + ]; + const h = (angle: number): number => { + const p = at(angle); + return Math.abs(heightAt(p[0], p[1]) - z0) - rise; + }; + const values: number[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step)); + const out: Vertex[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) { + const hk = values[k]; + const hn = values[(k + 1) % COMPASS_SAMPLES]; + if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue; + if (hk === 0) { + out.push(at(k * step)); + continue; + } + if (hn === 0 || hk < 0 === hn < 0) continue; + let lo = k * step; + let hi = lo + step; + let flo = hk; + let ok = true; + for (let i = 0; i < BISECT; i += 1) { + const mid = (lo + hi) / 2; + const fm = h(mid); + if (!Number.isFinite(fm)) { + ok = false; + break; + } + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + if (ok) out.push(at((lo + hi) / 2)); + } + return out; +} + +/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */ +export function compassPoint( + from: Vertex, + cursor: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex | null { + let best: Vertex | null = null; + let bestD = Infinity; + for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) { + const d = segmentLength(p, cursor); + if (d < bestD) { + best = p; + bestD = d; + } + } + return best; +} + +/** 선 길이(m) — 앞에서부터 더함. */ +export function lineLength(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]); + return total; +} + +/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */ +export function pointAtDistance(line: ReadonlyArray, m: number): Vertex { + if (m <= 0) return [line[0][0], line[0][1]]; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + if (seg > 0 && acc + seg >= m) { + const t = (m - acc) / seg; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + acc += seg; + } + const last = line[line.length - 1]; + return [last[0], last[1]]; +} + +/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */ +export function distanceAlong(line: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestD = Infinity; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + let t = 0; + if (seg > 0) { + t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg); + t = Math.min(1, Math.max(0, t)); + } + const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + const d = segmentLength(p, point); + if (d < bestD) { + bestD = d; + best = acc + seg * t; + } + acc += seg; + } + return best; +} + +/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */ +export function sliceLine(line: ReadonlyArray, fromM: number, toM: number): Vertex[] { + const lo = Math.min(fromM, toM); + const hi = Math.max(fromM, toM); + const out: Vertex[] = [pointAtDistance(line, lo)]; + let acc = 0; + for (let i = 1; i < line.length - 1; i += 1) { + acc += segmentLength(line[i - 1], line[i]); + if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]); + } + out.push(pointAtDistance(line, hi)); + return out; +} + +/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */ +export function stationChainages(lengthM: number, intervalM: number = 20): number[] { + const out = [0]; + for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM); + if (lengthM > 1e-6) out.push(lengthM); + return out; +} diff --git a/resources/tester/test_562_1_plan_draw.py b/resources/tester/test_562_1_plan_draw.py new file mode 100644 index 000000000..e42bff53d --- /dev/null +++ b/resources/tester/test_562_1_plan_draw.py @@ -0,0 +1,203 @@ +"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔ +TS `logic/D03_PlanAnalysis_Draw.ts`. + +같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값. + +./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402 + +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def hill(x: float, y: float) -> float: + if x > 800: + return math.nan + return 0.0005 * x * x + 3 * math.sin(y / 40) + + +def plane(x: float, y: float) -> float: + return 0.1 * x + + +LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m + +# (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김 +CASES = [ + ("segmentLength", [[1, 2], [4, 6]]), + ("groundSlopePct", [[0, 0], [100, 30], "hill"]), + ("groundSlopePct", [[100, 30], [0, 0], "hill"]), + ("groundSlopePct", [[700, 0], [900, 0], "hill"]), + ("groundSlopePct", [[5, 5], [5, 5], "hill"]), + ("fixedLengthPoint", [[10, 10], [13, 14], 20]), + ("fixedLengthPoint", [[10, 10], [10, 10], 20]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]), + ("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]), + ("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]), + ("compassCandidates", [[0, 0], 50, 5, "plane"]), + ("compassCandidates", [[200, 10], 60, 15, "hill"]), + ("compassCandidates", [[0, 0], 50, 50, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]), + ("lineLength", [LINE]), + ("pointAtDistance", [LINE, -5]), + ("pointAtDistance", [LINE, 75]), + ("pointAtDistance", [LINE, 500]), + ("distanceAlong", [LINE, [40, 70]]), + ("distanceAlong", [LINE, [-10, -10]]), + ("sliceLine", [LINE, 25, 160]), + ("sliceLine", [LINE, 160, 25]), + ("stationChainages", [210]), + ("stationChainages", [40, 20]), + ("stationChainages", [0]), +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; +const heights = { + hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)), + plane: (x, y) => 0.1 * x, +}; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([name, args]) => + draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))), +); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +_HEIGHTS = {"hill": hill, "plane": plane} +_PY = { + "segmentLength": draw.segment_length, + "groundSlopePct": draw.ground_slope_pct, + "fixedLengthPoint": draw.fixed_length_point, + "fixedSlopePoint": draw.fixed_slope_point, + "compassCandidates": draw.compass_candidates, + "compassPoint": draw.compass_point, + "lineLength": draw.line_length, + "pointAtDistance": draw.point_at_distance, + "distanceAlong": draw.distance_along, + "sliceLine": draw.slice_line, + "stationChainages": draw.station_chainages, +} + + +def _py(name: str, args: list) -> object: + return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args)) + + +def _close(a: object, b: object) -> bool: + if a is None or b is None: + return a is b + if isinstance(a, (int, float)): + return isinstance(b, (int, float)) and abs(a - b) < 1e-6 + return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type] + + +@pytest.fixture(scope="module") +def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list: + tmp = tmp_path_factory.mktemp("draw") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + job = tmp / "job.json" + result = tmp / "out.json" + job.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_ts_matches_python(ts_out: list) -> None: + for (name, args), ts in zip(CASES, ts_out): + assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args)) + + +def test_hand_values() -> None: + assert draw.segment_length((1, 2), (4, 6)) == 5 + # 오르막 + · 내리막 − · 지형 밖 · 길이 0 + up = draw.ground_slope_pct((0, 0), (100, 0), hill) + assert up == pytest.approx(5.0) + assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0) + assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None + assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None + assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26)) + # z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180 + # 부호 무관 · 한도 100 m = 없음 + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None + # 지형 밖에 닿음 + assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None + # 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300° + got = draw.compass_candidates((0, 0), 50, 5, plane) + angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got) + assert angles == [60, 120, 240, 300] + assert draw.compass_candidates((0, 0), 50, 50, plane) == [] + near = draw.compass_point((0, 0), (10, 40), 50, 5, plane) + assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6) + assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None + + +def test_line_values() -> None: + assert draw.line_length(LINE) == 210 + assert draw.point_at_distance(LINE, -5) == (0, 0) + assert draw.point_at_distance(LINE, 75) == (30, 65) + assert draw.point_at_distance(LINE, 500) == (90, 180) + assert draw.distance_along(LINE, (40, 70)) == 80 + assert draw.distance_along(LINE, (-10, -10)) == 0 + assert draw.slice_line(LINE, 25, 160) == pytest.approx( + [(15, 20), (30, 40), (30, 100), (60, 140)] + ) + assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160) + assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210] + assert draw.station_chainages(40, 20) == [0, 20, 40] + assert draw.station_chainages(0) == [0]