diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts
index 3d05ffc66..964be9050 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts
@@ -56,7 +56,6 @@ import {
captureView,
createViewMemory,
fitCanvas,
- fitView,
openingView,
restoreSavedView,
screenToMetric,
@@ -92,6 +91,7 @@ import {
import type { DesignPageHost } from "../A00_Common/design/design_page";
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
+import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
@@ -160,7 +160,7 @@ export async function mountRouteEditor(
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
- /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */
+ /** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
let stationSnap = false;
let savedStationSnap = false;
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
@@ -195,7 +195,7 @@ export async function mountRouteEditor(
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
const contourLock = createContourLock(
canvas,
- () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation },
+ () => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour },
(index) => ((pickedContour = index), draw()),
);
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
@@ -266,6 +266,7 @@ export async function mountRouteEditor(
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
stationSnapButton.addEventListener("click", () => {
setStationSnap(!stationSnap);
+ if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
draw();
});
@@ -318,13 +319,6 @@ export async function mountRouteEditor(
rotation.set(opening.rotationRad);
viewReady = true;
}
- // [맞춤] — 처음 그림과 같은 등고 영역 맞춤(회전 0 · 349-0). 셈은 `_View` 몫.
- overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => {
- if (!meta || view.width <= 0 || !planned.length) return;
- view = fitView(meta, view, planned, contours?.layer ?? null);
- rotation.set(0);
- draw();
- });
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
@@ -423,6 +417,7 @@ export async function mountRouteEditor(
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
+ offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
@@ -453,8 +448,12 @@ export async function mountRouteEditor(
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
- /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
- function applyEdit(message: string, record = true): void {
+ /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
+ * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
+ function applyEdit(message: string, record = true, fitting = true): void {
+ const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM };
+ const from = fit.fitStart(history?.current?.(), snapshotNow());
+ if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
@@ -484,7 +483,7 @@ export async function mountRouteEditor(
const locks = record ? routeLocksOf(planned, apexLock) : null;
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
if (locks) apexLock = lockColumn(planned, locks);
- applyEdit(message, record);
+ applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3)
}
const historyControls = bindHistoryControls({
@@ -540,6 +539,7 @@ export async function mountRouteEditor(
curveHint,
contourHint,
commit: () => {
+ if (stationSnap) return applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 놓을 때(366-3)
history?.commit(snapshotNow());
historyControls.sync();
},
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
index 211b0658a..13021c9e8 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
@@ -11,6 +11,7 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
+ * 도구줄 차례(366-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형.
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
@@ -60,8 +61,31 @@ export function createRouteEditChrome(
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
노드 끌기 = 옮기기노드 클릭 = R 라벨
선 두 번 클릭 = 노드 추가노드 오른쪽 클릭 = 삭제
@@ -97,12 +102,6 @@ export function createRouteEditChrome(
가운데(휠) 버튼 끌기 = 지도 이동휠 = 확대
-
-
-
-
@@ -142,7 +141,7 @@ export function createRouteEditChrome(
// 도움말 = 기본 접힘 · [?] 한 번에 펼침(345-5 덧 3).
const hint = overlay.querySelector
(".b05-routeedit__hint")!;
- const hintToggle = hint.querySelector('[data-act="hint-toggle"]')!;
+ const hintToggle = overlay.querySelector('[data-act="hint-toggle"]')!;
const setHint = (open: boolean): void => {
hintOpen = open;
hint.classList.toggle("is-open", open);
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts
index e8e52b173..146717a3c 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts
@@ -1,10 +1,11 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_ContourLock.ts
- * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추.
+ * 평면 등고선 자물쇠(353-2) — 가닥을 고른 때만 누른 자리 옆에 뜨는 작은 단추(366-1 · 지나기만 = 안 뜸).
*
- * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂.
+ * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 잠근 높이 가닥을 다시
+ * 누르면 풀기 단추 · 다음 클릭 · 휠 · 떠남(0.7 s)에 숨김.
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
- * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`.
+ * 규칙은 `logic/B05_Profile_ContourLock` · 누름 판정은 집기와 같은 `hitPreparedLayer`.
* ========================================================================== */
import { pickPx } from "../A00_Common/touch_bridge";
@@ -22,8 +23,10 @@ import {
import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css";
/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */
-const HOVER_HIT_PX = 6;
-/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
+const HIT_PX = 6;
+/** 누름 ~ 뗌 사이 이만큼(px) 넘게 움직이면 끌기 — 고르기 아님. */
+const MOVE_PX = 4;
+/** 커서가 지도를 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
const HIDE_DELAY_MS = 700;
/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */
const OFFSET_PX = 12;
@@ -33,12 +36,14 @@ const LOCK_SVG = ``;
-/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
+/** 누름 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
export interface ContourLockScene {
layer: PreparedLayer;
view: ViewState;
- /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */
+ /** 지금 화면 등고선 간격 — 그리지 않은 줄은 안 잡힘. */
stepM: number;
+ /** 고른 가닥 번호(없으면 -1) — 안 잠김이면 이 가닥을 누를 때만 뜸. */
+ picked: number;
rotation: { unrotate: (px: number, py: number) => [number, number] };
}
@@ -68,8 +73,8 @@ export function createContourLock(
/** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */
let target = -1;
let hideTimer = 0;
- let frame = 0;
- let last: PointerEvent | null = null;
+ let downX = 0;
+ let downY = 0;
const levelOf = (index: number): number | null =>
index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null;
@@ -96,17 +101,24 @@ export function createContourLock(
hideTimer = 0;
};
- const hover = (event: PointerEvent): void => {
+ const hide = (): void => {
+ keep();
+ button.hidden = true;
+ };
+
+ /** 누름을 뗀 자리 — 고른 가닥(잠김이면 잠근 높이 가닥)일 때만 그 옆에 띄움. */
+ const showAt = (event: PointerEvent): void => {
const now = scene();
- // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄
- if (!now || event.buttons !== 0) return hideLater();
+ // 끌기 · 화면 이동 · 재기(Shift)는 고르기가 아님
+ const moved = Math.hypot(event.clientX - downX, event.clientY - downY) > MOVE_PX;
+ if (!now || event.button !== 0 || event.shiftKey || moved) return;
const rect = canvas.getBoundingClientRect();
const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
- const hitPx = pickPx(HOVER_HIT_PX);
+ const hitPx = pickPx(HIT_PX);
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
- const state = lockButtonState(levelOf(index), lockedLevel);
- if (!state.visible) return hideLater();
- keep();
+ if (!lockButtonState(levelOf(index), lockedLevel, index >= 0 && index === now.picked).visible) {
+ return;
+ }
target = index;
const origin = wrap.getBoundingClientRect();
button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`;
@@ -115,17 +127,14 @@ export function createContourLock(
paint();
};
- // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥.
- canvas.addEventListener("pointermove", (event) => {
- last = event;
- if (frame) return;
- frame = requestAnimationFrame(() => {
- frame = 0;
- if (last) hover(last);
- });
+ // 다음 클릭 · 휠에 숨김 — 집기(`_Pointer`)보다 먼저 달려 있어 고른 뒤 pointerup 이 다시 띄움.
+ canvas.addEventListener("pointerdown", (event) => {
+ downX = event.clientX;
+ downY = event.clientY;
+ hide();
});
- // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음.
- canvas.addEventListener("pointerup", hover);
+ canvas.addEventListener("pointerup", showAt);
+ canvas.addEventListener("wheel", hide, { passive: true });
canvas.addEventListener("pointerleave", hideLater);
button.addEventListener("pointerenter", keep);
button.addEventListener("pointerleave", hideLater);
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
index 1e6c5420b..2698017ad 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
@@ -15,9 +15,9 @@
--b05-side-open: 460px;
}
-/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */
+/* 도구줄 — 3D 도구줄 규격 그대로 · 회전 단추도 이 줄(366-2) · 넘치면 줄바꿈. */
.b05-routeedit__tools {
- right: calc(var(--spacing-12) + 112px); /* 오른쪽 위 회전 단추 자리 */
+ right: var(--spacing-12);
flex-wrap: wrap;
pointer-events: none; /* 빈 자리는 지도 조작을 통과 */
}
diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts
index 85fffd545..62242854a 100644
--- a/B05_Profile/logic/B05_Profile_ContourLock.ts
+++ b/B05_Profile/logic/B05_Profile_ContourLock.ts
@@ -21,13 +21,15 @@ export function featureOfLevel(levels: ContourLevels, level: number | null): num
return levels.findIndex((value) => value === level);
}
-/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */
+/** 누른 가닥 높이 · 잠근 높이 · 고른 가닥인지 → 단추(366-1) — 안 잠김 = 높이 있는 고른 가닥을
+ * 누를 때만 · 잠김 = 잠근 높이의 아무 가닥을 누를 때(풀기). 지나가기만 하면 안 뜸. */
export function lockButtonState(
- hoverLevel: number | null,
+ hitLevel: number | null,
lockedLevel: number | null,
+ selected: boolean,
): LockButtonState {
- if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true };
- return { visible: hoverLevel !== null, pressed: false };
+ if (lockedLevel !== null) return { visible: hitLevel === lockedLevel, pressed: true };
+ return { visible: hitLevel !== null && selected, pressed: false };
}
/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */
diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
new file mode 100644
index 000000000..c2e1da9ea
--- /dev/null
+++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
@@ -0,0 +1,171 @@
+/* =============================================================================
+ * B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
+ * 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
+ *
+ * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
+ * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서
+ * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
+ * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
+ * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
+ * 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
+ * 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
+ * ========================================================================== */
+
+import {
+ buildEditedPolyline,
+ type EditedCurve,
+ type EditedPolyline,
+ type Vertex,
+} from "./B05_Profile_UI_RouteEdit_Curve";
+import {
+ applyArcLocks,
+ applyCurveLimits,
+ curveShortfalls,
+ shortfallCrossed,
+ type NodeColumns,
+} from "./B05_Profile_UI_RouteEdit_Edits";
+import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame";
+
+/** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */
+export const MC_TOL_M = 0.05;
+
+/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
+export interface FitLimits {
+ minRadiusM: number;
+ limitRadiusM: number;
+ limitArcM: number;
+ intervalM: number;
+}
+
+type Columns = Pick;
+
+/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
+function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline {
+ const radius = [...cols.curveRadius];
+ applyArcLocks(planned, cols.curveLock, cols.curveArc, radius);
+ applyCurveLimits(planned, cols.curveOn, radius, limits.limitRadiusM, limits.limitArcM);
+ return buildEditedPolyline(planned, cols.curveOn, radius, limits.minRadiusM);
+}
+
+/** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */
+function mcOf(built: EditedPolyline, k: number): number | null {
+ const curve = built.curves.find((item) => item.node_first === k);
+ const span = curve ? curveSpans(built.vertices, [curve])[0] : undefined;
+ return span ? (span[0] + span[1]) / 2 : null;
+}
+
+/** 측점 밖 곡선 수 — MC 가 일반측점(No.1 이상) 위가 아닌 곡선 · 한 측점에 둘이면 하나만 위. */
+export function offStationCount(
+ line: ReadonlyArray,
+ curves: ReadonlyArray,
+ intervalM: number,
+): number {
+ if (!(intervalM > 0)) return 0;
+ const spans = curveSpans(line, curves);
+ const hits = new Set();
+ for (const [start, end] of spans) {
+ const mc = (start + end) / 2;
+ const n = Math.round(mc / intervalM);
+ if (n >= 1 && Math.abs(mc - n * intervalM) <= MC_TOL_M) hits.add(n);
+ }
+ return spans.length - hits.size;
+}
+
+/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
+export function fitStart(
+ before: (Columns & Pick) | null | undefined,
+ now: Columns & Pick,
+): number {
+ if (!before) return 1;
+ const keys = ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"] as const;
+ const at = now.planned.findIndex(
+ (vertex, i) =>
+ vertex[0] !== before.planned[i]?.[0] ||
+ vertex[1] !== before.planned[i]?.[1] ||
+ keys.some((key) => now[key][i] !== before[key][i]),
+ );
+ const changed =
+ at < 0 && now.planned.length !== before.planned.length ? now.planned.length - 1 : at;
+ return Math.max(1, changed - 1);
+}
+
+const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
+
+/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
+function solve(
+ cols: Columns,
+ limits: FitLimits,
+ planned: Vertex[],
+ k: number,
+ target: number,
+ mc: number,
+): { planned: Vertex[]; built: EditedPolyline; mc: number } | null {
+ const base = planned[k];
+ const before = planned[k - 1];
+ const length = Math.hypot(base[0] - before[0], base[1] - before[1]);
+ if (!(length > 1e-9)) return null;
+ const ux = (base[0] - before[0]) / length;
+ const uy = (base[1] - before[1]) / length;
+ const reach = limits.intervalM / 2;
+ const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
+ const at = (d: number) => {
+ const next = planned.slice();
+ next[k] = [round4(base[0] + ux * d), round4(base[1] + uy * d)];
+ const built = build(cols, next, limits);
+ const found = mcOf(built, k);
+ return found === null ? null : { planned: next, built, mc: found };
+ };
+ let d0 = 0;
+ let f0 = mc - target;
+ let d1 = clamp(-f0);
+ for (let step = 0; step < 10; step += 1) {
+ const tried = at(d1);
+ if (!tried || d1 === d0) return null;
+ const f1 = tried.mc - target;
+ if (Math.abs(f1) <= MC_TOL_M / 5) return tried;
+ const slope = (f1 - f0) / (d1 - d0);
+ if (Math.abs(slope) < 0.2) return null;
+ d0 = d1;
+ f0 = f1;
+ d1 = clamp(d1 - f1 / slope);
+ }
+ return null;
+}
+
+/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */
+export function fitCurveStations(
+ cols: Columns & Pick,
+ limits: FitLimits,
+ from: number,
+): Vertex[] {
+ let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
+ const interval = limits.intervalM;
+ if (!(interval > 0) || planned.length < 3) return planned;
+ let built = build(cols, planned, limits);
+ const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
+ let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
+ let previous = -1; // 앞 곡선 꺾임점
+ for (let k = 1; k < planned.length - 1; k += 1) {
+ const mc = mcOf(built, k);
+ if (mc === null) continue;
+ const near = Math.max(lastStation + 1, Math.round(mc / interval));
+ const onStation = Math.abs(mc - near * interval) <= MC_TOL_M;
+ const fitted =
+ onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
+ ? null
+ : solve(cols, limits, planned, k, near * interval, mc);
+ const keep =
+ fitted &&
+ !shortfallCrossed(
+ floor,
+ curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
+ ) &&
+ (previous < 0 ||
+ Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
+ MC_TOL_M / 5);
+ if (fitted && keep) ({ planned, built } = fitted);
+ if (onStation || keep) lastStation = near;
+ previous = k;
+ }
+ return planned;
+}
diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts
index 1106aa610..30fdf4ddd 100644
--- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts
+++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts
@@ -168,6 +168,8 @@ export interface CurveSummaryInput {
minRadiusM: number;
/** 아직 한 번도 안 그렸나(막 열었을 때). */
fresh: boolean;
+ /** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
+ offStation?: number;
}
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
@@ -192,6 +194,7 @@ export function curveSummary(input: CurveSummaryInput): string {
return (
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
+ `${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
`${edits ? ` · ${edits}` : ""}`
);
}
diff --git a/resources/tester/helper_366_3_station_fit.cjs b/resources/tester/helper_366_3_station_fit.cjs
new file mode 100644
index 000000000..37e872e41
--- /dev/null
+++ b/resources/tester/helper_366_3_station_fit.cjs
@@ -0,0 +1,114 @@
+/* 366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts` 를
+ * Node 로 돌려 JSON 한 줄을 냄(`test_366_3_station_fit.py` 가 판정).
+ * `.cjs` = 루트 package.json 이 "type": "module" 이라서.
+ */
+const fs = require("fs");
+const path = require("path");
+const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
+
+const ROOT = path.join(__dirname, "..", "..");
+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) =>
+ request.startsWith(".")
+ ? loadTs(path.join(path.dirname(full), `${request}.ts`))
+ : require(request);
+ new Function("exports", "module", "require", js)(box.exports, box, localRequire);
+ cache.set(full, box.exports);
+ return box.exports;
+}
+
+const LOGIC = path.join(ROOT, "B05_Profile", "logic");
+const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
+const curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts"));
+const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
+
+const INTERVAL = 20;
+const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
+// 지그재그 노선 — 꺾임 다섯(교각 다양) · 직선 넉넉
+const ROUTE = [
+ [0, 0],
+ [93, 7],
+ [171, 61],
+ [262, 48],
+ [337, 112],
+ [431, 101],
+ [503, 160],
+];
+
+function columns(planned, lock = []) {
+ const n = planned.length;
+ return {
+ planned,
+ curveOn: planned.map(() => true),
+ curveRadius: planned.map(() => null),
+ curveLock: planned.map(() => null),
+ curveArc: planned.map(() => null),
+ apexLock: planned.map((_, i) => i === 0 || i === n - 1 || lock.includes(i)),
+ };
+}
+
+function measure(planned) {
+ const built = curve.buildEditedPolyline(
+ planned,
+ planned.map(() => true),
+ planned.map(() => null),
+ LIMITS.minRadiusM,
+ );
+ const spans = frame.curveSpans(built.vertices, built.curves);
+ const mcs = spans.map(([a, b]) => (a + b) / 2);
+ const total = frame.stationsAlong(built.vertices, INTERVAL, false);
+ const length = built.vertices.reduce(
+ (sum, v, i) =>
+ i ? sum + Math.hypot(v[0] - built.vertices[i - 1][0], v[1] - built.vertices[i - 1][1]) : 0,
+ 0,
+ );
+ return {
+ curves: built.curves.length,
+ mcs,
+ residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
+ innerStations: total.filter((s) => s > 0 && s < length).length,
+ off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
+ };
+}
+
+const moved = (a, b) => a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1]));
+
+const before = measure(ROUTE);
+const all = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
+const locked = fit.fitCurveStations(columns(ROUTE, [3]), LIMITS, 1);
+const fromLater = fit.fitCurveStations(columns(ROUTE), LIMITS, 4);
+const everyLocked = fit.fitCurveStations(columns(ROUTE, [1, 2, 3, 4, 5]), LIMITS, 1);
+
+console.log(
+ JSON.stringify({
+ before,
+ all: { ...measure(all), moved: moved(ROUTE, all) },
+ locked: { ...measure(locked), moved: moved(ROUTE, locked) },
+ fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
+ everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
+ again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
+ noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
+ start: {
+ moved4: fit.fitStart(
+ columns(ROUTE),
+ columns(ROUTE.map((v, i) => (i === 4 ? [v[0] + 1, v[1]] : v))),
+ ),
+ radius2: fit.fitStart(columns(ROUTE), {
+ ...columns(ROUTE),
+ curveRadius: ROUTE.map((_, i) => (i === 2 ? 40 : null)),
+ }),
+ dropLast: fit.fitStart(columns(ROUTE), columns(ROUTE.filter((_, i) => i !== 5))),
+ same: fit.fitStart(columns(ROUTE), columns(ROUTE)),
+ none: fit.fitStart(null, columns(ROUTE)),
+ },
+ }),
+);
diff --git a/resources/tester/test_345_4_recommend_panel.py b/resources/tester/test_345_4_recommend_panel.py
index 341579d3a..20845504b 100644
--- a/resources/tester/test_345_4_recommend_panel.py
+++ b/resources/tester/test_345_4_recommend_panel.py
@@ -46,9 +46,9 @@ def test_적용은_본문_평면에_되돌리기_한_칸():
assert "apply: (snapshot, message) => restoreSnapshot(snapshot, message, true)," in main
restore = main[main.index("function restoreSnapshot(") :]
restore = restore[: restore.index("\n }\n")]
- assert "applyEdit(message, record);" in restore
+ assert "applyEdit(message, record, false);" in restore # 366-3 — 추천 적용은 측점 맞춤 안 함
apply_edit = main[main.index("function applyEdit(") :]
- assert "if (record) history?.commit(snapshotNow());" in apply_edit[:600]
+ assert "if (record) history?.commit(snapshotNow());" in apply_edit[:900]
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
on_apply = initial[initial.index("onApply: (picked) => {") :]
assert on_apply[:500].count("params.apply(") == 1
diff --git a/resources/tester/test_345_5b_plan_visible_area.py b/resources/tester/test_345_5b_plan_visible_area.py
index 0c81d8523..5ebf869b8 100644
--- a/resources/tester/test_345_5b_plan_visible_area.py
+++ b/resources/tester/test_345_5b_plan_visible_area.py
@@ -125,16 +125,14 @@ def test_판은_덮이기만_따라_안_옮김() -> None:
assert ".b05-route-profile.is-collapsed {\n height: 0;" in style
-def test_처음_열기와_맞춤_단추_같은_셈() -> None:
+def test_처음_열기_맞춤_그대로_맞춤_단추_없음() -> None:
+ """366-2 — [맞춤] 단추는 지움 · 처음 열 때 등고 영역 맞춤(fitView)은 그대로."""
view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8")
assert "return { view: fitView(meta, view, nodes, contours), rotationRad: 0 };" in view
assert "unionArea([route, ...(contours?.features ?? [])])" in view
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
- assert 'data-act="fit"' in chrome and ">맞춤" in chrome
+ assert 'data-act="fit"' not in chrome and ">맞춤" not in chrome
editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "openingView(projectId, meta, view, planned, contours?.layer ?? null)" in editor
- at = editor.index("""overlay.querySelector('[data-act="fit"]')!""")
- handler = editor[at : at + 260]
- assert "view = fitView(meta, view, planned, contours?.layer ?? null);" in handler
- assert "rotation.set(0);" in handler
+ assert 'data-act="fit"' not in editor and "fitView" not in editor
assert len(editor.splitlines()) <= 700
diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py
index 9dc0a4c58..38ba30392 100644
--- a/resources/tester/test_353_2_contour_lock.py
+++ b/resources/tester/test_353_2_contour_lock.py
@@ -26,10 +26,11 @@ new Function("exports", "module", js)(m.exports, m);
const L = m.exports;
const levels = [101, 102, 105, 102, null];
console.log(JSON.stringify({
- hoverFree: L.lockButtonState(102, null),
- hoverNone: L.lockButtonState(null, null),
- hoverLocked: L.lockButtonState(102, 102),
- hoverOther: L.lockButtonState(105, 102),
+ hitPicked: L.lockButtonState(102, null, true),
+ hitOnly: L.lockButtonState(102, null, false),
+ hitNone: L.lockButtonState(null, null, true),
+ hitLocked: L.lockButtonState(102, 102, false),
+ hitOther: L.lockButtonState(105, 102, true),
lock: L.toggleLock(null, 102),
unlock: L.toggleLock(102, 105),
pickFree: L.canPickContour(null),
@@ -50,14 +51,16 @@ def got() -> dict:
return json.loads(run.stdout)
-def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None:
- assert got["hoverFree"] == {"visible": True, "pressed": False}
- assert got["hoverNone"]["visible"] is False
+def test_안_잠김_단추는_고른_가닥을_누를_때만(got) -> None:
+ """366-1 — 지나기만 · 고르지 않은 가닥 = 안 뜸."""
+ assert got["hitPicked"] == {"visible": True, "pressed": False}
+ assert got["hitOnly"]["visible"] is False
+ assert got["hitNone"]["visible"] is False
-def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None:
- assert got["hoverLocked"] == {"visible": True, "pressed": True}
- assert got["hoverOther"]["visible"] is False
+def test_잠김_단추는_잠근_높이_아무_가닥을_누를_때_풀기(got) -> None:
+ assert got["hitLocked"] == {"visible": True, "pressed": True}
+ assert got["hitOther"]["visible"] is False
def test_누르면_잠금_다시_누르면_풂(got) -> None:
@@ -79,3 +82,9 @@ def test_편집기_이음() -> None:
lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8")
assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock
assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만
+ # 366-1 — 지나기(pointermove)로 안 띄움 · 뗀 자리 · 다음 클릭 · 휠 · 떠남에 숨김
+ assert '"pointermove"' not in lock
+ assert 'canvas.addEventListener("pointerup", showAt);' in lock
+ assert 'canvas.addEventListener("wheel", hide' in lock
+ assert 'canvas.addEventListener("pointerleave", hideLater);' in lock
+ assert "picked: pickedContour" in edit
diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py
new file mode 100644
index 000000000..d816fcbef
--- /dev/null
+++ b/resources/tester/test_366_3_station_fit.py
@@ -0,0 +1,85 @@
+"""366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts`
+를 Node(`helper_366_3_station_fit.cjs`)로 돌려 판정 + 편집기 이음(놓을 때 · applyEdit · 켤 때 ·
+되돌리기 · 추천 적용은 안 맞춤 · 상태줄 「측점 밖 곡선 N개」).
+
+ ./venv/Scripts/python.exe -m pytest resources/tester/test_366_3_station_fit.py -q
+"""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+B05 = ROOT / "B05_Profile"
+HELPER = Path(__file__).with_name("helper_366_3_station_fit.cjs")
+TOL = 0.05
+
+
+@pytest.fixture(scope="module")
+def got() -> dict:
+ run = subprocess.run( # noqa: S603 — 고정 실행 파일
+ ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
+ )
+ assert run.returncode == 0, run.stderr
+ return json.loads(run.stdout)
+
+
+def test_맞추기_전_곡선은_측점_밖(got) -> None:
+ assert got["before"]["off"] == got["before"]["curves"] == 5
+
+
+def test_MC_는_일반측점_위(got) -> None:
+ fit = got["all"]
+ assert fit["curves"] == 5 and fit["off"] == 0
+ assert max(fit["residues"]) <= TOL
+ stations = [round(mc / 20) for mc in fit["mcs"]]
+ assert all(n >= 1 for n in stations) and len(set(stations)) == len(stations)
+
+
+def test_곡선_수는_측점_수_이하(got) -> None:
+ assert got["all"]["curves"] <= got["all"]["innerStations"]
+
+
+def test_시점_종점_고정_IP_그대로_밀림은_간격_절반_안(got) -> None:
+ for case in ("all", "locked", "fromLater"):
+ moved = got[case]["moved"]
+ assert moved[0] == 0 and moved[-1] == 0
+ assert max(moved) <= 10 + 1e-6
+ assert got["locked"]["moved"][3] == 0
+
+
+def test_못_놓은_곡선은_그대로_개수(got) -> None:
+ assert got["locked"]["off"] == 1 # 고정 IP 곡선 하나만 밖 · 나머지는 맞춤
+ assert got["everyLocked"]["off"] == 5 and max(got["everyLocked"]["moved"]) == 0
+
+
+def test_고친_자리부터만(got) -> None:
+ moved = got["fromLater"]["moved"]
+ assert moved[1:4] == [0, 0, 0] and moved[4] > 0
+ assert got["fromLater"]["residues"][3:] == pytest.approx([0, 0], abs=TOL)
+
+
+def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -> None:
+ assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
+
+
+def test_시작_자리는_고친_꺾임점_앞(got) -> None:
+ start = got["start"]
+ assert start["moved4"] == 3 and start["radius2"] == 1
+ assert start["dropLast"] == 4 # 지운 마지막 꺾임점 → 새 끝 앞 곡선부터
+ assert start["same"] == 1 and start["none"] == 1
+
+
+def test_편집기_이음() -> None:
+ edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
+ assert "fitCurveStations(" in edit
+ # 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤
+ at = edit.index("function restoreSnapshot(")
+ assert "applyEdit(message, record, false);" in edit[at : at + 700]
+ assert len(edit.splitlines()) <= 700
+ edits = (B05 / "logic" / "B05_Profile_UI_RouteEdit_Edits.ts").read_text("utf-8")
+ assert "측점 밖 곡선 " in edits
+ chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8")
+ assert "곡선 가운데(MC)를 가까운 일반측점에" in chrome
diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py
index 7cce14368..43647248b 100644
--- a/resources/tester/test_b05_routeedit_initial.py
+++ b/resources/tester/test_b05_routeedit_initial.py
@@ -208,11 +208,34 @@ def test_노선_추천_다듬기_23_3():
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
- # 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로
- tools = chrome.index('data-slot="tools"')
- divider = chrome.index("b05-route__view-separator", tools) # 352-1 — 3D 도구줄 구분선 그대로
- assert tools < divider < chrome.index('data-act="contour-minor"')
- assert chrome.count("b05-route__view-separator") == 2
+ # 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형(맨 오른쪽)
+ bar = chrome[
+ chrome.index("b05-routeedit__tools") : chrome.index('')
+ ]
+ order = [
+ 'data-act="hint-toggle"',
+ "|",
+ 'data-act="undo"',
+ 'data-act="redo"',
+ "|",
+ 'data-slot="tools"',
+ "|",
+ 'data-act="rotate-ccw"',
+ 'data-act="rotate-cw"',
+ 'data-act="contour-minor"',
+ 'data-act="footprint"',
+ 'data-act="measure"',
+ "|",
+ 'data-act="station-snap"',
+ 'class="b05-routeedit__terrain"',
+ ]
+ at = 0
+ for mark in order:
+ key = "b05-route__view-separator" if mark == "|" else mark
+ at = bar.index(key, at) + 1
+ assert bar.count("b05-route__view-separator") == 4
+ assert chrome.count("b05-route__view-separator") == 4
+ assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared