- 새 셈 logic/D03_PlanAnalysis_Fan.ts(화면 고르기 전용 · 파이썬 짝 없음 · B05 626-3 이 그대로 씀) — 이웃 노드 사이 허용 호 · 정방향 = 앞 구간 쪽(첫 구간 없음 · 갈래 = 붙은 선 · 거의 같으면 마우스 쪽) · 완만한 땅 = 어느 방향이나 · Shift = 호 위 마우스 방위(길이 그대로 · 기울기 실제 지형 · 호 밖 = 호 끝) - 미리보기 paintFan — 정방향 반투명 채움 · 역방향 테두리 · 노드 + 따뜻한 색 · − 찬 색 · 고리 = 고른 점 부호 색 - 놓인 꺾임점 = 들어온 구간 기울기 부호 색(저장 없이 셈) - Shift · 완만한 땅으로 찍은 노드 기록 = 길이 고정(저장 꼴 그대로) - 색 토큰 --draw-map-slope-up · --draw-map-slope-down · test_551_2 토큰 8 → 10 - 시험 test_625_draw(부채꼴 · 정방향 · +/− · 노드 2/4/6/8 · 완만한 땅 · Shift 실제 지형) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
7.1 KiB
Python
162 lines
7.1 KiB
Python
"""D03 길이·기울기 고정 부채꼴 셈(PLAN 625-2 · 요구 530 · 531 B1~B7) — TS `logic/D03_PlanAnalysis_Fan.ts` 순수 시험.
|
||
|
||
화면 고르기 전용(파이썬 짝 없음) — tsc 로 옮겨 Node 로 돌림.
|
||
- 경사면 65 % · 반지름 20 m · 9 % = 노드 4 · 호 2 · 호마다 + 하나 − 하나 · 부채꼴 폭 ≈ 2·arcsin(9/65).
|
||
- 꽃잎 땅 cos(nθ) = 노드 2 · 4 · 6 · 8 · 호 n.
|
||
- 정방향 = 앞 구간 쪽 호 · 첫 구간 없음 · 거의 같으면 마우스 쪽.
|
||
- 완만한 땅 = 노드 0 · 어느 방향이나 · Shift 사이 = 길이 그대로 · 기울기 = 실제 지형 · 호 밖 = 호 끝.
|
||
|
||
./venv/Scripts/python.exe -m pytest resources/tester/test_625_draw.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_FILES = [
|
||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts",
|
||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts",
|
||
]
|
||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||
|
||
_RUNNER = """
|
||
import { writeFileSync } from "node:fs";
|
||
import * as fan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.js";
|
||
import { groundSlopePct, segmentLength } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js";
|
||
const L = 20, S = 9, O = [0, 0];
|
||
const plane = (x, y) => 0.65 * x;
|
||
const gentle = (x, y) => 0.01 * x;
|
||
// 꽃잎 — 원 위 높이 차 = 10·(cos nθ − 1)/2 · 시작점 0
|
||
const petal = (n) => (x, y) => (Math.hypot(x, y) / L) * 10 * (Math.cos(n * Math.atan2(y, x)) - 1) / 2;
|
||
const at = (deg) => [Math.cos((deg * Math.PI) / 180) * 50, Math.sin((deg * Math.PI) / 180) * 50];
|
||
const deg = (p) => ((Math.atan2(p[1], p[0]) * 180) / Math.PI + 360) % 360;
|
||
const view = (f) => ({
|
||
nodes: f.nodes.map((n) => ({ deg: (n.angle * 180) / Math.PI, sign: n.sign })),
|
||
arcs: f.arcs.map((a) => ({ from: (a.from * 180) / Math.PI, to: (a.to * 180) / Math.PI, forward: a.forward })),
|
||
flat: f.flat,
|
||
});
|
||
const pick = (f, z, p) => p && { deg: deg(p), length: segmentLength(O, p), slope: groundSlopePct(O, p, z) };
|
||
const out = {};
|
||
// 경사면 — 앞 구간 북쪽으로(앞 점 = 남쪽) · 첫 구간 · 동쪽(오르막) 앞 구간 = 마우스 쪽
|
||
out.plane = view(fan.compassFan(O, L, S, plane, [0, -20]));
|
||
out.planeFirst = view(fan.compassFan(O, L, S, plane, null));
|
||
out.planeTieSouth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(-80)));
|
||
out.planeTieNorth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(80)));
|
||
const f = fan.compassFan(O, L, S, plane, [0, -20]);
|
||
out.between = pick(f, plane, fan.fanBetween(f, O, at(93), L));
|
||
out.betweenOut = pick(f, plane, fan.fanBetween(f, O, at(60), L));
|
||
out.pick = pick(f, plane, fan.fanPick(f, O, at(60), L));
|
||
out.pickReverse = pick(f, plane, fan.fanPick(f, O, at(-95), L));
|
||
for (const n of [1, 2, 3, 4]) out[`petal${n}`] = view(fan.compassFan(O, L, S, petal(n), null));
|
||
const g = fan.compassFan(O, L, S, gentle, [0, -20]);
|
||
out.gentle = view(g);
|
||
out.gentlePick = pick(g, gentle, fan.fanPick(g, O, at(200), L));
|
||
out.gentleBetween = pick(g, gentle, fan.fanBetween(g, O, at(200), L));
|
||
out.steep = view(fan.compassFan(O, L, S, (x, y) => 0.5 * Math.hypot(x, y), null));
|
||
out.offTerrain = view(fan.compassFan(O, L, S, () => NaN, null));
|
||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||
"""
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||
tmp = tmp_path_factory.mktemp("fan")
|
||
out = tmp / "js"
|
||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
["node", str(TSC), *map(str, TS_FILES), "--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")
|
||
result = tmp / "out.json"
|
||
subprocess.run( # noqa: S603
|
||
["node", str(out / "runner.mjs"), str(result)],
|
||
cwd=str(ROOT),
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
return json.loads(result.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _inside(arc: dict, deg: float) -> bool:
|
||
return (deg - arc["from"]) % 360 <= arc["to"] - arc["from"]
|
||
|
||
|
||
def test_경사면_노드_넷_호_둘_부호_짝(r):
|
||
f = r["plane"]
|
||
assert len(f["nodes"]) == 4 and len(f["arcs"]) == 2 and not f["flat"]
|
||
width = 2 * math.degrees(math.asin(9 / 65))
|
||
by_deg = {round(n["deg"], 3): n["sign"] for n in f["nodes"]}
|
||
for arc in f["arcs"]:
|
||
assert abs((arc["to"] - arc["from"]) - width) < 0.05
|
||
ends = {by_deg[round(arc["from"], 3)], by_deg[round(arc["to"] % 360, 3)]}
|
||
assert ends == {1, -1} # 호마다 + 하나 − 하나
|
||
assert _inside(arc, 90) or _inside(arc, 270) # 등고선 방향
|
||
# + = 오르막(동쪽 반) · − = 서쪽 반
|
||
for n in f["nodes"]:
|
||
assert (n["sign"] == 1) == (math.cos(math.radians(n["deg"])) > 0)
|
||
|
||
|
||
def test_정방향_앞_구간_쪽_첫_구간_없음_같으면_마우스_쪽(r):
|
||
forward = [a for a in r["plane"]["arcs"] if a["forward"]]
|
||
assert len(forward) == 1 and _inside(forward[0], 90)
|
||
assert not any(a["forward"] for a in r["planeFirst"]["arcs"])
|
||
south = [a for a in r["planeTieSouth"]["arcs"] if a["forward"]]
|
||
north = [a for a in r["planeTieNorth"]["arcs"] if a["forward"]]
|
||
assert _inside(south[0], 270) and _inside(north[0], 90)
|
||
|
||
|
||
@pytest.mark.parametrize("n", [1, 2, 3, 4])
|
||
def test_꽃잎_땅_노드_2_4_6_8(r, n):
|
||
f = r[f"petal{n}"]
|
||
assert len(f["nodes"]) == 2 * n and len(f["arcs"]) == n
|
||
for k in range(n): # 허용 호 = 꽃잎 꼭대기(cos nθ = 1)마다
|
||
assert sum(_inside(a, 360 * k / n) for a in f["arcs"]) == 1
|
||
assert all(node["sign"] == -1 for node in f["nodes"])
|
||
|
||
|
||
def test_shift_사이_길이_그대로_기울기_실제_지형(r):
|
||
b = r["between"]
|
||
assert abs(b["deg"] - 93) < 1e-6 and abs(b["length"] - 20) < 1e-9
|
||
assert abs(b["slope"] - 65 * math.cos(math.radians(93))) < 1e-6 and abs(b["slope"]) < 9
|
||
# 호 밖 = 가까운 호 끝(= ±9 % 노드)
|
||
o = r["betweenOut"]
|
||
assert abs(o["length"] - 20) < 1e-9 and abs(abs(o["slope"]) - 9) < 0.01
|
||
assert abs(o["deg"] - (90 - math.degrees(math.asin(9 / 65)))) < 0.05
|
||
|
||
|
||
def test_보통_고르기_가까운_노드_역방향도(r):
|
||
assert abs(abs(r["pick"]["slope"]) - 9) < 0.01
|
||
rev = r["pickReverse"]
|
||
assert abs(abs(rev["slope"]) - 9) < 0.01 and 260 < rev["deg"] < 280 # 역방향 호 노드도 고름
|
||
|
||
|
||
def test_완만한_땅_어느_방향이나(r):
|
||
g = r["gentle"]
|
||
assert g["flat"] and not g["nodes"] and not g["arcs"]
|
||
for key in ("gentlePick", "gentleBetween"):
|
||
p = r[key]
|
||
assert abs(p["deg"] - 200) < 1e-6 and abs(p["length"] - 20) < 1e-9
|
||
assert abs(p["slope"] - 1 * math.cos(math.radians(200))) < 1e-6
|
||
|
||
|
||
def test_가파른_땅_지형_밖_고를_자리_없음(r):
|
||
for key in ("steep", "offTerrain"):
|
||
f = r[key]
|
||
assert not f["flat"] and not f["nodes"] and not f["arcs"]
|