Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-28 23:00:38 +09:00
7 changed files with 276 additions and 16 deletions
+7 -3
View File
@@ -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({
@@ -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);
}
@@ -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<Vertex>,
curves: ReadonlyArray<EditedCurve>,
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<Vertex>,
curves: ReadonlyArray<EditedCurve>,
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<NodeColumns, "planned">,
now: Columns & Pick<NodeColumns, "planned">,
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<NodeColumns, "planned">): 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(
@@ -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) }];
}),
@@ -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);
})(),
},
}),
);
+1 -1
View File
@@ -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
@@ -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