diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 85faf4c1d..990682ee0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -378,7 +378,9 @@ export async function mountRouteEditor( routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length); const contourHint = (): string => contourHintText(contours, pickedContour); - /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ + const offStation = (): number => + stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap(), stationIntervalM) : 0; + /** 상태줄 꼬리 · 측점 밖 곡선 수(441 막힘 · 432) — 셈은 `_Edits` · `StationFit` 몫. */ const curveHint = (): string => edits.curveSummary({ nodeCount: planned.length, @@ -390,7 +392,7 @@ export async function mountRouteEditor( violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, - offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0, + offStation: offStation(), flags: edits.routeFlags(planned, curveOn, curveInfo), // 겹친 노드 · 못 그린 곡선(430) }); @@ -440,6 +442,8 @@ export async function mountRouteEditor( fitLimits(), ]); markEdited(); + if (stationSnap && fitting && before && fit.pushedOff(before, snapshotNow(), fitLimits())) + return restoreSnapshot(before, "곡선 가운데를 측점에 못 맞춰 되돌렸습니다.", false); // 432 writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); status.textContent = `${routeHead()} — ${message} ${curveHint()}`; @@ -549,7 +553,7 @@ export async function mountRouteEditor( if (base) markEdited(); return base !== undefined; }, - offStation: () => (stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0), + offStation, }); bindNodeSplice({ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index a2ed117c6..34aadbe63 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -165,7 +165,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { function fitTail(before: Vertex[], picked: number, hand: boolean): string { const { curveInfo, planned, line, snap } = params.state(); if (!snap) return ""; - const off = offStationMcs(line, curveInfo, snap.stepM); + const off = offStationMcs(line, curveInfo, snap.stepM, snap.stationM); return fitNote(before, planned, picked, off, snap.stationM, snap.stepM, hand); } diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 038d95855..66fd406ba 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -11,6 +11,8 @@ * · 유지(280-4) — 고치기 전 노선(`before`)에서 MC 가 측점 · 10 m 배수(No.N+10)였던 곡선은 그 IP · 곡선 칸이 * 그대로면 옛 꼴(측점 간격 배수 + 0 · 10)로만 맞춤(밀 거리 = 측점 간격) · 손으로 고친 곡선만 고른 간격. * MC 가 고른 간격 배수 · 10 m 배수면 「위」(No.N+10 특별측점 · 저장 칸 없음 = 노선 모양으로 기억). + * · 일반측점(측점 간격 배수) 「위」 폭 = 측점 목록 「(MC)」 규칙과 같음(`onStation` · 정수 m = ±0.5 m · 432) — 폭 안이면 정확히 맞춰 보고 + * 못 맞춰도 「위」로 둠(손으로 측점에 둔 곡선) · 그 곡선을 폭 밖으로 미는 걸음은 부르는 쪽이 막음(`pushedOff`). * · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로 * 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값. * 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로. @@ -24,7 +26,7 @@ import { shortfallCrossed, type NodeColumns, } from "./B05_Profile_UI_RouteEdit_Edits"; -import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame"; +import { curveSpans, mcScale } from "./B05_Profile_UI_RouteEdit_Frame"; /** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */ export const MC_TOL_M = 0.05; @@ -32,6 +34,10 @@ export const MC_TOL_M = 0.05; /** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */ export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20); +/** MC 가 측점 `c` 위 — 측점 목록 「(MC)」 규칙(`curveStations` 반올림)과 같은 폭(432). */ +const onStation = (mc: number, c: number, intervalM: number): boolean => + Math.abs(Math.floor(mc * mcScale(intervalM) + 0.5) / mcScale(intervalM) - c) < 0.005; + /** 특별측점 간격(m) — MC 가 일반측점 사이 10 m 배수(No.N+10)면 측점 위로 봄(280-4). */ export const SPECIAL_STATION_M = 10; @@ -66,16 +72,18 @@ export function offStationCount( line: ReadonlyArray, curves: ReadonlyArray, intervalM: number, + stationM = intervalM, ): number { - return offStationMcs(line, curves, intervalM).length; + return offStationMcs(line, curves, intervalM, stationM).length; } /** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정 · - * 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」. */ + * 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」 · 일반측점(`stationM` 배수)은 「(MC)」 폭(432). */ export function offStationMcs( line: ReadonlyArray, curves: ReadonlyArray, intervalM: number, + stationM = intervalM, ): number[] { if (!(intervalM > 0)) return []; const tol = mcTolM(intervalM); @@ -83,9 +91,13 @@ export function offStationMcs( const off: number[] = []; for (const [start, end] of curveSpans(line, curves)) { const mc = (start + end) / 2; - const at = [intervalM, SPECIAL_STATION_M] - .map((step) => Math.round(mc / step) * step) - .find((c) => c > tol && Math.abs(mc - c) <= tol && !hits.has(Math.round(c * 1000))); + const free = (c: number): boolean => !hits.has(Math.round(c * 1000)); + const wide = Math.round(mc / stationM) * stationM; + const at = + [intervalM, SPECIAL_STATION_M] + .map((step) => Math.round(mc / step) * step) + .find((c) => c > tol && Math.abs(mc - c) <= tol && free(c)) ?? + (wide > 0 && onStation(mc, wide, intervalM) && free(wide) ? wide : undefined); if (at !== undefined) hits.add(Math.round(at * 1000)); else off.push(mc); } @@ -222,6 +234,9 @@ export function fitCurveStations( ]; const onAt = (mc: number, last: number, grid: Grid) => [grid, ...loose].flatMap((g) => near(mc, last, g)).find((c) => Math.abs(mc - c) <= tol); + /** 폭 안 측점(432) — 정확히 맞출 자리 · 못 맞춰도 「위」. */ + const wideAt = (mc: number, last: number) => + near(mc, last, { step: stationM, offset: 0, reach: 0 }).find((c) => onStation(mc, c, interval)); /** 곡선 `k` 를 측점 `c` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ const attempt = (at: Fit, k: number, mc: number, c: number, previous: number): Fit | null => { const fitted = solve(cols, limits, at.planned, k, c, mc, gridOf(k).reach); @@ -244,7 +259,7 @@ export function fitCurveStations( for (let j = k + 1; j < at.planned.length - 1; j += 1) { const mc = mcOf(at.built, j); if (mc === null) continue; - if (onAt(mc, last, gridOf(j)) !== undefined) return true; + if (onAt(mc, last, gridOf(j)) ?? wideAt(mc, last)) return true; const grid = gridOf(j); return ( !cols.apexLock[j] && near(mc, last, grid).some((c) => attempt(at, j, mc, c, k) !== null) @@ -258,10 +273,11 @@ export function fitCurveStations( const mc = mcOf(built, k); if (mc === null) continue; const on = onAt(mc, lastStation, gridOf(k)); + const wide = wideAt(mc, lastStation); if (on !== undefined) lastStation = on; else if (k >= from && !cols.apexLock[k]) { let pick: { fit: Fit; c: number } | null = null; - for (const c of near(mc, lastStation, gridOf(k))) { + for (const c of wide !== undefined ? [wide] : near(mc, lastStation, gridOf(k))) { const fit = attempt({ planned, built }, k, mc, c, previous); if (!fit) continue; pick ??= { fit, c }; @@ -271,7 +287,8 @@ export function fitCurveStations( } } if (pick) (({ planned, built } = pick.fit), (lastStation = pick.c)); - } + else if (wide !== undefined) lastStation = wide; + } else if (wide !== undefined) lastStation = wide; previous = k; } return planned; @@ -286,6 +303,26 @@ export function fitAfter( return fitCurveStations(now, limits, fitStart(before, now), before); } +/** 편집 한 걸음이 측점 위 곡선을 밖으로 밀었나(432 · 끌기 · 방향키 막힘과 같은 개수 견줌 · R · 곡선 길이 입력) — + * 꺾임점 · 곡선 켬이 같은 걸음만(교점 넣기 · 지우기 · 곡선 켬은 새 곡선이라 안 견줌). */ +export function pushedOff( + before: Columns & Pick, + now: Columns & Pick, + limits: FitLimits, +): boolean { + if ( + !(limits.intervalM > 0) || + before.planned.length !== now.planned.length || + before.curveOn.some((on, i) => on !== now.curveOn[i]) + ) + return false; + const off = (cols: Columns & Pick): number => { + const built = build(cols, cols.planned, limits); + return offStationCount(built.vertices, built.curves, limits.intervalM, limits.stationM); + }; + return off(now) > off(before); +} + /** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고 * `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작) · `before` = 누르기 전 노선(없으면 `base` + 지금 칸 · 280-4 유지). */ export function fitDrag( diff --git a/resources/tester/helper_366_3_station_fit.cjs b/resources/tester/helper_366_3_station_fit.cjs index 4b704f503..73feb5e0c 100644 --- a/resources/tester/helper_366_3_station_fit.cjs +++ b/resources/tester/helper_366_3_station_fit.cjs @@ -66,7 +66,7 @@ function measure(planned, interval = INTERVAL) { 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), + off: fit.offStationCount(built.vertices, built.curves, interval, INTERVAL), }; } @@ -89,7 +89,11 @@ console.log( // 390-2 스냅 간격 — 0.1 · 1 · 10 m 배수 · 측점 밖 개수도 같은 간격 steps: Object.fromEntries( [0.1, 1, 10].map((step) => { - const fitted = fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: step }, 1); + const fitted = fit.fitCurveStations( + columns(ROUTE), + { ...LIMITS, intervalM: step, stationM: INTERVAL }, + 1, + ); const offBefore = measure(ROUTE, step).off; return [step, { offBefore, ...measure(fitted, step), moved: moved(ROUTE, fitted) }]; }), diff --git a/resources/tester/helper_432_mc_on_station.cjs b/resources/tester/helper_432_mc_on_station.cjs new file mode 100644 index 000000000..09e00e396 --- /dev/null +++ b/resources/tester/helper_432_mc_on_station.cjs @@ -0,0 +1,140 @@ +/* 432 MC 를 일반측점 위에 두면 그 뒤 스냅대로 — 측점 No.(20 m) 노선 곡선 하나를 손으로 측점 + 0.3 m 에 두고 + * 「위」 판정 폭 · 맞춤 · 밀어내는 걸음 막힘(`pushedOff`)을 JSON 한 줄로(`test_432_mc_on_station.py` 가 판정). + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request), +}); + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts")); +const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); +const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts")); + +const STATION = 20; +const LIMITS = { + minRadiusM: 30, + limitRadiusM: 12, + limitArcM: 0, + intervalM: STATION, + stationM: STATION, +}; +const ROUTE = [ + [0, 0], + [100, 0], + [180, 60], + [280, 60], + [360, 0], + [460, 0], +]; +const K = 2; // 손으로 측점에 둘 곡선의 꺾임점 + +const columns = (p, lock = []) => ({ + planned: p.map(([x, y]) => [x, y]), + curveOn: p.map(() => true), + curveRadius: p.map(() => null), + curveLock: p.map(() => null), + curveArc: p.map(() => null), + apexLock: p.map((_, i) => i === 0 || i === p.length - 1 || lock.includes(i)), +}); +const built = (cols, limits = LIMITS) => + edits.rebuildLine({ ...cols, curveRadius: [...cols.curveRadius] }, limits); +const mcs = (cols, limits) => { + const b = built(cols, limits); + return frame.curveSpans(b.vertices, b.curves).map(([s, e]) => (s + e) / 2); +}; +const off = (cols, limits = LIMITS) => { + const b = built(cols, limits); + return fit.offStationCount(b.vertices, b.curves, limits.intervalM, limits.stationM); +}; +/** IP `k` 를 들어오는 직선 따라 밀어 곡선 `k` MC = target(손으로 끈 자리) — 할선 몇 번. */ +function place(cols, k, target) { + const base = cols.planned[k]; + const prev = cols.planned[k - 1]; + const len = Math.hypot(base[0] - prev[0], base[1] - prev[1]); + const ux = (base[0] - prev[0]) / len; + const uy = (base[1] - prev[1]) / len; + const at = (d) => { + const c = { + ...cols, + planned: cols.planned.map((v, i) => (i === k ? [base[0] + ux * d, base[1] + uy * d] : v)), + }; + return { c, mc: mcs(c, LIMITS)[k - 1] }; + }; + let d = 0; + let got = at(0); + for (let i = 0; i < 8; i += 1) { + d += target - got.mc; + got = at(d); + } + return got; +} + +const fitted = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1)); +const fittedMcs = mcs(fitted, LIMITS); +const station = Math.round(fittedMcs[K - 1] / STATION) * STATION; +// 손으로 측점 + 0.3 m(폭 안) · + 0.7 m(폭 밖) — 맞춤이 못 옮기게 그 IP 고정(못 맞춰도 「위」) +const inside = place(fitted, K, station + 0.3); +const outside = place(fitted, K, station + 0.7); +const lockIn = { ...inside.c, apexLock: inside.c.apexLock.map((v, i) => v || i === K) }; +const lockInFit = { ...lockIn, planned: fit.fitCurveStations(lockIn, LIMITS, 1) }; +// 고정 없이 폭 안 = 같은 측점에 정확히 맞춤 +const freeFit = { ...inside.c, planned: fit.fitCurveStations(inside.c, LIMITS, 1) }; +// 그 뒤 걸음 — 앞 교점(K-1)을 끌어 그 곡선 MC 를 밀어냄 · R 바꿈 · 폭 안 작은 걸음 +const nudge = (cols, k, dx, dy) => { + const now = { + ...cols, + planned: cols.planned.map((v, i) => (i === k ? [v[0] + dx, v[1] + dy] : v)), + }; + return { ...now, planned: fit.fitAfter(cols, now, LIMITS) }; +}; +const drag = nudge(lockInFit, K - 1, 0, 4); +const small = nudge(lockInFit, K - 1, 0, 0.1); +const withRadius = (cols, r) => { + const now = { ...cols, curveRadius: cols.curveRadius.map((x, i) => (i === K ? r : x)) }; + return { ...now, planned: fit.fitAfter(cols, now, LIMITS) }; +}; +const radius = withRadius(lockInFit, 100); +const radiusIn = withRadius(lockInFit, 60); +const freeRadius = withRadius(freeFit, 100); +// 1 m 스냅 · 측점 No. 20 m — 측점 밖 정수 m 자리는 옛 판정(0.05 m) 그대로 +const one = { ...LIMITS, intervalM: 1 }; +const odd = place(fitted, K, station + 3.3); + +console.log( + JSON.stringify({ + station, + fittedOff: off(fitted), + inside: { mc: inside.mc, off: off(inside.c) }, + outside: { mc: outside.mc, off: off(outside.c) }, + lockInFit: { mc: mcs(lockInFit, LIMITS)[K - 1], off: off(lockInFit) }, + freeFit: { mc: mcs(freeFit, LIMITS)[K - 1], off: off(freeFit) }, + drag: { mc: mcs(drag, LIMITS)[K - 1], pushed: fit.pushedOff(lockInFit, drag, LIMITS) }, + small: { mc: mcs(small, LIMITS)[K - 1], pushed: fit.pushedOff(lockInFit, small, LIMITS) }, + radius: { mc: mcs(radius, LIMITS)[K - 1], pushed: fit.pushedOff(lockInFit, radius, LIMITS) }, + radiusIn: { + mc: mcs(radiusIn, LIMITS)[K - 1], + pushed: fit.pushedOff(lockInFit, radiusIn, LIMITS), + }, + freeRadius: { + mc: mcs(freeRadius, LIMITS)[K - 1], + pushed: fit.pushedOff(freeFit, freeRadius, LIMITS), + }, + oneStep: { + insideOff: off(inside.c, one), + oddMc: odd.mc, + oddOff: (() => { + const b = built(odd.c, one); + return fit.offStationMcs(b.vertices, b.curves, 1, STATION); + })(), + }, + }), +); diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index 8afb1bb0f..bf571c14d 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -133,7 +133,7 @@ def test_끌기_방향키_편집기_이음() -> None: 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 "stationSnap ? fit.offStationCount(" in edit and " offStation,\n" in edit assert edit.count("\n") <= 695 diff --git a/resources/tester/test_432_mc_on_station.py b/resources/tester/test_432_mc_on_station.py new file mode 100644 index 000000000..35071c801 --- /dev/null +++ b/resources/tester/test_432_mc_on_station.py @@ -0,0 +1,75 @@ +"""432 MC 를 일반측점 위에 두면 그 뒤 스냅대로. + +「위」 판정 폭 = 측점 목록 「(MC)」 규칙(`curveStations` 반올림 · 정수 m = ±0.5 m) · +폭 안이면 정확히 맞춰 보고 못 맞춰도 「위」 · 그 곡선을 폭 밖으로 미는 걸음(끌기 · R 입력)은 +`pushedOff` 로 막음 · 측점 밖 정수 m 자리(1 m 스냅)는 옛 판정 그대로. + +TS 셈은 `helper_432_mc_on_station.cjs`(Node · `StationFit` 그대로). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_432_mc_on_station.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_432_mc_on_station.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=120 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_폭_안은_위_폭_밖은_측점_밖(got) -> None: + station = got["station"] + assert got["fittedOff"] == 0 + assert got["inside"]["mc"] == pytest.approx(station + 0.3, abs=1e-6) + assert got["inside"]["off"] == 0 + assert got["outside"]["mc"] == pytest.approx(station + 0.7, abs=1e-6) + assert got["outside"]["off"] == 1 + + +def test_못_맞춰도_위_맞출_수_있으면_정확히(got) -> None: + station = got["station"] + assert got["lockInFit"]["off"] == 0 # 고정 IP · 그 자리 그대로 「위」 + assert got["lockInFit"]["mc"] == pytest.approx(station + 0.3, abs=1e-3) + assert got["freeFit"]["off"] == 0 # 같은 측점에 정확히(다른 측점으로 안 옮김) + assert abs(got["freeFit"]["mc"] - station) <= 0.05 + + +def test_폭_밖으로_미는_걸음_막음(got) -> None: + station = got["station"] + for case in ("drag", "radius"): + assert abs(got[case]["mc"] - station) > 0.5 and got[case]["pushed"] is True + for case in ("small", "radiusIn"): + assert abs(got[case]["mc"] - station) < 0.5 and got[case]["pushed"] is False + assert abs(got["freeRadius"]["mc"] - station) <= 0.05 and got["freeRadius"]["pushed"] is False + + +def test_1m_스냅_측점_밖은_옛_판정(got) -> None: + one = got["oneStep"] + assert one["insideOff"] == 0 # 일반측점 + 0.3 = 「(MC)」 폭 + assert one["oddOff"][0] == pytest.approx(one["oddMc"], abs=1e-6) # 정수 m + 0.3 = 측점 밖 + + +def test_편집기_배선() -> None: + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + apply_edit = edit[edit.index("function applyEdit(") :] + apply_edit = apply_edit[: apply_edit.index("writeRouteLocks(")] + assert ( + "stationSnap && fitting && before && fit.pushedOff(before, snapshotNow(), fitLimits())" + in apply_edit + ) + assert "fit.offStationCount(plannedLine, curveInfo, snap(), stationIntervalM)" in edit + bar = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text( + encoding="utf-8" + ) + assert "offStationMcs(line, curveInfo, snap.stepM, snap.stationM)" in bar