feat(562-1): D03 수동 그리기 셈 짝 — 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 양각기 · 선 위 거리 ↔ 점 · 측점

- logic/D03_PlanAnalysis_Draw.ts ↔ D03_PlanAnalysis_Draw.py 같은 이름 짝 · 높이는 인자 heightAt
- 거울 시험 test_562_1_plan_draw.py — 두 쪽 값 대조 + 손셈 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:58:56 +09:00
co-authored by Claude Opus 5.5
parent 6d4964ae55
commit ae24e29d12
3 changed files with 692 additions and 0 deletions
+253
View File
@@ -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
@@ -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<Vertex>): 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<Vertex>, 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<Vertex>, 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<Vertex>, 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;
}
+203
View File
@@ -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]