Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-18 22:04:51 +09:00
15 changed files with 509 additions and 79 deletions
+5 -5
View File
@@ -447,10 +447,11 @@ export async function mountRouteEditor(
historyControls.sync();
}
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430 · 433-14 ②). */
const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();
function tellDraft(): void {
if (!history) return;
const dirty = lineChanged(history) || pickedTerrain() !== designTerrain || snap.changed();
const dirty = lineChanged(history) || otherChanged();
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
}
@@ -587,8 +588,7 @@ export async function mountRouteEditor(
projectId,
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () =>
(!!history && lineChanged(history)) || pickedTerrain() !== designTerrain || snap.changed(),
changed: () => (!!history && lineChanged(history)) || otherChanged(),
terrain: pickedTerrain,
snap: snap.draft,
initial,
@@ -627,7 +627,7 @@ export async function mountRouteEditor(
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, snap.draft);
history = draftLink.watch(history, options.draft, snap.draft, otherChanged);
draftLink.restore(options.draft, restoreSnapshot);
historyControls.sync();
syncCurveBar();
@@ -58,15 +58,18 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
};
}
/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. */
/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로.
* `changed` = 노선 밖 바뀜(스냅 · 분석 지형) — 노선이 처음으로 돌아가도 이것이면 초안(433-14 ②). */
export function watch(
history: RouteEditHistory,
bridge: RouteDraftBridge | undefined,
snap: () => SnapSetting,
changed: () => boolean = () => false,
): RouteEditHistory {
if (!bridge) return history;
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
if (snapshot) bridge.edited(lineChanged(history) ? draftOfSnapshot(snapshot, snap()) : null);
if (snapshot)
bridge.edited(lineChanged(history) || changed() ? draftOfSnapshot(snapshot, snap()) : null);
return snapshot;
};
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
+17 -3
View File
@@ -195,8 +195,8 @@ export function handleAtScreen(
px: number,
py: number,
hitPx: number,
): { node: number; end: "start" | "end" } | null {
let best: { node: number; end: "start" | "end" } | null = null;
): { node: number; end: "start" | "end"; distance: number } | null {
let best: { node: number; end: "start" | "end"; distance: number } | null = null;
let bestDistance = hitPx;
curves.forEach((curve) => {
(["start", "end"] as const).forEach((which) => {
@@ -205,7 +205,7 @@ export function handleAtScreen(
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) {
bestDistance = distance;
best = { node: curve.node_first, end: which };
best = { node: curve.node_first, end: which, distance };
}
});
});
@@ -382,6 +382,20 @@ export function pickAtScreen(
return node >= 0 ? { node } : null;
}
/** 누름 · 맴돔에 이길 것(433-14 ③ · 433-21 ③) — 노드 가운데 `corePx` 안 = 노드(`pick`) · 밖 = 마커 ·
* 노드 · 손잡이 중 가까운 것(같으면 그 차례) · 셋 다 없으면 `pick`(눈금 · 빈 곳). 거리 = 화면 px · 없으면 Infinity. */
export function pressWinner(
nodeDistance: number,
corePx: number,
markDistance: number,
handleDistance: number,
): "mark" | "pick" | "handle" {
if (nodeDistance <= corePx) return "pick";
const best = Math.min(markDistance, nodeDistance, handleDistance);
if (best === Infinity) return "pick";
return markDistance === best ? "mark" : nodeDistance === best ? "pick" : "handle";
}
/** 맴도는 자리(381-3) — `Pointer` 가 적고 `Render` 가 고리 · 파랑으로 밝힘. 창(캔버스)마다 따로. */
const hovers = new WeakMap<HTMLCanvasElement, RouteEditPick>();
export const setPickHover = (canvas: HTMLCanvasElement, pick: RouteEditPick): void =>
+94 -51
View File
@@ -25,6 +25,7 @@ import {
nodeAtScreen,
pickAtScreen,
pickHover,
pressWinner,
segmentAtScreen,
setPickHover,
stationAtScreen,
@@ -42,7 +43,7 @@ import {
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기(381-3 — 9 → 12). */
const NODE_HIT_PX = 12;
/** 노드 가운데 이 안(px)은 횡단 눈금이 가까워도 노드(381-3). */
/** 노드 가운데 이 안(px)은 횡단 눈금 · 손잡이 · 구조물 마커가 가까워도 노드(381-3 · 433-14 ③ · 433-21 ③). */
const NODE_CORE_PX = 6;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
@@ -121,7 +122,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const { canvas, status, toScreen, toMetric } = params;
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => {
const handleAt = (
px: number,
py: number,
): { node: number; end: "start" | "end"; distance: number } | null => {
const { curveInfo, curveLock, apexLock } = params.state();
return handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
@@ -199,6 +203,23 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
let dragOff = 0;
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
/** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */
const chooseAt = (px: number, py: number) => {
const pick = pickAt(px, py);
const node = pick && "node" in pick ? toScreen(params.state().planned[pick.node]) : null;
const handle = handleAt(px, py);
const won = pressWinner(
node ? Math.hypot(node[0] - px, node[1] - py) : Infinity,
NODE_CORE_PX,
marks.distance(px, py),
handle?.distance ?? Infinity,
);
return {
mark: won === "mark",
pick: won === "pick" ? pick : null,
handle: won === "handle" ? handle : null,
};
};
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
@@ -211,8 +232,9 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
}
const state = params.state();
hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3)
// 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫)
if (marks.press(px, py)) {
// 구조물 마커 — 교각점 가운데 밖이면 교각점 · 손잡이보다 가까울 때(433-21 ③) · 고른 교각점은 풂(마커 방향키 몫)
const choice = chooseAt(px, py);
if (choice.mark && marks.press(px, py)) {
if (state.picked >= 0) {
params.setPicked(-1);
params.syncCurveBar();
@@ -222,17 +244,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return;
}
marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로)
const pick = pickAt(px, py);
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
// 노드 가운데(6px)는 노드 · 그 밖은 횡단 눈금과 가까운 쪽(381-3 `pickAt`).
const pick = choice.pick;
// 노드 가운데(6px)는 노드 — 헤어핀처럼 곡선이 몰린 데서도 노드를 집음(2026-09-07 사용자 지적 ④) ·
// 그 밖은 횡단 눈금 · 손잡이와 가까운 쪽(381-3 `pickAt` · 433-14 ③ 낮은 확대 R 12 손잡이).
const hitNode = pick && "node" in pick ? pick.node : -1;
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragRaw = null;
dragOff = params.offStation();
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragHandle = choice.handle;
dragMoved = false;
if (hitNode >= 0) {
params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
@@ -298,10 +318,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
canvas.setPointerCapture(event.pointerId);
});
/** 스냅 미리보기 — 맞추기 전 자리를 쥔 뒤 맞춤(끔이면 쥐지 않음). */
const fitPreview = (planned: Vertex[]): void => {
/** 스냅 미리보기 — 맞추기 전 자리를 쥔 뒤 교각점 `index` 자리 그대로 맞춤(끔이면 쥐지 않음). */
const fitPreview = (planned: Vertex[], index = dragNode): void => {
const raw = planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
dragRaw = params.fitDrag(dragNode) ? raw : null;
dragRaw = params.fitDrag(index) ? raw : null;
};
canvas.addEventListener("pointermove", (event) => {
@@ -311,42 +331,57 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
if (marks.move(px, py)) return;
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
// 노드 갈래와 같게(433-21 ①) — 맞추기 전 자리에서 셈 · 미리보기 맞춤 · 측점 밖이 늘면 그 걸음 되돌림.
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
// 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const kept = [state.planned[node], state.curveRadius[node], state.curveOn[node]] as const;
state.planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
state.curveRadius[node] = Math.max(
radiusFloorM(state.nodeInfo[node]?.inner_angle_deg, state.limitRadiusM, state.limitArcM),
Math.round(moved.radius * 100) / 100,
);
state.curveOn[node] = true;
params.setPicked(node);
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
if (dragRaw) {
dragRaw.forEach((vertex, index) => (state.planned[index] = vertex));
params.markEdited();
const after = params.state();
if (
shortfallCrossed(
before,
curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM),
)
) {
[state.planned[node], state.curveRadius[node], state.curveOn[node]] = kept;
params.markEdited();
status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`;
params.draw();
return;
}
dragMoved = true;
params.syncCurveBar();
status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`;
params.draw();
}
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (!moved) {
if (dragRaw) fitPreview(params.state().planned, node);
return;
}
// 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430).
const now = params.state();
const before = curveShortfalls(now.nodeInfo, now.limitRadiusM, now.limitArcM);
const kept = [now.planned[node], now.curveRadius[node], now.curveOn[node]] as const;
const back = (message: string): void => {
const undo = params.state();
dragRaw?.forEach((vertex, index) => (undo.planned[index] = vertex));
[undo.planned[node], undo.curveRadius[node], undo.curveOn[node]] = kept;
params.markEdited();
fitPreview(undo.planned, node);
status.textContent = `${params.routeHead()} — ${message}`;
params.draw();
};
now.planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
now.curveRadius[node] = Math.max(
radiusFloorM(now.nodeInfo[node]?.inner_angle_deg, now.limitRadiusM, now.limitArcM),
Math.round(moved.radius * 100) / 100,
);
now.curveOn[node] = true;
params.setPicked(node);
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
params.markEdited();
const after = params.state();
if (
shortfallCrossed(
before,
curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM),
)
)
return back(`하한에 걸려 더 못 옮깁니다${floorNote(after.limitRadiusM, after.limitArcM)}.`);
fitPreview(after.planned, node);
if (dragRaw && params.offStation() > dragOff)
return back("곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.");
dragMoved = true;
params.syncCurveBar();
status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`;
params.draw();
return;
}
if (dragNode >= 0) {
@@ -396,10 +431,11 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
const measuring = event.shiftKey || params.measureMode();
if (measuring && params.measure.preview(px, py)) params.draw();
// 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔.
// 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘.
const onMark = !measuring && marks.over(px, py);
const over = measuring || onMark ? null : pickAt(px, py);
// 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈(`chooseAt` · 433-21 ③) · 재는 중엔 끔.
// 마커 · 손잡이가 이기면 밑에 깔린 교각점 · 눈금은 안 밝힘.
const choice = measuring ? null : chooseAt(px, py);
const onMark = choice?.mark === true;
const over = choice?.pick ?? null;
hover(over);
const overNode = over && "node" in over ? over.node : -1;
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
@@ -409,7 +445,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
? state.apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
: choice?.handle
? "grab"
: over
? "pointer"
@@ -422,6 +458,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
marks.release();
if (!dragMoved && dragRaw) {
// 막힌 걸음만 하고 놓음 — 맞추지 않은 기준이면 미리보기 맞춤이 남으므로 누르기 전 자리로(433-14 ①)
const now = params.state().planned;
dragRaw.forEach((vertex, index) => (now[index] = vertex));
params.markEdited();
params.draw();
}
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
@@ -264,8 +264,8 @@ export interface StructureMarkInput {
move: (px: number, py: number) => boolean;
/** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */
release: () => boolean;
/** 마커 위인가 — 커서 몫. */
over: (px: number, py: number) => boolean;
/** 가장 가까운 마커까지 화면 거리(px) — 없으면 Infinity(누름 · 맴돔 · 커서가 노드 · 손잡이와 견줌 433-21 ③). */
distance: (px: number, py: number) => number;
/** 이 평면 마커가 고른 것이면 풂 · true(409 — 마커 아닌 곳 누름 · 다른 화면이 고른 것은 그대로 · 440). */
deselect: () => boolean;
}
@@ -287,7 +287,7 @@ export function bindStructureMarks(
const state = params.state();
return state.plannedLine.length ? state.plannedLine : state.planned;
};
const markAt = (px: number, py: number): PlanMark | null => {
const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => {
const found = planMarks(canvas);
const line = lineNow();
let best: PlanMark | null = null;
@@ -299,7 +299,7 @@ export function bindStructureMarks(
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) [best, bestDistance] = [mark, distance];
}
return best;
return best ? { mark: best, distance: bestDistance } : null;
};
const say = (moving: Moving, done: boolean): void => {
const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM;
@@ -401,7 +401,7 @@ export function bindStructureMarks(
return {
press(px, py) {
const mark = markAt(px, py);
const mark = markAt(px, py)?.mark;
if (!mark) return false;
release();
planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름)
@@ -425,7 +425,7 @@ export function bindStructureMarks(
return true;
},
release: () => !holdKey && finish(),
over: (px, py) => markAt(px, py) !== null,
distance: (px, py) => markAt(px, py)?.distance ?? Infinity,
// 빈 곳 누름 = 이 마커가 고른 것만 풂 — B06 카드 · 목록 · 알약이 고른 것은 그대로(440) · Esc 는 앞 그대로
deselect: () => planMarks(canvas)?.picked === ownPick && deselect(),
};
@@ -404,7 +404,7 @@ async function run() {
marked: (dragRows ?? []).filter((row) => row[2] === 999).length,
};
// ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번)
// ⑧ 노선 한 걸음 + 계획고 맞춤 — 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번)
let graded = false;
const stopShift = qPipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "profile" || graded) return;
@@ -413,7 +413,7 @@ async function run() {
void Promise.resolve().then(() =>
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }),
);
}, "중심점 좌우");
}, "노선 한 걸음 + 계획고 맞춤");
mark = runs().length;
qBridge.edited(draftOf(shift(1, 0, 1)));
await settle(mark + 2);
@@ -204,6 +204,110 @@ function sweep(fixedR) {
}
const swept = { auto: sweep(false), fixed: sweep(true) };
// ④-2 손잡이(BC · EC) 끌기 훑기(433-21 ①) — `_Pointer` 손잡이 갈래 그대로: 맞추기 전 자리에서 셈 · 하한 막힘 ·
// 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림(`guard`) · 놓을 때 정본 맞춤. IP 셋 × 두 끝 × 12 방향 × 3 거리.
function sweepHandle(fixedR, guard = true) {
const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1));
if (fixedR)
start.curveRadius = edits
.rebuildLine(copy(start), LIMITS)
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
const floor = (built) =>
edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL);
const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] };
for (const K of [2, 3, 4])
for (const which of ["start", "end"])
for (let deg = 0; deg < 360; deg += 30)
for (const reach of [9, 17, 26]) {
const hist = copy(start);
let st = { ...copy(hist), curveOn: [...hist.curveOn] };
let built = edits.rebuildLine(st, LIMITS);
const offBefore = off(built);
const c0 = built.curves.find((c) => c.node_first === K);
if (!c0) continue;
const h0 = which === "start" ? c0.start : c0.end;
let raw = null;
let movedOnce = false;
const preview = () => {
raw = st.planned.map(([x, y]) => [x, y]);
st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) };
built = edits.rebuildLine(st, LIMITS);
};
for (let t = 0.5; t <= reach + 1e-9; t += 0.5) {
if (raw) raw.forEach((v, i) => (st.planned[i] = v));
built = edits.rebuildLine(st, LIMITS);
const c = built.curves.find((entry) => entry.node_first === K);
const to = [
h0[0] + Math.cos((deg * Math.PI) / 180) * t,
h0[1] + Math.sin((deg * Math.PI) / 180) * t,
];
const moved = c
? curve.dragHandleTo(
st.planned[K - 1],
[c.apex[0], c.apex[1]],
st.planned[K + 1],
which,
to,
)
: null;
if (!moved) {
if (raw) preview();
continue;
}
const before = floor(built);
const kept = [st.planned[K], st.curveRadius[K], st.curveOn[K]];
const back = () => {
raw?.forEach((v, i) => (st.planned[i] = v));
[st.planned[K], st.curveRadius[K], st.curveOn[K]] = kept;
built = edits.rebuildLine(st, LIMITS);
preview();
};
st.planned[K] = moved.apex;
st.curveRadius[K] = Math.max(
edits.radiusFloorM(
built.nodes[K]?.inner_angle_deg,
LIMITS.limitRadiusM,
LIMITS.limitArcM,
),
Math.round(moved.radius * 100) / 100,
);
st.curveOn[K] = true;
built = edits.rebuildLine(st, LIMITS);
if (edits.shortfallCrossed(before, floor(built))) {
back();
continue;
}
preview();
if (guard && off(built) > offBefore) {
back();
tally.strayed += 1;
continue;
}
movedOnce = true;
}
tally.drags += 1;
if (!movedOnce) continue;
built = edits.rebuildLine(st, LIMITS);
if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue;
const from = fit.fitStart(hist, st);
st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) };
built = edits.rebuildLine(st, LIMITS);
tally.released += 1;
const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL);
if (left.length) {
tally.offAfter += 1;
if (tally.worst.length < 5) tally.worst.push({ K, which, deg, reach, mc: left });
}
}
return tally;
}
const sweptHandle = {
auto: sweepHandle(false),
fixed: sweepHandle(true),
unguarded: { auto: sweepHandle(false, false), fixed: sweepHandle(true, false) },
};
// ③ MC 측점 값 · 눈금 글
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
const snapped = Object.fromEntries(
@@ -227,6 +331,7 @@ console.log(
keys,
drag,
swept,
sweptHandle,
snapped,
labels,
scale: [0.1, 1, 10, null].map(frame.mcScale),
@@ -0,0 +1,158 @@
/* 453 평면 집기 · 스냅만 바꾼 초안 헬퍼 — 진짜 `_RouteEdit_Input`(pressWinner · handleAtScreen) ·
* `_RouteEdit_History` · `_RouteEdit_Draft`(다리 감싸기)를 트랜스파일해 돌림. 결과 JSON 한 벌(표준출력).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const data = new Map();
globalThis.sessionStorage = {
get length() {
return data.size;
},
key: (i) => [...data.keys()][i] ?? null,
getItem: (k) => (data.has(k) ? data.get(k) : null),
setItem: (k, v) => data.set(k, String(v)),
removeItem: (k) => data.delete(k),
};
const win = new EventTarget();
win.setTimeout = setTimeout;
win.clearTimeout = clearTimeout;
win.sessionStorage = globalThis.sessionStorage;
win.localStorage = { getItem: () => null };
globalThis.window = win;
const STUBS = {
"@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback },
"@util/common_util_svg": { stationLabel: (c, i) => `No.${Math.round(c / i)}` },
};
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const page = path.join(ROOT, "B05_Profile");
const input = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_Input.ts"));
const edit = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts"));
const draftLink = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_Draft.ts"));
// ① 누름 판정 — `_Pointer.chooseAt` 와 같은 셈(노드 · 손잡이는 화면 8 px 떨어짐 · 433-14 ③ 낮은 확대 R 12)
const toScreen = ([x, y]) => [x, y];
const node = [100, 100];
const curves = [{ node_first: 1, start: [108, 100], end: [100, 108] }];
const choose = (px, py, markDistance = Infinity) => {
const pick = input.pickAtScreen([[0, 0], node, [200, 200]], toScreen, px, py, 12, 6, null);
const at = pick && "node" in pick ? toScreen(node) : null;
const handle = input.handleAtScreen(curves, toScreen, px, py, 14);
return input.pressWinner(
at ? Math.hypot(at[0] - px, at[1] - py) : Infinity,
6,
markDistance,
handle?.distance ?? Infinity,
);
};
const pick = {
handleSide: choose(107, 100), // 노드 7 px(가운데 밖) · 손잡이 1 px — 손잡이
handleCenter: choose(108, 100), // 손잡이 위 · 노드 8 px — 손잡이
coreEdge: choose(106, 100), // 노드 6 px(가운데 끝) · 손잡이 2 px — 노드
nodeCore: choose(102, 100), // 노드 2 px(가운데 안) — 노드
nodeCloser: choose(93, 100), // 노드 7 px · 손잡이 15 px(문턱 밖) — 노드
handleOnly: choose(116, 100), // 노드 16 px(문턱 밖) · 손잡이 8 px — 손잡이
nothing: choose(150, 150), // 빈 곳 — pick(null 그대로)
markOnCore: choose(103, 100, 1), // 마커 1 px 이어도 노드 가운데 3 px — 노드(433-21 ③)
markCloser: choose(93, 100, 3), // 노드 7 px · 마커 3 px — 마커
nodeBeatsMark: choose(93, 100, 9), // 노드 7 px · 마커 9 px — 노드
tie: input.pressWinner(8, 6, 8, 8), // 같으면 마커 → 노드 → 손잡이
handleDistance: input.handleAtScreen(curves, toScreen, 111, 100, 14)?.distance ?? null,
};
// ② 스냅만 바꾼 뒤 노선 처음으로 — 다리 초안(433-14 ②)
const snap = (x) => ({
planned: [
[0, 0],
[x, 0],
[100, 0],
],
curveOn: [true, true, true],
curveRadius: [null, null, null],
curveLock: [null, null, null],
curveArc: [null, null, null],
apexLock: [false, false, false],
picked: -1,
});
const draft = (snapChanged) => {
const told = [];
const bridge = { edited: (d) => told.push(d ? d.nodes[1][0] : null), saved: () => null };
const history = draftLink.watch(
edit.createRouteEditHistory(snap(50)),
bridge,
() => ({ stationSnap: true, snapStepM: 1 }),
() => snapChanged,
);
history.commit(snap(60));
history.undo();
history.redo();
history.undo();
return told;
};
const old = (() => {
const told = [];
const bridge = { edited: (d) => told.push(d ? d.nodes[1][0] : null), saved: () => null };
const history = draftLink.watch(edit.createRouteEditHistory(snap(50)), bridge, () => ({}));
history.commit(snap(60));
history.undo();
return told;
})();
// ③ 맞추지 않은 기준에서 막힌 걸음만 하고 놓음(433-14 ① · req269 idem 재현) — 막힌 걸음 미리보기
// (`fitPreview` = `fitDrag`)가 노선을 바꿈 → `endDrag` 가 맞추기 전 자리(`dragRaw`)로 되돌림.
const fit = loadTs(path.join(page, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: 20 };
const ROUTE = [[0, 0]];
{
let seed = 1;
const rnd = () => ((seed = (seed * 16807) % 2147483647), seed / 2147483647);
let a = 0;
for (let i = 0; i < 14; i += 1) {
a += (rnd() - 0.5) * 1.6;
const L = 35 + rnd() * 60;
const q = ROUTE[ROUTE.length - 1];
ROUTE.push([q[0] + Math.cos(a) * L, q[1] + Math.sin(a) * L]);
}
}
const cols = (planned) => ({
planned,
curveOn: planned.map(() => true),
curveRadius: planned.map(() => null),
curveLock: planned.map(() => null),
curveArc: planned.map(() => null),
apexLock: planned.map((_, i) => i === 0 || i === planned.length - 1),
});
const gap = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1])));
const blocked = { drags: 0, previewMoved: 0, leftAfterRelease: 0 };
for (let K = 1; K < ROUTE.length - 1; K += 1) {
const base = ROUTE.map(([x, y]) => [x, y]);
const dragRaw = base.map(([x, y]) => [x, y]); // 막힌 걸음 = 누르기 전 자리 그대로 맞춤
let planned = fit.fitDrag(cols(base), base, K, LIMITS);
blocked.drags += 1;
if (gap(planned, base) > 1e-6) blocked.previewMoved += 1;
dragRaw.forEach((vertex, index) => (planned[index] = vertex)); // endDrag(!dragMoved && dragRaw)
if (gap(planned, base) > 1e-6) blocked.leftAfterRelease += 1;
}
process.stdout.write(
JSON.stringify({ pick, blocked, draft: { snapChanged: draft(true), same: draft(false), old } }),
);
+2 -2
View File
@@ -12,7 +12,7 @@
- B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음.
못박는 것(365-4)
- 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔.
- 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만).
- 노선 한 걸음 + 계획고 맞춤 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만).
- B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림.
못박는 것(365-5)
- 들어온 화면 무관 — 설계 칸 = 세션 저장 상세 · 초안 자리 시설 = 차례 짝 · 제원 지문 ·
@@ -147,7 +147,7 @@ def test_365_4_저장_노선_계획고_횡단_한_벌(r):
assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈)
def test_365_4_중심점_좌우_횡단_한_번(r):
def test_365_4_노선_한_걸음_계획고_맞춤_횡단_한_번(r):
runs = r["shiftRuns"]
assert [x["outcome"] for x in runs] == ["dropped", "done"], runs
assert [x["cross"] for x in runs] == [False, True]
@@ -45,12 +45,12 @@ def test_집기_맴돔_한_함수():
assert "pickAtScreen(" in pointer
down = pointer[pointer.index('addEventListener("pointerdown"') :]
down = down[: down.index('addEventListener("pointermove"')]
assert "hover(null);" in down and "const pick = pickAt(px, py);" in down
assert "hover(null);" in down and "const pick = choice.pick;" in down # chooseAt 안 pickAt(433)
assert "nodeAt(px, py)" not in down and "stationAtScreen(" not in down
move = pointer[pointer.index('addEventListener("pointermove"') :]
move = move[: move.index("const endDrag")]
# 끄는 동안은 앞에서 돌아감 — 맴돔은 끌기 갈래 뒤
assert move.index("if (dragNode >= 0)") < move.index("pickAt(px, py)")
assert move.index("if (dragNode >= 0)") < move.index("chooseAt(px, py)")
assert "hover(over);" in move
assert 'addEventListener("pointerleave"' in pointer
+17 -1
View File
@@ -93,6 +93,16 @@ def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None:
assert swept["strayed"] == 0
@pytest.mark.parametrize("mode", ["auto", "fixed"])
def test_손잡이_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None:
"""433-21 ① — 손잡이(BC · EC) 끌기도 노드 갈래와 같게: 미리보기 측점 밖이 늘면 그 걸음 되돌림 →
놓은 뒤 MC 가 다 측점 위(되돌림 없으면 216 중 20 남음 = 기준이 고침을 잼)."""
swept = got["sweptHandle"][mode]
assert swept["drags"] == 216 and swept["released"] > 0
assert swept["offAfter"] == 0, swept["worst"]
assert got["sweptHandle"]["unguarded"][mode]["offAfter"] > 0
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None:
drag = got["drag"][step]
@@ -105,7 +115,7 @@ def test_끌기_방향키_편집기_이음() -> None:
pointer = (B05 / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(encoding="utf-8")
keys = (B05 / "B05_Profile_UI_RouteEdit_Keys.ts").read_text(encoding="utf-8")
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "params.fitDrag(dragNode)" in pointer
assert "params.fitDrag(index)" in pointer and "fitPreview(after.planned, node);" in pointer
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
@@ -116,6 +126,12 @@ def test_끌기_방향키_편집기_이음() -> None:
move = pointer[pointer.index("const previous = state.planned[dragNode];") :]
assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff")
assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;")
# 손잡이 갈래도 같은 차례(433-21 ①) — 하한 · 측점 밖 막힘 뒤에 옮김 표시
handle = pointer[pointer.index("const node = dragHandle.node;") :]
handle = handle[: handle.index("if (dragNode >= 0) {")]
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex));" in handle
assert handle.index("shortfallCrossed(") < handle.index("params.offStation() > dragOff")
assert handle.index("params.offStation() > dragOff") < handle.index("dragMoved = true;")
assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit
assert edit.count("\n") <= 695
@@ -76,7 +76,7 @@ def test_공용_고름과_같은_길() -> None:
def test_집기_차례는_재기_다음_맨_앞() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
measure = pointer.index("void params.measure.pick(px, py);")
mark = pointer.index("if (marks.press(px, py)) {")
mark = pointer.index("if (choice.mark && marks.press(px, py)) {") # 433-21 ③ 가까운 것일 때
release = pointer.index("marks.deselect();")
node = pointer.index("if (hitNode >= 0) {")
handle = pointer.index("} else if (dragHandle) {")
@@ -60,7 +60,7 @@ def test_빈_곳_누름은_마커가_고른_것만_풂(got) -> None:
def test_누름_마커_먼저_교각점_풂() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
down = pointer[pointer.index("if (marks.press(px, py)) {") :]
down = pointer[pointer.index("if (choice.mark && marks.press(px, py)) {") :]
branch = down[: down.index("marks.deselect();")]
assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch
assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch
@@ -68,9 +68,10 @@ def test_누름_마커_먼저_교각점_풂() -> None:
def test_맴돔_커서_마커_먼저() -> None:
"""433-21 ③ — 맴돔 · 커서는 누름과 같은 판정(`chooseAt` · 노드 가운데 밖이면 가까운 것)."""
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
over = pointer.index("const onMark = !measuring && marks.over(px, py);")
pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);")
over = pointer.index("const onMark = choice?.mark === true;")
pick = pointer.index("const over = choice?.pick ?? null;")
cursor = pointer.index("canvas.style.cursor = onMark")
assert over < pick < cursor
@@ -157,7 +157,13 @@ def test_편집기_배선() -> None:
assert "curveBar.noteFit(snap.text(), before, index)" in main # 놓을 때 맞춤 보고
snap = main.split("bindStationSnap(chrome", 1)[1][:400]
assert "tellDraft();" in snap # 끔 · 간격 바꿈 즉시 계산 줄
assert "snap.changed()" in main.split("function tellDraft()", 1)[1][:400]
assert (
"lineChanged(history) || otherChanged()" in main.split("function tellDraft()", 1)[1][:400]
)
assert (
"const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();"
in main
) # 433-14 ②
assert "plan.node_edits" in main
assert "nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc })" in main
assert len(main.splitlines()) <= 695
@@ -0,0 +1,84 @@
"""453 B05 평면 편집 — 집기 한 판정 · 손잡이 끌기 · 막힌 걸음 놓음 · 스냅만 바꾼 초안.
못박는 것
- 433-14 ③ · 433-21 ③ 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것 · 맴돔 커서 같은 판정.
- 433-14 ① 막힌 걸음만 하고 놓으면 미리보기 맞춤을 누르기 전 자리로(맞추지 않은 기준 13/13 → 0).
- 433-14 ② 스냅 · 분석 지형만 바꾼 뒤 노선이 처음으로 돌아가도 다리 초안 있음(null 아님).
- 433-21 ① 손잡이 끌기 훑기는 `test_390_5_snap_step_ops`.
짝: `helper_453_plan_pick_draft.cjs`(진짜 TS `_Input` · `_History` · `_Draft` · `StationFit`).
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
B05 = HERE.parents[1] / "B05_Profile"
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def got() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_453_plan_pick_draft.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_노드_가운데만_노드_밖은_가까운_것(got) -> None:
p = got["pick"]
assert (
p["handleSide"] == "handle" and p["handleCenter"] == "handle"
) # 예전 = 노드 12 px 안이라 노드
assert p["coreEdge"] == "pick" and p["nodeCore"] == "pick"
assert p["nodeCloser"] == "pick" and p["handleOnly"] == "handle" and p["nothing"] == "pick"
assert p["handleDistance"] == 3
def test_마커_겹친_교점_가운데는_교점(got) -> None:
p = got["pick"]
assert p["markOnCore"] == "pick" # 예전 = 마커 먼저
assert p["markCloser"] == "mark" and p["nodeBeatsMark"] == "pick" and p["tie"] == "mark"
def test_누름_맴돔_커서_한_판정() -> None:
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
down = pointer[pointer.index('addEventListener("pointerdown"') :]
down = down[: down.index('addEventListener("pointermove"')]
assert "const choice = chooseAt(px, py);" in down
assert "if (choice.mark && marks.press(px, py)) {" in down
assert "dragHandle = choice.handle;" in down and "handleAt(px, py)" not in down
move = pointer[pointer.index('addEventListener("pointermove"') :]
move = move[: move.index("const endDrag")]
hover = move[move.index("const measuring") :]
assert "chooseAt(px, py)" in hover and "handleAt(px, py)" not in hover
assert "marks.over(" not in pointer and "pressWinner(" in pointer
def test_막힌_걸음만_놓으면_되돌림(got) -> None:
b = got["blocked"]
assert b["drags"] == 13 and b["previewMoved"] == 13 and b["leftAfterRelease"] == 0
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
end = pointer[pointer.index("const endDrag") :]
assert end.index("if (!dragMoved && dragRaw) {") < end.index("if (dragMoved) {")
def test_스냅만_바꾼_뒤_처음으로_초안_있음(got) -> None:
d = got["draft"]
assert d["snapChanged"] == [60, 50, 60, 50] # 처음 노선(50)이어도 초안
assert d["same"] == [60, None, 60, None] and d["old"] == [60, None]
edit = _read("B05_Profile_UI_RouteEdit.ts")
assert "draftLink.watch(history, options.draft, snap.draft, otherChanged);" in edit
assert "const dirty = lineChanged(history) || otherChanged();" in edit
assert "changed: () => (!!history && lineChanged(history)) || otherChanged()," in edit