feat(B05): 367-3 자유지정 되돌리기 · 다시하기 — 자유지정 안 한 걸음씩 · 큰 이력과 따로 · 적용 막히면 열린 채
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -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 도 같은 기준). */
|
||||
|
||||
@@ -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 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user