diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 8b78f88a7..df9a17e65 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -10,6 +10,9 @@ * 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10). * 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 · * Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음. + * 중간 노드에서 긋기(771-2 · 갈래 숨긴 페이지 = B05) = 다시 긋기의 뒤 노드 열림 — 같은 선 다른 노드에 닿으면 + * 그 사이 갈음(`rejoinSpan` · 거꾸로 됨) · 같은 노드 · 안 닿고 끝냄 = 꼴 그대로 + 안내. + * Esc(771-1) = 이번 긋기 직전 꼴 · 지움 바로 뒤 긋기면 지움 전 꼴(`dropBase`) · 다시 긋기 = 꼴 그대로. * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). @@ -46,7 +49,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; -import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; +import { nodeRecord, rejoinSpan, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; import { fmt, lineAt, @@ -66,6 +69,10 @@ import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」."; +/** 중간 노드에서 긋다 다른 노드에 안 닿고 끝냄(771-2). */ +const RESPAN_MISS = "다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다."; +const REJOIN_MISS = + "다른 노드에 닿지 않아 반영하지 않았습니다 — 다른 노드에 닿으면 그 사이가 바뀝니다."; export type DrawMode = "idle" | "draw"; @@ -75,7 +82,7 @@ export function lineLimitBlock( network: PlanNetwork, ): string | null { if (kind === "line" || kind === "point") - return "예정노선은 한 선입니다 — 선 끝에서 이어 긋습니다."; + return "예정노선은 한 선입니다 — 선 끝에서 이어 긋거나 노드에서 시작해 다른 노드에 닿게 긋습니다."; if (kind === "empty" && network.lines.some((line) => line.vertices.length > 1)) return "예정노선은 한 선입니다 — 지운 뒤 새로 긋습니다."; return null; @@ -107,6 +114,8 @@ export interface PlanDrawParams { commit: (next: PlanNetwork) => void; /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ onMode: () => void; + /** 지금 꼴이 지움 바로 뒤면 지움 전 꼴(771-1 — 지움 → 긋기 중 Esc = 지움까지 되살림) · 아니면 null. */ + dropBase?: () => PlanNetwork | null; } export interface PlanDraw { @@ -138,17 +147,18 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null) let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음 let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 + let restore: PlanNetwork | null = null; // 지움 바로 뒤 긋기 = Esc 로 돌아갈 지움 전 꼴(771-1) let preview: DrawPreview | null = null; let fan: Fan | null = null; let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9) let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시 let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; - // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 + // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점(null = 중간 노드에서 긋기 · 같은 선 어느 노드든 · 771-2) · 새로 찍은 속 점 · 그 기록 let span: { line: string; from: number; - to: number; + to: number | null; through: Vertex[]; records: NodeRecord[]; } | null = null; @@ -207,6 +217,20 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { : null; } + /** 중간 노드에서 긋기 붙을 자리(771-2) — 같은 선 노드 중 가장 가까운 것 · 시작 노드는 한 점 찍은 뒤만. */ + function nodeSnap( + vertices: Vertex[], + px: number, + py: number, + map: ScreenMap, + ): { point: Vertex; note: string; onLine: null } | null { + const best = vertices + .map((point, i) => ({ point, i, gap: screenGap(map, point, px, py) })) + .filter((item) => item.gap <= SNAP_PX && (item.i !== span!.from || span!.through.length > 0)) + .sort((a, b) => a.gap - b.gap)[0]; + return best ? { point: best.point, note: "노드에 닿음", onLine: null } : null; + } + /** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */ function prevOf(): Vertex | null { const network = params.network(); @@ -244,13 +268,15 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { const z = params.heightAt(); const cursor = map.toMetric(px, py); fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null; - // 다시 긋기 = 원래 뒤 노드에만 붙음 - const end = spanVertices?.[span!.to]; + // 다시 긋기 = 원래 뒤 노드에만 붙음 · 중간 노드에서 긋기 = 같은 선 노드(시작 노드는 한 점 찍은 뒤부터) + const end = span?.to === null ? undefined : spanVertices?.[span!.to]; const snap = end ? screenGap(map, end, px, py) <= SNAP_PX ? { point: end, note: "뒤 노드에 붙음", onLine: null } : null - : snapAt(px, py, map); + : spanVertices + ? nodeSnap(spanVertices, px, py, map) + : snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; let lengthOnly = false; @@ -283,6 +309,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { // ── 모드 ──────────────────────────────────────────────── /** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */ function stop(): void { + if (span) showToast(span.to === null ? REJOIN_MISS : RESPAN_MISS, "info"); let network = params.network(); const line = active; const entry = line === null ? null : network.lines[line]; @@ -315,6 +342,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { active = null; flip = false; strokeStart = null; + restore = null; preview = null; fan = null; span = null; @@ -330,6 +358,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { } setMode("draw"); span = { line: lineId, from, to, through: [], records: [] }; + restore = params.dropBase?.() ?? null; panelState(); params.onMode(); return true; @@ -383,12 +412,24 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { function begin(px: number, py: number, map: ScreenMap, free = false): boolean { const network = params.network(); const target = aim(px, py, map); - const single = featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network); + const branches = featureOn(params.page, "branch"); + // 한 선 페이지(B05) 중간 노드 = 그 노드에서 긋기 · 다른 노드에 닿으면 그 사이 갈음(771-2 · 닿기 전엔 꼴 그대로) + const vertex = target.kind === "line" ? target.hit.vertex : null; + if (!branches && target.kind === "line" && vertex !== null && vertex > 0) { + const line = network.lines[target.hit.line]; + if (vertex < line.vertices.length - 1) { + span = { line: line.id, from: vertex, to: null, through: [], records: [] }; + restore = params.dropBase?.() ?? null; + return true; + } + } + const single = branches ? null : lineLimitBlock(target.kind, network); if (single) { showToast(single, "info"); return false; } strokeStart = network; + restore = params.dropBase?.() ?? null; if (target.kind === "tip") { if (target.head) { const turned = reverseLine(network, target.line); @@ -453,11 +494,17 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { span.records.push(record); return true; } - const next = replaceSpan( + const vertices = params.network().lines[spanLine()].vertices; + const end = + span.to ?? + (samePoint(vertices[span.from], preview.point) + ? span.from + : vertices.findIndex((v) => samePoint(v, preview!.point))); + const next = (span.to === null ? rejoinSpan : replaceSpan)( params.network(), spanLine(), span.from, - span.to, + end, span.through, span.records, ); @@ -486,7 +533,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let network = params.network(); let stroke = active; const index = spanLine(); - const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null; + const dim = + span && span.to !== null && index >= 0 + ? { line: index, from: span.from, to: span.to } + : null; if (span && index >= 0) { // 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴) const head = network.lines[index].vertices[span.from]; @@ -568,20 +618,24 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return true; } if (mode === "draw" && keyIs(event, "plan.drawFinish")) { - if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info"); - setMode("idle"); + setMode("idle"); // 다시 긋기 · 중간 노드 긋기 = 안 닿음 알림(stop) return true; } + // Esc = 이번 수정 직전 꼴(지움 바로 뒤 긋기 = 지움 전 꼴 · 771-1) · 다시 긋기 = 꼴 그대로 if (mode === "draw" && event.key === "Escape") { - if (mode === "draw" && strokeStart && strokeStart !== params.network()) - params.commit(strokeStart); + const drew = span + ? span.through.length > 0 + : !!strokeStart && strokeStart !== params.network(); + const back = (drew && restore) || strokeStart; + if (back && back !== params.network()) params.commit(back); active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함 + span = null; setMode("idle"); return true; } if (mode === "draw" && span && keyIs(event, "plan.drawBack")) { span.records.pop(); - if (!span.through.pop()) setMode("idle"); + if (!span.through.pop()) ((span = null), setMode("idle")); return true; } if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index eb6deb8bd..312b2a9f1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -128,6 +128,8 @@ export interface PlanEdit { dropRegion: (line: string) => void; /** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */ dropAll: () => void; + /** 지금 꼴이 선 지움(노드 · 토막 · 구역 · 전체 · 잇단 지움 묶음) 바로 뒤면 지움 전 꼴 · 아니면 null(771-1). */ + dropBase: () => PlanNetwork | null; /** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */ splitAt: (px: number, py: number, map: ScreenMap) => boolean; /** 이 자리에 무엇이 있나(우클릭 메뉴). */ @@ -184,6 +186,17 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { sel.sync(); } + /** 선 지움 — 지움 전 꼴 기억(잇단 지움 = 첫 지움 전 · 771-1). */ + let dropped: { before: PlanNetwork; after: PlanNetwork } | null = null; + const dropBase = (): PlanNetwork | null => + dropped && dropped.after === params.network() ? dropped.before : null; + function drop(next: PlanNetwork | string): void { + const before = params.network(); + const base = dropBase() ?? before; + apply(next); + if (params.network() !== before) dropped = { before: base, after: params.network() }; + } + /** 선 끝 — 첫 점(갈래 아님) · 끝 점 · 그 밖 = null. */ function endOf(node: PlanNode): LineEnd | null { const line = params.network().lines.find((entry) => entry.id === node.line); @@ -217,19 +230,19 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { function dropNode(node: PlanNode): void { const network = params.network(); const index = lineIndexOf(network, node.line); - if (index >= 0) apply(removeVertex(network, index, node.vertex)); + if (index >= 0) drop(removeVertex(network, index, node.vertex)); } function dropRegion(line: string): void { const network = params.network(); const index = lineIndexOf(network, line); - if (index >= 0) apply(removeLine(network, index)); + if (index >= 0) drop(removeLine(network, index)); } function dropAll(): void { const network = params.network(); if (!network.lines.length) return; - apply(removeAll(network)); + drop(removeAll(network)); showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info"); } @@ -239,7 +252,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { if (!picked) return; if (picked.kind === "mark") return dropMark(); if (picked.kind === "node") return dropNode(picked.node!); - if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments)); + if (picked.kind === "segment") return drop(removeSegments(params.network(), picked.segments)); if (picked.kind === "line") return dropRegion(picked.line!); if (picked.kind === "all") return dropAll(); showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info"); @@ -618,6 +631,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { dropMark, dropRegion, dropAll, + dropBase, splitAt, hit, key(event) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts index e07743a39..27a7518ed 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts @@ -94,6 +94,7 @@ export function createPlanKit(params: PlanKitParams): PlanKit { heightAt, commit, onMode: params.onMode, + dropBase: () => edit.dropBase(), // 지움 → 긋기 중 Esc = 지움까지 되살림(771-1) }); const edit = createPlanEdit({ network, diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts index ad88e9d36..2f74a8d29 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -1,6 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_Span.ts * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. + * 중간 노드에서 긋기(771-2 · 요구 613) = `rejoinSpan` — 닿은 노드가 앞이면 거꾸로 · 같은 노드 = 까닭 글. * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). * 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유. @@ -103,3 +104,26 @@ export function replaceSpan( ), }; } + +export const REJOIN_SAME = + "같은 노드에 닿아 반영하지 않았습니다 — 다른 노드에 닿으면 그 사이가 바뀝니다."; + +/** 중간 노드에서 긋기(771-2) — `start` 노드에서 그은 `through` 가 `end` 노드에 닿음 = 두 노드 사이 갈음. + * 거꾸로 닿음(`end` < `start`) = 점 차례를 뒤집어 넣음(속 점 기록 = 선 차례 앞 토막 몫으로 밈) · + * 같은 노드 = 까닭 글 · 그 밖 = `replaceSpan` 과 같음. */ +export function rejoinSpan( + network: PlanNetwork, + line: number, + start: number, + end: number, + through: ReadonlyArray, + records: ReadonlyArray = [], +): PlanNetwork | string { + if (start === end) return REJOIN_SAME; + if (start < end) return replaceSpan(network, line, start, end, through, records); + const back = [...records].reverse(); + return replaceSpan(network, line, end, start, [...through].reverse(), [ + null, + ...back.slice(0, -1), + ]); +} diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py index b23e3e18d..50b4e0435 100644 --- a/resources/tester/test_626_3_expected_edit.py +++ b/resources/tester/test_626_3_expected_edit.py @@ -86,7 +86,8 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): assert 'createElement("button")' not in ui # 숨김 = 기능 표 읽기(635-1 · 플래그 없음) draw = _src(D03 / "D03_PlanAnalysis_UI_Draw.ts") - assert 'featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network)' in draw + assert 'const branches = featureOn(params.page, "branch");' in draw + assert "branches ? null : lineLimitBlock(target.kind, network)" in draw assert 'markButton.hidden = !featureOn(opts.page, "years");' in _src( D03 / "D03_PlanAnalysis_UI_DrawPanel.ts" ) diff --git a/resources/tester/test_771_2_rejoin_span.py b/resources/tester/test_771_2_rejoin_span.py new file mode 100644 index 000000000..e9cd85f2d --- /dev/null +++ b/resources/tester/test_771_2_rejoin_span.py @@ -0,0 +1,110 @@ +"""B05 중간 노드에서 긋기(PLAN 771-2 · 요구 613) — TS `logic/D03_PlanAnalysis_Span.ts` `rejoinSpan`. + +중간 노드에서 그은 점이 같은 선 다른 노드에 닿음 = 두 노드 사이 옛 구간을 새 점으로 갈음 · 한 줄 그대로 · +거꾸로(앞 노드에 닿음) = 점 차례 뒤집어 넣음 · 같은 노드 = 까닭 글(꼴 그대로) · 끝점에 닿음 = 끝까지 갈음 · +노드 기록 = 꼭짓점과 같은 길이. + +./venv/Scripts/python.exe -m pytest resources/tester/test_771_2_rejoin_span.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.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"; +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); +const S = span.nodeRecord("slope", 20, 9); +writeFileSync( + process.argv[2], + JSON.stringify({ + forward: span.rejoinSpan(n, 0, 1, 3, [[150, 50], [250, 50]]), + backward: span.rejoinSpan(n, 0, 3, 1, [[250, 50], [150, 50]]), + same: span.rejoinSpan(n, 0, 2, 2, [[150, 50]]), + head: span.rejoinSpan(n, 0, 2, 0, [[100, -50]]), + tail: span.rejoinSpan(n, 0, 2, 4, [[300, 50]]), + recorded: span.rejoinSpan(n, 0, 3, 1, [[250, 50], [150, 50]], [L, S]), + same_text: span.REJOIN_SAME, + }), +); +""" + + +def _run_ts(tmp_path: Path) -> dict: + out = tmp_path / "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") + result = tmp_path / "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 _vertices(network: dict) -> list: + assert len(network["lines"]) == 1 # 한 줄 그대로 + return network["lines"][0]["vertices"] + + +def test_forward_and_backward(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + want = [[0, 0], [100, 0], [150, 50], [250, 50], [300, 0], [400, 0]] + assert _vertices(r["forward"]) == want + assert _vertices(r["backward"]) == want # 거꾸로 그어도 선 차례로 + + +def test_same_node_and_ends(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + assert r["same"] == r["same_text"] + assert "같은 노드" in r["same"] + assert _vertices(r["head"]) == [[0, 0], [100, -50], [200, 0], [300, 0], [400, 0]] + assert _vertices(r["tail"]) == [[0, 0], [100, 0], [200, 0], [300, 50], [400, 0]] + + +def test_backward_records(tmp_path: Path) -> None: + line = _run_ts(tmp_path)["recorded"]["lines"][0] + nodes = line["nodes"] + assert len(nodes) == len(line["vertices"]) == 6 # 서버 400 막음 + # 선 차례 [150,50] 앞 토막 = 닿은 자리(자유 · null) · [250,50] 앞 토막 = 둘째 찍은 점 기록(S) · 끝 노드 = 자유 + assert nodes[2] is None + assert nodes[3]["kind"] == "slope" + assert nodes[4]["kind"] == "free"