fix(453 B05 평면 편집): 노드 가운데만 노드 우선 · 손잡이 끌기 측점 맞춤 · 막힌 걸음 놓음 되돌림 · 스냅만 바꾼 초안
- 433-14 ③ · 433-21 ③ 누름 · 맴돔 · 커서 한 판정(pressWinner) — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것 - 433-21 ① 손잡이 끌기 = 노드 갈래와 같게 — 맞추기 전 자리에서 셈 · 미리보기 맞춤 · 측점 밖이 늘면 그 걸음 되돌림 - 433-14 ① 막힌 걸음만 하고 놓으면 미리보기 맞춤을 누르기 전 자리로 - 433-14 ② 사진첩 다리도 스냅 · 분석 지형 바뀜을 봄 — 노선이 처음으로 돌아가도 초안 - 433-1 test_365 ⑧ 이름 = 노선 한 걸음 + 계획고 맞춤 - 시험 test_453_plan_pick_draft 새로 · 390_5 손잡이 훑기 · 381_3 · 399 · 409 · 430 배선 글 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 } }),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user