fix(B05·B07 평면): 364-3 절/성토선 비탈 끝 모서리 둥글림 · S 자 촘촘히 (TS ↔ 파이썬 짝)

- 까닭 셋: 접힘 버린 자리 건너뜀 모서리(898.6 → 908 m · 139°) · 촘촘히가 가운데 점만 봐 S 자 빠짐(860 → 861 · 70°) · 잔 갈지자
- 촘촘히 = 4분 점도 봄 · 비탈 끝 = 가장 많이 꺾인 점부터 앞뒤 3 m 를 두 접선에 붙는 2차 곡선으로(15° 넘음) · 측점 · 깎기↔쌓기 바뀜 점은 그대로 · 둥글린 자리 눈금 뺌
- 요구 239 세션 자료: 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7° → 0 · 1 · 34.8°(측점 860 · 861 사이 1 m 에 폭 4.4 → 6.0 m)
- 시험 test_364_3_slope_end_round.py(전후 · 측점 그대로 · TS 같은 점) · 자료 fixtures/b05_364_3_footprint_req239.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-16 23:53:08 +09:00
co-authored by Claude Opus 5.5
parent be748ed7c8
commit 3e3215826e
4 changed files with 350 additions and 13 deletions
+107 -6
View File
@@ -4,7 +4,7 @@
*
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
* (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
@@ -348,6 +348,91 @@ function turnDeg(a: number[], m: number[], b: number[]): number {
);
}
/** 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°). 짝 같은 이름. */
export const ROUND_TURN_DEG = 15.0;
/** 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 좁으면 그대로. */
export const ROUND_M = 3.0;
export const ROUND_MIN_M = 0.5;
/** 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한. */
export const ROUND_STEP_DEG = 5.0;
export const ROUND_PASSES = 200;
const dist = (a: number[], b: number[]): number =>
Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
/** 가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈 — 측점(pins) · 바뀜 점 · 양 끝은 그대로.
* 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). 짝: `_round_corners`. */
function roundCorners(
points: Line,
flags: boolean[],
pins: boolean[],
): [Line, boolean[], number[]] {
const pts = points.map((p) => [...p]);
const fl = [...flags];
const src = points.map((_p, i) => i);
const pinned = (i: number): boolean => src[i] >= 0 && pins[src[i]];
const fixed = (i: number): boolean =>
i === 0 || i === pts.length - 1 || fl[i] !== fl[i - 1] || pinned(i);
/** i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지) — [남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향(i 쪽)]. */
const walk = (i: number, step: number, length: number): [number, number[], number, number[]] => {
let gone = 0.0;
let j = i;
for (;;) {
const k = j + step;
const seg = dist(pts[j], pts[k]);
const toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]];
if (gone + seg >= length) {
const ratio = (length - gone) / seg;
return [k, [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio], length, toward];
}
// 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림
if (fixed(k)) return [k, pts[k], pinned(k) ? (gone + seg) / 2.0 : gone + seg, toward];
gone += seg;
j = k;
}
};
/** 두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 null. */
const control = (a: number[], ta: number[], b: number[], tb: number[], reach: number) => {
const den = ta[0] * tb[1] - ta[1] * tb[0];
if (Math.abs(den) < 1e-12) return null;
const s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den;
const u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den;
const c = [a[0] + ta[0] * s, a[1] + ta[1] * s];
if (s <= 0 || u <= 0 || dist(a, c) > 2 * reach || dist(b, c) > 2 * reach) return null;
return c;
};
for (let pass = 0; pass < ROUND_PASSES; pass += 1) {
let worst = ROUND_TURN_DEG;
let at = -1;
let reach = 0.0;
for (let i = 1; i < pts.length - 1; i += 1) {
if (fixed(i)) continue;
const turn = turnDeg(pts[i - 1], pts[i], pts[i + 1]);
if (turn > worst) {
const room = Math.min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]);
if (room >= ROUND_MIN_M) [worst, at, reach] = [turn, i, room];
}
}
if (at < 0) break;
const [before, a, , ta] = walk(at, -1, reach);
const [after, b, , tb] = walk(at, 1, reach);
const p = control(a, ta, b, tb, reach) ?? pts[at];
const count = Math.max(2, Math.ceil(worst / ROUND_STEP_DEG));
const curve: Line = [];
for (let n = 0; n <= count; n += 1) {
const t = n / count;
const point = [0, 1].map((k) =>
roundTo((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4),
);
if (dist(point, pts[before]) > 1e-6 && dist(point, pts[after]) > 1e-6) curve.push(point);
}
pts.splice(before + 1, after - before - 1, ...curve);
fl.splice(before + 1, after - before - 1, ...curve.map(() => fl[at]));
src.splice(before + 1, after - before - 1, ...curve.map(() => -1));
}
return [pts, fl, src];
}
/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */
function densify(
chainages: number[],
@@ -358,9 +443,17 @@ function densify(
if (depth >= SPLIT_DEPTH) return [];
const middle = (c0 + c1) / 2.0;
const pm = place(middle, frameOf(middle));
// 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3)
const q1 = place((c0 + middle) / 2.0, frameOf((c0 + middle) / 2.0));
const q3 = place((middle + c1) / 2.0, frameOf((middle + c1) / 2.0));
const triples = [
...pm.map((m, k) => [p0[k], m, p1[k]]),
...q1.map((q, k) => [p0[k], q, pm[k]]),
...q3.map((q, k) => [pm[k], q, p1[k]]),
];
// 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
const bends = pm.some((m, k) => {
const turn = turnDeg(p0[k], m, p1[k]);
const bends = triples.some(([a, m, b]) => {
const turn = turnDeg(a, m, b);
return SPLIT_TURN_DEG < turn && turn < 90.0;
});
if (!bends) return [];
@@ -465,10 +558,18 @@ function sampleLines(
left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])),
right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])),
};
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
// 도면 관례) · 접혀 버린 자리는 뺌.
const ticks: Line[] = [];
const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) };
for (const side of SIDES) {
const key = `${side}_end_m` as const;
const pins = kept[key].map((i) => byChainage.has(roundTo(chainages[i], 3)));
const [line, flags, src] = roundCorners(lines[key], cut[side], pins);
lines[key] = line;
cut[side] = flags;
keptSets[side] = new Set(src.filter((j) => j >= 0).map((j) => kept[key][j]));
}
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
// 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌.
const ticks: Line[] = [];
const position = new Map(chainages.map((chainage, i) => [chainage, i]));
[...base]
.sort((a, b) => a - b)
@@ -3,8 +3,8 @@
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut ·
slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면
발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단
편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
"""
@@ -226,6 +226,100 @@ def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float:
return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])))
#: 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°)
ROUND_TURN_DEG = 15.0
#: 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다
#: 좁으면 그대로
ROUND_M = 3.0
ROUND_MIN_M = 0.5
#: 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한
ROUND_STEP_DEG = 5.0
ROUND_PASSES = 200
def _dist(a: list[float], b: list[float]) -> float:
return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2)
def _round_corners(
points: list[list[float]], flags: list[bool], pins: list[bool]
) -> tuple[list[list[float]], list[bool], list[int]]:
"""가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈.
측점(pins) · 깎기↔쌓기 바뀜 점 · 양 끝은 그대로 둔다.
돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1).
"""
pts, fl, src = [list(p) for p in points], list(flags), list(range(len(points)))
def pinned(i: int) -> bool:
return src[i] >= 0 and pins[src[i]]
def fixed(i: int) -> bool:
return i == 0 or i == len(pts) - 1 or fl[i] != fl[i - 1] or pinned(i)
def walk(i: int, step: int, length: float) -> tuple[int, list[float], float, list[float]]:
"""i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지).
돌려줌 = (남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향 — i 쪽을 향함).
"""
gone, j = 0.0, i
while True:
k = j + step
seg = _dist(pts[j], pts[k])
toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]]
if gone + seg >= length:
ratio = (length - gone) / seg
at = [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio]
return k, at, length, toward
if fixed(k):
# 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림
return k, pts[k], (gone + seg) / 2.0 if pinned(k) else gone + seg, toward
gone, j = gone + seg, k
def control(a: list[float], ta: list[float], b: list[float], tb: list[float], reach: float):
"""두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 None."""
den = ta[0] * tb[1] - ta[1] * tb[0]
if abs(den) < 1e-12:
return None
s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den
u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den
c = [a[0] + ta[0] * s, a[1] + ta[1] * s]
if s <= 0 or u <= 0 or _dist(a, c) > 2 * reach or _dist(b, c) > 2 * reach:
return None
return c
for _ in range(ROUND_PASSES):
worst, at, reach = ROUND_TURN_DEG, -1, 0.0
for i in range(1, len(pts) - 1):
if fixed(i):
continue
turn = _turn_deg(pts[i - 1], pts[i], pts[i + 1])
if turn > worst:
room = min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2])
if room >= ROUND_MIN_M:
worst, at, reach = turn, i, room
if at < 0:
break
before, a, _gone, ta = walk(at, -1, reach)
after, b, _gone, tb = walk(at, 1, reach)
p = control(a, ta, b, tb, reach) or pts[at]
count = max(2, math.ceil(worst / ROUND_STEP_DEG))
curve = []
for n in range(count + 1):
t = n / count
point = [
round((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4)
for k in (0, 1)
]
if _dist(point, pts[before]) > 1e-6 and _dist(point, pts[after]) > 1e-6:
curve.append(point)
pts[before + 1 : after] = curve
fl[before + 1 : after] = [fl[at]] * len(curve)
src[before + 1 : after] = [-1] * len(curve)
return pts, fl, src
def _densify(
chainages: list[float], frame_of: Any, place: Any
) -> tuple[list[float], list[tuple[float, float, float, float]]]:
@@ -236,8 +330,12 @@ def _densify(
return []
middle = (c0 + c1) / 2.0
pm = place(middle, frame_of(middle))
# 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3)
q1 = place((c0 + middle) / 2.0, frame_of((c0 + middle) / 2.0))
q3 = place((middle + c1) / 2.0, frame_of((middle + c1) / 2.0))
triples = [*zip(p0, pm, p1), *zip(p0, q1, pm), *zip(pm, q3, p1)]
# 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)):
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in triples):
return []
return (
split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1)
@@ -264,7 +362,8 @@ def slope_pieces(
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차
보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는
점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1).
점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). 버린 자리 · 잔
갈지자에 남은 비탈 끝 모서리는 측점을 두고 둥글린다(364-3).
비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
"""
sorted_records = sorted(
@@ -333,10 +432,15 @@ def slope_pieces(
cut = {
side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides
}
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
# 선다(납품 도면 관례) · 접혀 버린 자리는 뺌.
ticks: list[list[list[float]]] = []
kept_sets = {key: set(kept[key]) for key in keys}
for side in sides:
key = f"{side}_end_m"
pins = [round(chainages[i], 3) in by_chainage for i in kept[key]]
lines[key], cut[side], src = _round_corners(lines[key], cut[side], pins)
kept_sets[key] = {kept[key][j] for j in src if j >= 0}
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
# 선다(납품 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌.
ticks: list[list[list[float]]] = []
position = {chainage: i for i, chainage in enumerate(chainages)}
for index, chainage in enumerate(sorted(base)):
i = position[chainage]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,131 @@
"""364-3 절/성토선 비탈 끝 모서리 둥글림 — 요구 239 브레인 검증 세션 자료로 전후 꺾임.
자료 `fixtures/b05_364_3_footprint_req239.json` = 세션 측점 줄 70 + 노선. 고치기 전 세션 선:
비탈 끝 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7°(접힘 버린 자리 · S 자 촘촘히 빠짐 · 잔 갈지자).
TS(`B05_Profile_PlanFootprint.ts`) 도 같은 자료로 같은 점인지 본다.
./venv/Scripts/python.exe -m pytest resources/tester/test_364_3_slope_end_round.py -q
"""
from __future__ import annotations
import json
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))
import B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope as plan # noqa: E402
DATA = json.loads(
(ROOT / "resources/tester/fixtures/b05_364_3_footprint_req239.json").read_text(encoding="utf-8")
)
ROUTE = [tuple(point) for point in DATA["route"]]
RECORDS = DATA["records"]
END = ROUTE[-1][0]
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
def _turns(lines: list) -> list[float]:
"""조각 안 꺾임(°) — 조각 끝(깎기↔쌓기 바뀜 · 양 끝)은 뺌."""
return [
plan._turn_deg(line[i - 1], line[i], line[i + 1])
for line in lines
for i in range(1, len(line) - 1)
]
def _pieces() -> tuple:
return plan.slope_pieces(ROUTE, RECORDS, 0.0, END, 2.0)
def test_비탈_끝_30도_넘는_꺾임() -> None:
edge, fill, cut, _ticks = _pieces()
fill_turns, cut_turns = _turns(fill["lines"]), _turns(cut["lines"])
over = sum(t > 30 for t in fill_turns + cut_turns)
# 남는 하나 = 측점 860 · 861 사이 1 m 에 비탈 폭 4.4 → 6.0 m(측점 자리라 그대로)
assert over <= 1, over
assert max(fill_turns + cut_turns) < 40.0
assert sum(t > 15 for t in fill_turns + cut_turns) <= 25
assert max(_turns(edge["lines"])) < 10.0 # 도로 끝은 손대지 않음
def test_둥글림을_끄면_꺾임이_남음() -> None:
"""기준이 바뀐 길을 재는지 — 둥글림만 끄면 30° 넘는 꺾임이 그대로 나옴."""
keep = plan.ROUND_TURN_DEG
plan.ROUND_TURN_DEG = 1e9
try:
_edge, fill, cut, _ticks = _pieces()
finally:
plan.ROUND_TURN_DEG = keep
turns = _turns(fill["lines"]) + _turns(cut["lines"])
assert sum(t > 30 for t in turns) >= 5 and max(turns) > 90.0
def test_측점_비탈_끝은_그대로() -> None:
"""남은 측점 비탈 끝 점은 둥글림 뒤에도 같은 자리 점으로 남음."""
keep = plan.ROUND_TURN_DEG
plan.ROUND_TURN_DEG = 1e9
try:
_e, fill0, cut0, _t = _pieces()
finally:
plan.ROUND_TURN_DEG = keep
_e, fill, cut, _t = _pieces()
before = {tuple(p) for line in fill0["lines"] + cut0["lines"] for p in line}
after = {tuple(p) for line in fill["lines"] + cut["lines"] for p in line}
for record in RECORDS:
x, y, nx, ny = plan.route_normal(ROUTE, record["chainage_m"])
for side in ("left", "right"):
offset = record[f"{side}_end_m"]
want = (round(x + nx * offset, 4), round(y + ny * offset, 4))
if want in before:
assert want in after, (record["chainage_m"], side)
DRIVER = """
import { readFileSync, writeFileSync } from "node:fs";
import { slopePieces } from "./B05_Profile_PlanFootprint.js";
const c = JSON.parse(readFileSync(process.argv[2], "utf-8"));
writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, 0, c.end, 2)));
"""
@pytest.fixture(scope="module")
def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("ts_round")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
"--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
payload = {"route": ROUTE, "records": RECORDS, "end": END}
(out / "in.json").write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads((out / "out.json").read_text(encoding="utf-8"))
def test_TS_도_같은_점(ts_pieces) -> None:
edge, fill, cut, ticks = _pieces()
for name, want, got in (
("도로 끝", edge["lines"], ts_pieces["edge"]),
("쌓기", fill["lines"], ts_pieces["fill"]),
("깎기", cut["lines"], ts_pieces["cut"]),
("눈금", ticks["ticks"], ts_pieces["ticks"]),
):
assert len(want) == len(got), f"{name} 선 수"
for line_w, line_g in zip(want, got):
assert len(line_w) == len(line_g), f"{name} 점 수"
for pw, pg in zip(line_w, line_g):
assert abs(pw[0] - pg[0]) < 2e-4 and abs(pw[1] - pg[1]) < 2e-4, (name, pw, pg)