diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 02d0791d7..acaa42994 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -48,6 +48,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { joinTouching } from "./logic/D03_PlanAnalysis_OneLine"; import { isMiddleNode, nodeRecord, rejoinMiss, rejoinSpan } from "./logic/D03_PlanAnalysis_Span"; import { fmt, @@ -188,7 +189,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return vertices[vertices.length - 1] ?? null; }; - /** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */ + /** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고) · 한 선 페이지 = 다른 토막 끝만(774-2). */ function snapAt( px: number, py: number, @@ -200,16 +201,20 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { .filter((point) => !head || !samePoint(point, head)) .map((point) => ({ point, note: "지점에 붙음" })); for (const point of params.snaps?.() ?? []) offers.push({ point, note: "이음점에 붙음" }); + const one = !featureOn(params.page, "branch"); network.lines.forEach((line, i) => { if (i !== active) - for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" }); + line.vertices.forEach((point, k) => { + if (!one || k === 0 || k === line.vertices.length - 1) + offers.push({ point, note: one ? "끊긴 끝에 이음" : "선 꺾임점에 붙음" }); + }); }); const best = offers .map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) })) .filter((offer) => offer.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; if (best) return { point: best.point, note: best.note, onLine: null }; - const hit = lineAt(network, px, py, map, active); + const hit = one ? null : lineAt(network, px, py, map, active); return hit ? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } } : null; @@ -331,6 +336,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (typeof joined !== "string" && joined !== turned) network = joined; } } + if (!featureOn(params.page, "branch")) network = joinTouching(network); // B05 = 늘 노선 하나(774-2) if (network !== params.network()) params.commit(network); } active = null; diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_OneLine.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_OneLine.ts new file mode 100644 index 000000000..6eda6acb7 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_OneLine.ts @@ -0,0 +1,37 @@ +/* ============================================================================= + * D03_PlanAnalysis_OneLine.ts + * 한 선 페이지(B05 · 기능 표 「branch」 숨김) 예정노선 = 늘 노선 하나(PLAN 774-2 · 요구 618). + * `joinTouching` = 끝이 맞닿은 토막을 한 선으로 이음(꼬리 ↔ 머리 · 꼬리 ↔ 꼬리 · 머리 ↔ 머리 = 뒤집어 맞춤) · + * 갈래 · 점 하나 선은 안 건드림 · 남는 id = 앞 토막(`attachHead` 규칙). 노선 여럿 = D 그룹(D03)만. + * DOM · fetch · sessionStorage 없음. + * ========================================================================== */ + +import { attachHead, reverseLine, samePoint, type PlanNetwork } from "./D03_PlanAnalysis_Plan"; + +/** 끝이 맞닿은 토막을 모두 이어 한 선으로 — 못 이을 것만 남김. */ +export function joinTouching(network: PlanNetwork): PlanNetwork { + let next = network; + for (let again = true; again;) { + again = false; + const ends = next.lines.map((line) => + line.vertices.length > 1 && !line.branch ? [line.vertices[0], line.vertices.at(-1)!] : null, + ); + for (let i = 0; i < ends.length && !again; i += 1) + for (let j = 0; j < ends.length && !again; j += 1) { + const [a, b] = [ends[i], ends[j]]; + if (i === j || !a || !b) continue; + // j 머리가 i 꼬리에 오게 맞춤 + const turned = samePoint(a[1], b[0]) + ? next + : samePoint(a[1], b[1]) + ? reverseLine(next, j) + : samePoint(a[0], b[0]) + ? reverseLine(next, i) + : null; + if (!turned || typeof turned === "string") continue; + const joined = attachHead(turned, j); + if (joined !== turned) [next, again] = [joined, true]; + } + } + return next; +} diff --git a/resources/tester/test_771_2_rejoin_span.py b/resources/tester/test_771_2_rejoin_span.py index 461f79bc2..542b6a87e 100644 --- a/resources/tester/test_771_2_rejoin_span.py +++ b/resources/tester/test_771_2_rejoin_span.py @@ -3,6 +3,7 @@ 중간 노드에서 그은 점이 같은 선 다른 노드에 닿음 = 두 노드 사이 옛 구간을 새 점으로 갈음 · 한 줄 그대로 · 거꾸로(앞 노드에 닿음) = 점 차례 뒤집어 넣음 · 같은 노드 = 까닭 글(꼴 그대로) · 끝점에 닿음 = 끝까지 갈음 · 노드 기록 = 꼭짓점과 같은 길이. +774-2(요구 618) B05 = 늘 노선 하나 `logic/D03_PlanAnalysis_OneLine.ts` `joinTouching` = 맞닿은 토막 이음. 774-1(요구 616) 끝 판정 `rejoinMiss` = 다른 중간 노드만 끝 · 시작 노드 · 끝점 = 계속 긋기 까닭 글 · 시작 = 중간 노드만. ./venv/Scripts/python.exe -m pytest resources/tester/test_771_2_rejoin_span.py -q @@ -17,12 +18,22 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.ts" +ONE_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_OneLine.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" _RUNNER = """ import { writeFileSync } from "node:fs"; import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; import * as span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js"; +import * as one from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_OneLine.js"; +const two = (a, b) => { + let m = plan.newLine(plan.emptyNetwork(), a[0]); + for (const p of a.slice(1)) m = plan.appendVertex(m, 0, p); + m = plan.newLine(m, b[0]); + for (const p of b.slice(1)) m = plan.appendVertex(m, 1, p); + return m; +}; +const flat = (m) => m.lines.map((l) => l.vertices); let n = plan.newLine(plan.emptyNetwork(), [0, 0]); for (const p of [[100, 0], [200, 0], [300, 0], [400, 0]]) n = plan.appendVertex(n, 0, p); const L = span.nodeRecord("length", 20, 9); @@ -41,6 +52,11 @@ writeFileSync( middle: [0, 1, 2, 3, 4].map((v) => span.isMiddleNode(5, v)), tip_text: span.REJOIN_TIP, hold_text: span.REJOIN_HOLD, + join_tail_head: flat(one.joinTouching(two([[0, 0], [1, 0]], [[1, 0], [2, 0]]))), + join_tail_tail: flat(one.joinTouching(two([[0, 0], [1, 0]], [[2, 0], [1, 0]]))), + join_head_head: flat(one.joinTouching(two([[1, 0], [0, 0]], [[1, 0], [2, 0]]))), + join_apart: flat(one.joinTouching(two([[0, 0], [1, 0]], [[5, 0], [6, 0]]))), + join_branch: flat(one.joinTouching(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]))), }), ); """ @@ -53,6 +69,7 @@ def _run_ts(tmp_path: Path) -> dict: "node", str(TSC), str(TS_FILE), + str(ONE_FILE), "--outDir", str(out), "--rootDir", @@ -122,3 +139,13 @@ def test_end_only_other_middle_node(tmp_path: Path) -> None: assert r["miss"] == [None, None, hold, tip, tip, None] assert "중간 노드" in tip and "중간 노드" in hold assert r["middle"] == [False, True, True, True, False] + + +def test_one_line_join_touching(tmp_path: Path) -> None: + """774-2 — 맞닿은 두 토막 = 한 선(어느 끝이든 · 차례 이어짐) · 떨어진 토막 · 갈래 = 그대로.""" + r = _run_ts(tmp_path) + assert r["join_tail_head"] == [[[0, 0], [1, 0], [2, 0]]] + assert r["join_tail_tail"] == [[[0, 0], [1, 0], [2, 0]]] + assert r["join_head_head"] in ([[[0, 0], [1, 0], [2, 0]]], [[[2, 0], [1, 0], [0, 0]]]) + assert len(r["join_apart"]) == 2 + assert len(r["join_branch"]) == 2 # 갈래(D03 꼴)는 안 이음