fix(B05 평면 측점 스냅): 끌기 · 방향키 하한 검사 = 맞추기 전 노선 — 10 m · 측점 No. 미리보기가 IP 를 밀어 헛걸리던 것 (390-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-17 20:11:30 +09:00
co-authored by Claude Opus 5.5
parent e3b8123f8b
commit a8a8e4faba
4 changed files with 39 additions and 9 deletions
+1
View File
@@ -534,6 +534,7 @@ export async function mountRouteEditor(
const base = stationSnap ? history?.current?.()?.planned : undefined; const base = stationSnap ? history?.current?.()?.planned : undefined;
if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits()); if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits());
if (base) markEdited(); if (base) markEdited();
return base !== undefined;
}, },
}); });
+16 -6
View File
@@ -29,8 +29,8 @@ interface Hold {
index: number; index: number;
before: Vertex[]; before: Vertex[];
start: Vertex; start: Vertex;
/** 키로 옮긴 자리(맞추기 전) — 다음 걸음은 여기서(맞춤이 쌓이지 않음). */ /** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */
at: Vertex; raw: Vertex[] | null;
moved: boolean; moved: boolean;
} }
@@ -41,15 +41,25 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y)); const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y));
/** 스냅 미리보기 — 맞추기 전 노선을 쥔 뒤 맞춤(끔이면 쥐지 않음). */
function fitPreview(now: Hold, planned: Vertex[]): void {
const raw = planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
now.raw = params.fitDrag(now.index) ? raw : null;
}
/** 한 걸음 — 하한을 새로 깨면 그 걸음만 되돌림(끌기와 같은 검사). */ /** 한 걸음 — 하한을 새로 깨면 그 걸음만 되돌림(끌기와 같은 검사). */
function step(now: Hold): void { function step(now: Hold): void {
const state = params.state(); const state = params.state();
if (now.raw) {
now.raw.forEach((vertex, index) => (state.planned[index] = vertex));
params.markEdited();
}
const node = state.planned[now.index]; const node = state.planned[now.index];
if (!node) return; if (!node) return;
const snapStepM = params.stations() !== null ? params.snapStepM() : undefined; const snapStepM = params.stations() !== null ? params.snapStepM() : undefined;
const [dx, dy] = arrowStep(now.key, now.shift, metricAt, snapStepM); const [dx, dy] = arrowStep(now.key, now.shift, metricAt, snapStepM);
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
state.planned[now.index] = [now.at[0] + dx, now.at[1] + dy]; state.planned[now.index] = [node[0] + dx, node[1] + dy];
params.markEdited(); params.markEdited();
const after = params.state(); const after = params.state();
if ( if (
@@ -57,6 +67,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
) { ) {
state.planned[now.index] = node; state.planned[now.index] = node;
params.markEdited(); params.markEdited();
fitPreview(now, state.planned);
status.textContent = status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` + `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`; `(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
@@ -64,8 +75,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
return; return;
} }
now.moved = true; now.moved = true;
now.at = state.planned[now.index]; fitPreview(now, state.planned);
params.fitDrag(now.index);
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`; status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
params.draw(); params.draw();
} }
@@ -112,7 +122,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
index, index,
before: state.planned.map(([x, y]): Vertex => [x, y]), before: state.planned.map(([x, y]): Vertex => [x, y]),
start: [node[0], node[1]], start: [node[0], node[1]],
at: [node[0], node[1]], raw: null,
moved: false, moved: false,
}; };
hold = now; hold = now;
@@ -101,8 +101,9 @@ export interface RouteEditPointerParams {
commit: () => void; commit: () => void;
/** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */ /** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */
snapStepM: () => number; snapStepM: () => number;
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에. */ /** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 ·
fitDrag: (index: number) => void; * 맞췄으면 true(끔이면 false · 아무 일 없음). */
fitDrag: (index: number) => boolean;
} }
export interface RouteEditPointer { export interface RouteEditPointer {
@@ -188,6 +189,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
let dragMoved = false; let dragMoved = false;
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */
let dragRaw: Vertex[] | null = null;
canvas.addEventListener("pointerdown", (event) => { canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -208,6 +211,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const hitNode = pick && "node" in pick ? pick.node : -1; const hitNode = pick && "node" in pick ? pick.node : -1;
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragRaw = null;
dragHandle = hitNode >= 0 ? null : handleAt(px, py); dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false; dragMoved = false;
if (hitNode >= 0) { if (hitNode >= 0) {
@@ -274,6 +278,12 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
canvas.setPointerCapture(event.pointerId); canvas.setPointerCapture(event.pointerId);
}); });
/** 스냅 미리보기 — 맞추기 전 자리를 쥔 뒤 맞춤(끔이면 쥐지 않음). */
const fitPreview = (planned: Vertex[]): void => {
const raw = planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
dragRaw = params.fitDrag(dragNode) ? raw : null;
};
canvas.addEventListener("pointermove", (event) => { canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
@@ -303,6 +313,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return; return;
} }
if (dragNode >= 0) { if (dragNode >= 0) {
if (dragRaw) {
dragRaw.forEach((vertex, index) => (state.planned[index] = vertex));
params.markEdited();
}
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
@@ -320,13 +334,14 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
state.planned[dragNode] = previous; state.planned[dragNode] = previous;
params.markEdited(); params.markEdited();
fitPreview(state.planned);
status.textContent = status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` + `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`; `(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
params.draw(); params.draw();
return; return;
} }
params.fitDrag(dragNode); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본) fitPreview(state.planned); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본)
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`; status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
@@ -370,6 +385,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
dragNode = -1; dragNode = -1;
dragHandle = null; dragHandle = null;
dragMoved = false; dragMoved = false;
dragRaw = null;
// 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①). // 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①).
if (moved) { if (moved) {
params.slopeRefresh(); params.slopeRefresh();
@@ -98,6 +98,9 @@ def test_끌기_방향키_편집기_이음() -> None:
assert "params.fitDrag(dragNode)" in pointer assert "params.fitDrag(dragNode)" in pointer
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer
assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys
assert edit.count("\n") <= 695 assert edit.count("\n") <= 695