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
@@ -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