From 84876b747be6e7c2fbaca261566120179d6a00a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:53:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20367-3=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=90=EC=9C=A0=EC=A7=80=EC=A0=95=20=EC=95=88=20=ED=95=9C=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=EC=94=A9=20=C2=B7=20=ED=81=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20=EB=94=B0=EB=A1=9C=20=C2=B7=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=A7=89=ED=9E=88=EB=A9=B4=20=EC=97=B4=EB=A6=B0=20?= =?UTF-8?q?=EC=B1=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_profile_free past/future(상태 바뀐 클릭만 · 새 클릭이 future 비움) · undo/redo/canUndo/canRedo · reset 비움 · Backspace = undo - 자유지정 동안 ↶ ↷ 단추 · Ctrl+Z · Ctrl+Shift+Z = 자유지정 쪽만(되돌릴 것 없어도 큰 이력 안 감) - applyEdits 성공 여부 돌려줌 · 막히면(기울기 · 흙덮기) 자유지정 열린 채 - 시험 helper_215_1 ⑥ ⑦ · test_215_1 · 345_3 창 넓힘 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_profile.ts | 11 +-- .../design/logic/design_profile_free.ts | 57 ++++++++++---- .../B05_Profile_UI_Profile_Panel_Tools.ts | 19 +++-- .../tester/helper_215_1_free_profile.cjs | 75 ++++++++++++++++++- resources/tester/test_215_1_free_profile.py | 32 ++++++++ resources/tester/test_345_3_route_body.py | 4 +- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index faa2750cb..6e5986cc6 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { refresh: () => renderBalance(), }); - /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ - function applyEdits(next: AlignmentEdits): void { - if (!base || !alignment) return; + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */ + function applyEdits(next: AlignmentEdits): boolean { + if (!base || !alignment) return false; const candidate = buildAlignment(base, next); if ( base.policy.grade_violation_policy === "block" && @@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) { `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, "error", ); - return; + return false; } // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). - if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false; store.replace(next); history.record(); + return true; } /** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */ diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index 4bb03c965..c658fca63 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -4,7 +4,8 @@ * * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 - * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기. + * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) = + * 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음. * * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 * [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L @@ -181,8 +182,8 @@ export interface FreeDrawDeps { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */ - apply: (next: AlignmentEdits) => void; + /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */ + apply: (next: AlignmentEdits) => boolean | void; /** 모드를 끈다(적용 뒤 · Esc). */ close: () => void; /** 단계 · 안내가 바뀜. */ @@ -198,6 +199,11 @@ export interface FreeDraw { hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void; /** Enter · Escape · Backspace — 먹었으면 true. */ key: (key: string) => boolean; + /** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */ + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; marks: () => FreeMarks; hint: () => string; } @@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { let anchor: number | null = null; let points: FreeVertex[] = []; let target: FreeVertex | null = null; + /** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */ + interface Snap { + phase: FreePhase; + pending: number | null; + range: [number, number] | null; + anchor: number | null; + points: FreeVertex[]; + } + let past: Snap[] = []; + let future: Snap[] = []; + const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] }); + function step(from: Snap[], to: Snap[]): void { + const state = from.pop(); + if (!state) return; + to.push(snap()); + ({ phase, pending, range, anchor, points } = state); + target = null; + deps.changed(); + } function reset(): void { phase = "range"; @@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { anchor = null; points = []; target = null; + past = []; + future = []; } /** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */ @@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { if (!base || end === null || z === null) return; const vertices = [...path(), { chainage_m: end, elevation_m: z }]; const next = buildFreeEdits(base, deps.edits(), vertices); + // 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3) + if (deps.apply(next) === false) return; reset(); - deps.apply(next); deps.close(); } @@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { phase: () => phase, reset, click(chainageM, elevationM, stations) { + const before = snap(); if (phase === "range") { const station = snapStation(stations, chainageM); if (station === null) return true; @@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { } points.push(next); } + if (JSON.stringify(snap()) !== JSON.stringify(before)) { + past.push(before); + future = []; + } target = null; deps.changed(); return true; @@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { return true; } if (key === "Backspace") { - if (phase !== "drawing") return false; - if (points.length) points.pop(); - else { - anchor = null; - phase = "armed"; - } - target = null; - deps.changed(); + if (!past.length) return false; + step(past, future); return true; } return false; }, + undo: () => step(past, future), + redo: () => step(future, past), + canUndo: () => past.length > 0, + canRedo: () => future.length > 0, marks() { const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY; const dots = path(); @@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`; } if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; - return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)"; + return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)"; }, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 12bc11376..6372ff738 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -49,7 +49,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - applyEdits: (next: AlignmentEdits) => void; + /** false = 막힘(기울기 · 흙덮기). */ + applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ selectedStationId: () => string | null; /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ @@ -157,10 +158,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { history.record(); }, canResetToSaved: ctx.canRestoreSaved, - onUndo: () => inner.undo(), - onRedo: () => inner.redo(), - canUndo: () => inner.canUndo(), - canRedo: () => inner.canRedo(), + // 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림. + onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()), + onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()), + canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()), + canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()), hasStraightRuns: () => { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; @@ -327,6 +329,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로. if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) { event.preventDefault(); + // 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3) + if (tools.mode() === "free") { + if (event.shiftKey || isCtrlKey(event, "y")) free.redo(); + else free.undo(); + freeView.redraw(); + return; + } flushHold(); if (event.shiftKey || isCtrlKey(event, "y")) history.redo(); else history.undo(); diff --git a/resources/tester/helper_215_1_free_profile.cjs b/resources/tester/helper_215_1_free_profile.cjs index 3c27f333b..e5a7e9181 100644 --- a/resources/tester/helper_215_1_free_profile.cjs +++ b/resources/tester/helper_215_1_free_profile.cjs @@ -26,7 +26,9 @@ function loadTs(file) { } const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts")); -const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts")); +const align = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); // 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다. const stations = Array.from({ length: 11 }, (_, i) => i * 20); @@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]); out.radiiKeys = Object.keys(next.curve_radii).sort(); const built = align.buildAlignment(base, next); const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; - out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) }; + out.pvi = { + 40: pvi(40), + 60: pvi(60), + 80: pvi(80), + 100: pvi(100), + 120: pvi(120), + 140: pvi(140), + 160: pvi(160), + }; out.ground = { 40: z0(40), 160: z0(160) }; const g1 = (103.46 - z0(40)) / 40; const g2 = (101.2 - 103.46) / 40; out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6)); out.r80 = next.curve_radii["80.000"]; out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null; - out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets); + out.outsideUntouched = + !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets); } // ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작. @@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]); out.enterOutsideDrawing = draw.key("Enter"); } +// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로. +{ + const { draw, log } = harness(); + out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") }; + draw.click(40, 0, stations); + draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임 + draw.click(160, 0, stations); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + draw.click(120, 103, stations); + const dots = () => draw.marks().dots.map((d) => d.chainage_m); + draw.undo(); + out.undo1 = { dots: dots(), canRedo: draw.canRedo() }; + draw.redo(); + out.redo1 = { dots: dots(), canRedo: draw.canRedo() }; + draw.undo(); + draw.click(100, 104, stations); // 새 클릭 = future 비움 + out.newClick = { dots: dots(), canRedo: draw.canRedo() }; + const phases = []; + while (draw.canUndo()) { + draw.undo(); + phases.push(`${draw.phase()}:${draw.marks().edges.length}`); + } + out.undoAll = phases; + out.undoApplied = log.applied.length; + out.undoClosed = log.closed; + draw.redo(); + draw.redo(); + out.redoTwice = { phase: draw.phase(), band: draw.marks().band }; + draw.reset(); + out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() }; +} + +// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로. +{ + let tries = 0; + const draw = free.createFreeDraw({ + base: () => base, + alignment: () => align.buildAlignment(base, align.emptyEdits()), + edits: () => align.emptyEdits(), + apply: () => ((tries += 1), false), + close: () => (tries += 100), + changed: () => undefined, + }); + draw.click(40, 0, stations); + draw.click(160, 0, stations); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + draw.key("Enter"); + draw.click(160, 0, stations); + out.blocked = { + tries, + phase: draw.phase(), + dots: draw.marks().dots.length, + canUndo: draw.canUndo(), + }; +} + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index 2c626af39..1166efad7 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -115,6 +115,38 @@ def test_Backspace_와_Esc_취소(out): assert out["enterOutsideDrawing"] is False +def test_자유지정_되돌리기_다시하기(out): + # 367-3 ⑥ 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · 큰 이력(apply · close) 안 건드림 + assert out["undoEmpty"] == {"can": False, "backspace": False} + assert out["undo1"] == {"dots": [40, 80], "canRedo": True} + assert out["redo1"] == {"dots": [40, 80, 120], "canRedo": False} + assert out["newClick"] == {"dots": [40, 80, 100], "canRedo": False} + # 한 걸음 = 점 · 시작 · 범위 끝 · 범위 첫 · 같은 세로선 다시 누름은 안 쌓임 + assert out["undoAll"] == ["drawing:2", "drawing:2", "armed:2", "range:1", "range:0"] + assert out["undoApplied"] == 0 and out["undoClosed"] == 0 + assert out["redoTwice"] == {"phase": "armed", "band": [40, 160]} + assert out["resetClears"] == {"undo": False, "redo": False} + + +def test_적용_막히면_열린_채(out): + # 367-3 ⑦ Enter · 끝 세로선 둘 다 apply 시도(2) · close 없음 · 점 그대로 + assert out["blocked"] == {"tries": 2, "phase": "drawing", "dots": 2, "canUndo": True} + + +def test_되돌리기_배선_자유지정_먼저(): + panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel + assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel + keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :] + free_at = keys.index('if (tools.mode() === "free") {') + assert ( + free_at < keys.index("history.undo()") + and "return;" in keys[free_at : keys.index("flushHold();")] + ) + profile = _read("A00_Common/design/design_profile.ts") + assert "function applyEdits(next: AlignmentEdits): boolean {" in profile + + def test_도구줄_직선화_오른쪽_자유지정(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts") assert '"자유지정"' in tools diff --git a/resources/tester/test_345_3_route_body.py b/resources/tester/test_345_3_route_body.py index 2a038790d..18cdbd0de 100644 --- a/resources/tester/test_345_3_route_body.py +++ b/resources/tester/test_345_3_route_body.py @@ -48,7 +48,9 @@ def test_Ctrl_Z_는_사진첩_한_줄(out): def test_종단도_Ctrl_Z_줄이_사진첩_하나(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {') - assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240] + assert ( + "undoOwner" not in tools and "history.undo()" in tools[at : at + 600] + ) # 367-3 자유지정 분기 앞에 섬 history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts") assert '"keydown"' not in history and "onPageHistory" in history