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:
2026-10-17 11:53:36 +09:00
co-authored by Claude Opus 5.5
parent ddc6fe2311
commit 84876b747b
6 changed files with 171 additions and 27 deletions
+6 -5
View File
@@ -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 도 같은 기준). */
+44 -13
View File
@@ -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 취소)";
},
};
}