feat(D03): 예정노선 그리기 — 시점 · 종점 여럿 · 들어올 때 자동 받기 · [긋기] 양끝 · 부분 수정 · 기울기 % · ° 미리보기 · 방향 바꾸기 · 선 고르기 · 지우기 · 연도 (PLAN 579-3 · 580-1~3 · 요구 440 · 441)

- 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함)
- 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험
- 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율)
- 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기]
- 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기
- 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔)
- UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐)
- 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:45:10 +09:00
co-authored by Claude Opus 5.5
parent 23aacda55e
commit 1ddb5cb1b2
11 changed files with 1406 additions and 380 deletions
+125 -6
View File
@@ -1,7 +1,9 @@
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 ·
화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음.
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -19,6 +21,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
@@ -27,7 +30,8 @@ _RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
const Y = 2026;
let n = plan.emptyNetwork([0, 0]);
const empty = plan.emptyNetwork();
let n = plan.newLine(empty, [0, 0]);
n = plan.appendVertex(n, 0, [100, 0]);
n = plan.appendVertex(n, 0, [100, 100]);
n = plan.normalizePieces(n, Y);
@@ -52,10 +56,57 @@ const read = plan.fromServer(
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
Y,
);
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
j = plan.newLine(j, [200, 0]);
j = plan.appendVertex(j, 1, [150, 50]);
j = plan.appendVertex(j, 1, [100, 0]);
j = plan.normalizePieces(j, 2027);
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
const sameYear = plan.normalizePieces(
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
Y,
);
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
let b = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
Y,
);
b = plan.newLine(b, [100, 80]);
b = plan.appendVertex(b, 1, [100, 0]);
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
let m = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
let r = plan.normalizePieces(
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
Y,
);
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
const removeBlocked = plan.removeVertex(m, 0, 1);
const lineBlocked = plan.removeLine(m, 0);
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
const branchFlip = plan.reverseLine(m, 1);
const lineYear = plan.setLineYear(m, "main", 2029);
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
];
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
reached: plan.reachedEnd(n, [100, 100]),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
}));
"""
@@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None:
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
assert got["reached"] is True
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
def test_branch_order_and_guard(tmp_path: Path) -> None:
@@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
("main", 50.0, 200.0, 2027),
("branch-1", 0.0, 60.0, 2026),
]
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
joined = got["joined"]
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
assert len(joined["lines"]) == 1
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
total = 100 + 2 * (50**2 * 2) ** 0.5
spans = _spans(joined)
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
assert abs(spans[1][1] - total) < 1e-3
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
branch = got["asBranch"]["lines"][1]
assert branch["branch"] == {"line": "main", "vertex": 1}
assert branch["vertices"] == [[100, 0], [100, 80]]
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
body = {**joined, "marks": [], "epsg": 5186}
body["marks"] = [
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
]
assert len(server.derive(body, 2026)["pieces"]) == 2
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
moved = got["movedV"]
assert moved["lines"][0]["vertices"][1] == [100, -100]
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
first = (100**2 + 100**2) ** 0.5
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
removed = got["removedV"]
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
assert abs(_spans(removed)[1][0] - share) < 1e-3
assert "갈래가 붙은 점" in got["removeBlocked"]
assert "갈래가 붙은 선" in got["lineBlocked"]
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
assert "갈래는 방향을" in got["branchFlip"]
def test_reverse_year_mark_move(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rev = got["reversed"]
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
server.derive(body, 2026)
def test_line_names_mirror(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
cases = [
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
+2 -1
View File
@@ -47,7 +47,8 @@ def test_d03_parts():
assert '"rotate-ccw", "rotate-cw"' in tools
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
assert "map.stations" in draw and "map.slopes" in draw
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
assert "map.stations" in paint and "map.slopes" in paint
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
assert "createToolbar({" in layers and "createIconButton({" in layers
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers