diff --git a/A00_Common/map/logic/map_guide_route.ts b/A00_Common/map/logic/map_guide_route.ts new file mode 100644 index 000000000..6852d2e6d --- /dev/null +++ b/A00_Common/map/logic/map_guide_route.ts @@ -0,0 +1,434 @@ +/* ============================================================================= + * map_guide_route.ts(logic) + * 예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — 기준지점을 차례로 거치는 참고용 선. + * 화면 가이드 전용 · 파이썬 짝 없음 · 저장 안 함(설계 값 아님 · 선례 `D03_PlanAnalysis_Fan.ts:8`). + * 2 m 격자 A*: 걸음 = 반지름 걸음/칸 ± 0.5 격자점(20 m = 56 방향) · |Δz| > 상한 × 걸음 길이 막음 · + * 비용 = 길이 × (1 + 급경사 × 급경사 표본 비율 + 기울기 × (g/상한)²) + 꺾임 × 걸음 × (꺾임각/π)² · + * 어림 = 직선 거리 × 가중(가중 A*) · 구간마다 상자 = 두 점 + 여유(못 닿으면 두 배 한 번) · 펼침 상한. + * 첫 걸음 = 정확한 시작점에서 · 끝 걸음도 기울기 검사 · 꼭짓점 그대로(20 m 다시 나누기 금지). + * 1 m 격자 = 2 칸 솎아 2 m · 급경사 = 격자 높이에서 바로 셈 · 못 닿음 = 가장 가까이 간 데까지. + * 근거 = `docs/raw/요구_1101/_분석_546-C_추천노선_라이다.md` C1 · DOM · fetch · three 없음. + * ========================================================================== */ + +import { + heightAt as gridHeightAt, + type DtmGrid, +} from "../../../common_util/common_util_surface_sampler"; + +export type Point2 = [number, number]; +export type Point3 = [number, number, number]; + +/** 높이 원천 — heightAt(NaN = 밖 · 빈칸) · 원천 격자 칸(m) · 격자 원점(칸 꼭짓점 맞춤). */ +export interface GuideTerrain { + heightAt: (x: number, y: number) => number; + /** 원천 격자 칸 m(기본 2) — 2 m 보다 잘면 솎아 2 m 안팎으로. */ + cell?: number; + origin?: Point2; +} + +export interface GuideWeights { + /** 급경사 표본 비율 벌점(기본 3) */ + steep: number; + /** (g/상한)² 벌점(기본 0.3) */ + grade: number; + /** 꺾임 벌점(기본 1) */ + turn: number; + /** 어림 가중(기본 3 · 1 = 정확 A*) */ + heuristic: number; +} + +export interface GuideOptions { + /** 걸음 m(기본 20 · 그리기 「길이·기울기 고정」 칸) */ + step?: number; + /** 기울기 상한 비율(기본 0.14) */ + maxGrade?: number; + /** 급경사 문턱 %(기본 70 = 35° · 급경사 겹 둘째 경계) */ + steepPct?: number; + weights?: Partial; + /** 구간마다 펼침 상한(기본 30 만 ≈ 0.4 s · 못 닿음 ≤ 0.5 s) */ + maxExpand?: number; + /** 구간마다 시간 상한 ms(기본 450 · 못 닿음 ≤ 0.5 s · 느린 기계 막음) */ + maxMs?: number; +} + +export interface GuideSegment { + /** 꼭짓점 [x, y, z] — 닿음이면 끝 = 다음 지점 · 아니면 가장 가까이 간 데까지 */ + vertices: Point3[]; + reached: boolean; + /** 평면 길이 m */ + length: number; + /** 걸음 가운데 가장 큰 |기울기| 비율 */ + maxGrade: number; + /** 급경사(문턱 이상) 지나는 길이 비율 0~1 */ + steepRatio: number; + expanded: number; +} + +export interface GuideResult { + segments: GuideSegment[]; + ms: number; +} + +export const GUIDE_DEFAULTS = { + step: 20, + maxGrade: 0.14, + steepPct: 70, + weights: { steep: 3, grade: 0.3, turn: 1, heuristic: 3 } as GuideWeights, + maxExpand: 300_000, + maxMs: 450, +}; + +const MARGIN_MIN = 200; +const MARGIN_MAX = 800; +const STEEP_SAMPLES = [0.25, 0.5, 0.75, 1]; + +/** 서버 지형 격자(`parseDtmGrid`) → 가이드 높이 원천(원천 칸 · 원점 그대로). */ +export function guideTerrainFromGrid(grid: DtmGrid): GuideTerrain { + const n = grid.x.length; + const cell = n > 1 ? (grid.x[n - 1] - grid.x[0]) / (n - 1) : 2; + return { + heightAt: (x, y) => gridHeightAt(grid, x, y), + cell, + origin: [grid.x[0], grid.y[0]], + }; +} + +/** 지점 차례로 잇는 가이드 선 — 지점 사이 구간마다 A*. */ +export function guideRoute( + points: Point2[], + terrain: GuideTerrain, + options: GuideOptions = {}, +): GuideResult { + const started = performance.now(); + const segments: GuideSegment[] = []; + for (let index = 0; index + 1 < points.length; index++) { + segments.push(guideSegment(points[index], points[index + 1], terrain, options)); + } + return { segments, ms: performance.now() - started }; +} + +/** 두 점 사이 가이드 선 하나. */ +export function guideSegment( + start: Point2, + goal: Point2, + terrain: GuideTerrain, + options: GuideOptions = {}, +): GuideSegment { + const step = options.step ?? GUIDE_DEFAULTS.step; + const gmax = options.maxGrade ?? GUIDE_DEFAULTS.maxGrade; + const weights = { ...GUIDE_DEFAULTS.weights, ...options.weights }; + const steepGrade = (options.steepPct ?? GUIDE_DEFAULTS.steepPct) / 100; + const source = terrain.cell ?? 2; + const cell = source * Math.max(1, Math.round(2 / source)); + const origin = terrain.origin ?? [0, 0]; + const za = terrain.heightAt(start[0], start[1]); + const zb = terrain.heightAt(goal[0], goal[1]); + if (!Number.isFinite(za) || !Number.isFinite(zb)) { + return measure([[start[0], start[1], za]], false, 0, null); + } + const span = Math.hypot(goal[0] - start[0], goal[1] - start[1]); + const margin = Math.min(MARGIN_MAX, Math.max(MARGIN_MIN, span / 2)); + const limit = options.maxExpand ?? GUIDE_DEFAULTS.maxExpand; + let spent = 0; // 두 상자 합친 펼침 수 + let late = false; + const deadline = performance.now() + (options.maxMs ?? GUIDE_DEFAULTS.maxMs); + let edge = false; // 상자 끝에 닿음 = 넓히면 길이 있을 수 있음 + let found = search(margin); + if (!found.reached && edge && spent < limit && !late) found = search(margin * 2); + found.expanded = spent; + return found; + + function search(pad: number): GuideSegment { + const i0 = Math.floor((Math.min(start[0], goal[0]) - pad - origin[0]) / cell); + const j0 = Math.floor((Math.min(start[1], goal[1]) - pad - origin[1]) / cell); + const w = Math.ceil((Math.max(start[0], goal[0]) + pad - origin[0]) / cell) - i0 + 1; + const h = Math.ceil((Math.max(start[1], goal[1]) + pad - origin[1]) / cell) - j0 + 1; + const n = w * h; + const START = n; + const GOAL = n + 1; + const zs = new Float32Array(n).fill(Infinity); // Infinity = 아직 안 읽음 · NaN = 밖 + const steepState = new Uint8Array(n); // 0 모름 · 1 급경사 · 2 아님 + const g = new Float64Array(n + 2).fill(Infinity); + const parent = new Int32Array(n + 2).fill(-1); + const dir = new Float32Array(n + 2).fill(NaN); + const closed = new Uint8Array(n + 2); + const heap = new MinHeap(); + const nx = (id: number) => origin[0] + (i0 + (id % w)) * cell; + const ny = (id: number) => origin[1] + (j0 + Math.floor(id / w)) * cell; + const zAt = (id: number): number => { + let value = zs[id]; + if (value === Infinity) { + value = terrain.heightAt(nx(id), ny(id)); + zs[id] = Number.isFinite(value) ? value : NaN; + } + return value; + }; + const steepSq = (2 * cell * steepGrade) ** 2; + const steepAt = (id: number): boolean => { + if (steepState[id] === 0) { + const i = id % w; + const j = (id - i) / w; + if (i < 1 || j < 1 || i >= w - 1 || j >= h - 1) steepState[id] = 2; + else { + const gx = zAt(id + 1) - zAt(id - 1); + const gy = zAt(id + w) - zAt(id - w); + steepState[id] = gx * gx + gy * gy >= steepSq ? 1 : 2; // NaN = 아님 + } + } + return steepState[id] === 1; + }; + const radius = step / cell; + // 걸음 표 — 격자 차 · 길이 · 방위 · 급경사 표본 차(1/4 · 1/2 · 3/4 · 끝) + const offI: number[] = []; + const offJ: number[] = []; + const offLen: number[] = []; + const offDir: number[] = []; + const sampleD: number[] = []; // 칸 번호 차 + const reach = Math.ceil(radius + 0.5); + for (let dj = -reach; dj <= reach; dj++) { + for (let di = -reach; di <= reach; di++) { + if (Math.abs(Math.hypot(di, dj) - radius) > 0.5) continue; + offI.push(di); + offJ.push(dj); + offLen.push(Math.hypot(di, dj) * cell); + offDir.push(Math.atan2(dj, di)); + for (const t of STEEP_SAMPLES) { + sampleD.push(Math.round(dj * t) * w + Math.round(di * t)); + } + } + } + const steepShare = weights.steep / STEEP_SAMPLES.length; + const turnWeight = (weights.turn * step) / (Math.PI * Math.PI); + // 걸음 비용 — 막히면 Infinity + const cost = ( + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + inDir: number, + ): number => { + const len = Math.hypot(bx - ax, by - ay); + const dz = Math.abs(bz - az); + if (!(dz <= gmax * len)) return Infinity; + let hits = 0; + for (const t of STEEP_SAMPLES) { + const si = Math.round((ax + (bx - ax) * t - origin[0]) / cell) - i0; + const sj = Math.round((ay + (by - ay) * t - origin[1]) / cell) - j0; + if (si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si)) hits++; + } + const grade = len > 0 ? dz / len / gmax : 0; + let value = len * (1 + steepShare * hits + weights.grade * grade * grade); + if (Number.isFinite(inDir) && len > 0) { + let turn = Math.abs(Math.atan2(by - ay, bx - ax) - inDir); + if (turn > Math.PI) turn = 2 * Math.PI - turn; + value += turnWeight * turn * turn; + } + return value; + }; + const relax = ( + from: number, + to: number, + c: number, + ax: number, + ay: number, + bx: number, + by: number, + ) => { + if (!Number.isFinite(c) || g[from] + c >= g[to]) return; + g[to] = g[from] + c; + parent[to] = from; + dir[to] = Math.atan2(by - ay, bx - ax); + heap.push(g[to] + weights.heuristic * Math.hypot(goal[0] - bx, goal[1] - by), to); + }; + // 끝 걸음 — 한 걸음 안이면 정확한 끝점으로(기울기 검사) + const tryGoal = (from: number, ax: number, ay: number, az: number) => { + if (Math.hypot(goal[0] - ax, goal[1] - ay) <= step + 0.5 * cell) { + relax( + from, + GOAL, + cost(ax, ay, az, goal[0], goal[1], zb, dir[from]), + ax, + ay, + goal[0], + goal[1], + ); + } + }; + g[START] = 0; + tryGoal(START, start[0], start[1], za); + // 첫 걸음 — 정확한 시작점에서 반지름 걸음 고리 + const ci = Math.round((start[0] - origin[0]) / cell) - i0; + const cj = Math.round((start[1] - origin[1]) / cell) - j0; + for (let j = cj - reach - 1; j <= cj + reach + 1; j++) { + for (let i = ci - reach - 1; i <= ci + reach + 1; i++) { + if (i < 0 || j < 0 || i >= w || j >= h) continue; + const id = j * w + i; + const bx = nx(id); + const by = ny(id); + if (Math.abs(Math.hypot(bx - start[0], by - start[1]) - step) > 0.5 * cell) continue; + const bz = zAt(id); + if (Number.isNaN(bz)) continue; + relax(START, id, cost(start[0], start[1], za, bx, by, bz, NaN), start[0], start[1], bx, by); + } + } + let best = START; + let bestDist = span; + while (heap.size > 0 && spent < limit && !late) { + const u = heap.pop(); + if (closed[u]) continue; + closed[u] = 1; + if (u === GOAL) return finish(GOAL, true); + spent++; + if ((spent & 1023) === 0 && performance.now() > deadline) late = true; + const ui = u % w; + const uj = Math.floor(u / w); + const ux = nx(u); + const uy = ny(u); + const uz = zs[u]; + const left = Math.hypot(goal[0] - ux, goal[1] - uy); + if (left < bestDist) { + bestDist = left; + best = u; + } + tryGoal(u, ux, uy, uz); + const gu = g[u]; + const du = dir[u]; + for (let k = 0; k < offI.length; k++) { + const vi = ui + offI[k]; + const vj = uj + offJ[k]; + if (vi < 0 || vj < 0 || vi >= w || vj >= h) { + edge = true; + continue; + } + const v = vj * w + vi; + if (closed[v]) continue; + let vz = zs[v]; + if (vz === Infinity) vz = zAt(v); + if (vz !== vz) continue; // NaN = 밖 + const len = offLen[k]; + const dz = Math.abs(vz - uz); + if (dz > gmax * len) continue; + let hits = 0; + for (let s = 4 * k; s < 4 * k + 4; s++) { + const at = u + sampleD[s]; + const state = steepState[at]; + if (state === 1 || (state === 0 && steepAt(at))) hits++; + } + const grade = dz / len / gmax; + let c = len * (1 + steepShare * hits + weights.grade * grade * grade); + if (du === du) { + let turn = Math.abs(offDir[k] - du); + if (turn > Math.PI) turn = 2 * Math.PI - turn; + c += turnWeight * turn * turn; + } + const next = gu + c; + if (next >= g[v]) continue; + g[v] = next; + parent[v] = u; + dir[v] = offDir[k]; + const ex = goal[0] - (ux + offI[k] * cell); + const ey = goal[1] - (uy + offJ[k] * cell); + heap.push(next + weights.heuristic * Math.sqrt(ex * ex + ey * ey), v); + } + } + return finish(best, false); + + function finish(last: number, reached: boolean): GuideSegment { + const chain: Point3[] = []; + for (let id = last; id !== -1; id = parent[id]) { + if (id === START) chain.push([start[0], start[1], za]); + else if (id === GOAL) chain.push([goal[0], goal[1], zb]); + else chain.push([nx(id), ny(id), zs[id]]); + } + chain.reverse(); + return measure(chain, reached, 0, (x, y) => { + const si = Math.round((x - origin[0]) / cell) - i0; + const sj = Math.round((y - origin[1]) / cell) - j0; + return si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si); + }); + } + } + + // 길이 · 최대 기울기 · 급경사 지남 비율(걸음마다 칸 간격 표본) + function measure( + vertices: Point3[], + reached: boolean, + expanded: number, + steepAt: ((x: number, y: number) => boolean) | null, + ): GuideSegment { + let length = 0; + let maxGrade = 0; + let steepLength = 0; + for (let k = 1; k < vertices.length; k++) { + const [ax, ay, az] = vertices[k - 1]; + const [bx, by, bz] = vertices[k]; + const len = Math.hypot(bx - ax, by - ay); + if (len <= 0) continue; + length += len; + maxGrade = Math.max(maxGrade, Math.abs(bz - az) / len); + if (!steepAt) continue; + const parts = Math.max(1, Math.ceil(len / cell)); + for (let p = 0; p < parts; p++) { + const t = (p + 0.5) / parts; + if (steepAt(ax + (bx - ax) * t, ay + (by - ay) * t)) steepLength += len / parts; + } + } + return { + vertices, + reached, + length, + maxGrade, + steepRatio: length > 0 ? steepLength / length : 0, + expanded, + }; + } +} + +/** 이진 힙(값 작은 것 먼저) — 타입 배열 · 늘어남. */ +class MinHeap { + private keys = new Float64Array(1024); + private ids = new Int32Array(1024); + size = 0; + + push(key: number, id: number): void { + if (this.size === this.keys.length) { + const keys = new Float64Array(this.size * 2); + keys.set(this.keys); + const ids = new Int32Array(this.size * 2); + ids.set(this.ids); + this.keys = keys; + this.ids = ids; + } + let at = this.size++; + while (at > 0) { + const up = (at - 1) >> 1; + if (this.keys[up] <= key) break; + this.keys[at] = this.keys[up]; + this.ids[at] = this.ids[up]; + at = up; + } + this.keys[at] = key; + this.ids[at] = id; + } + + pop(): number { + const top = this.ids[0]; + const key = this.keys[--this.size]; + const id = this.ids[this.size]; + let at = 0; + for (;;) { + let child = 2 * at + 1; + if (child >= this.size) break; + if (child + 1 < this.size && this.keys[child + 1] < this.keys[child]) child++; + if (this.keys[child] >= key) break; + this.keys[at] = this.keys[child]; + this.ids[at] = this.ids[child]; + at = child; + } + this.keys[at] = key; + this.ids[at] = id; + return top; + } +} diff --git a/resources/tester/test_636_guide_route.py b/resources/tester/test_636_guide_route.py new file mode 100644 index 000000000..efc2eb703 --- /dev/null +++ b/resources/tester/test_636_guide_route.py @@ -0,0 +1,187 @@ +"""예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — `A00_Common/map/logic/map_guide_route.ts`. + +2 m 격자 A*(20 m 걸음 · ±14 % 막음 · 급경사 벌점 · 꺾임 벌점 · 펼침 상한). +평지 · 높이 차 지그재그(모든 걸음 ≤ 상한 · 첫 · 끝 걸음 포함) · 못 닿음 ≤ 0.5 s · 지점 셋 · +급경사 벌점 효과 · 1 km ≤ 0.3 s · 1 m 격자 솎음 · 지형 밖. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_636_guide_route.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_guide_route.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +GMAX = 0.14 + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { guideRoute } from "./A00_Common/map/logic/map_guide_route.js"; +// 산 꼴 — 급경사 35°+ 약 2 할 · 동쪽으로 12 % 오름 +const hills = (x, y) => + 10 * Math.sin(x / 37) * Math.cos(y / 53) + 6.25 * Math.sin((x + 2 * y) / 19) + 0.12 * x; +const TERRAIN = { + flat: () => 100, + plane: (x) => 0.3 * x, + hills, + cliff: (x, y) => hills(x, y) + (x >= 0 ? 500 : 0), + hole: (x, y) => (Math.hypot(x, y) < 50 ? NaN : 0), +}; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = {}; +for (const [key, c] of Object.entries(cases)) { + const terrain = { heightAt: TERRAIN[c.terrain], cell: c.cell ?? 2 }; + const runs = []; + for (let k = 0; k < (c.repeat ?? 1); k++) + runs.push(guideRoute(c.points, terrain, c.options ?? {})); + out[key] = { ...runs[runs.length - 1], allMs: runs.map((r) => r.ms) }; +} +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +CASES = { + "flat": {"terrain": "flat", "points": [[0.7, 0.3], [500.4, 3.1]]}, + "zigzag": {"terrain": "plane", "points": [[0.7, 0.3], [300.9, 1.3]]}, + "cliff": {"terrain": "cliff", "points": [[-500.0, 0.0], [500.0, 0.0]]}, + "three": {"terrain": "hills", "points": [[0.0, 0.0], [400.0, 300.0], [700.0, -100.0]]}, + "steep0": { + "terrain": "hills", + "points": [[0.0, 0.0], [600.0, 400.0]], + "options": {"weights": {"steep": 0}}, + }, + "steep3": {"terrain": "hills", "points": [[0.0, 0.0], [600.0, 400.0]]}, + "km": {"terrain": "hills", "points": [[0.0, 0.0], [800.0, 600.0]], "repeat": 3}, + "lidar": {"terrain": "hills", "cell": 1, "points": [[0.0, 0.0], [300.0, 200.0]]}, + "outside": {"terrain": "hole", "points": [[0.0, 0.0], [300.0, 0.0]]}, +} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("guide_route") + 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, result = tmp / "job.json", 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 _grades(vertices: list) -> list[float]: + return [ + abs(b[2] - a[2]) / math.hypot(b[0] - a[0], b[1] - a[1]) + for a, b in zip(vertices, vertices[1:]) + ] + + +def _check_ends(seg: dict, start: list, goal: list) -> None: + assert seg["vertices"][0][:2] == start + assert seg["vertices"][-1][:2] == goal + + +def test_평지_곧게(r): + (seg,) = r["flat"]["segments"] + assert seg["reached"] + _check_ends(seg, *CASES["flat"]["points"]) + span = math.dist(*CASES["flat"]["points"]) + assert seg["length"] <= span * 1.1 + assert seg["maxGrade"] == 0 and seg["steepRatio"] == 0 + + +def test_높이_차_지그재그_모든_걸음_상한_안(r): + (seg,) = r["zigzag"]["segments"] + assert seg["reached"] + _check_ends(seg, *CASES["zigzag"]["points"]) + grades = _grades(seg["vertices"]) + assert max(grades) <= GMAX + 1e-9 # 첫 · 끝 걸음 포함 + assert seg["maxGrade"] == pytest.approx(max(grades)) + assert seg["length"] >= 0.3 * 300 / GMAX # 높이 차 90 m 를 14 % 로 + steps = [math.dist(a[:2], b[:2]) for a, b in zip(seg["vertices"], seg["vertices"][1:])] + assert all(19 <= s <= 21.5 for s in steps[:-1]) # 꼭짓점 그대로(끝 걸음만 짧을 수 있음) + + +def test_못_닿음_가장_가까이_간_데까지_빠르게(r): + (seg,) = r["cliff"]["segments"] + assert not seg["reached"] + assert seg["vertices"][0][:2] == CASES["cliff"]["points"][0] + assert -40 <= seg["vertices"][-1][0] < 0 # 벼랑 앞까지 + assert max(_grades(seg["vertices"])) <= GMAX + 1e-9 + assert r["cliff"]["ms"] <= 500 + + +def test_지점_셋_구간마다_이어짐(r): + points = CASES["three"]["points"] + segs = r["three"]["segments"] + assert len(segs) == 2 + for seg, a, b in zip(segs, points, points[1:]): + assert seg["reached"] + _check_ends(seg, a, b) + assert max(_grades(seg["vertices"])) <= GMAX + 1e-9 + assert segs[0]["vertices"][-1] == segs[1]["vertices"][0] + + +def test_급경사_벌점이_급경사_지남을_줄임(r): + off = r["steep0"]["segments"][0] + on = r["steep3"]["segments"][0] + assert off["reached"] and on["reached"] + assert on["steepRatio"] < off["steepRatio"] + + +def test_1_km_0_3_초_안(r): + (seg,) = r["km"]["segments"] + assert seg["reached"] + assert max(_grades(seg["vertices"])) <= GMAX + 1e-9 + assert min(r["km"]["allMs"]) <= 300 + + +def test_1_m_격자는_2_m_로_솎음(r): + (seg,) = r["lidar"]["segments"] + assert seg["reached"] + inner = seg["vertices"][1:-1] + assert inner and all(v[0] % 2 == 0 and v[1] % 2 == 0 for v in inner) + + +def test_지형_밖_지점은_못_닿음(r): + (seg,) = r["outside"]["segments"] + assert not seg["reached"] + assert len(seg["vertices"]) == 1