From 98e7d4aee2673aa80e83b4a8d3672fef3eefc3bd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:26:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(454=20B05=20=EB=B0=A9=ED=96=A5=ED=82=A4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B1=B8=EC=9D=8C):=20=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=ED=9B=84=EB=B3=B4=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EB=91=98=20=C2=B7=20=EB=92=A4=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=95=9E=EB=B3=B4=EA=B8=B0=20(433-21=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 곡선마다 내림 · 올림 측점 후보 · 뒤 곡선이 측점 위거나 맞춰지는 쪽 고름 - 무작위 노선 30 개 20 m 걸음 측점 밖 막힘 141 → 25 · 놓은 뒤 측점 밖 43 → 0 - 시험 test_454_arrow_station_step 추가 Co-Authored-By: Claude Opus 5.5 --- .../logic/B05_Profile_RouteEdit_StationFit.ts | 76 +++++++++++---- .../tester/helper_454_arrow_station_step.cjs | 96 +++++++++++++++++++ .../tester/test_454_arrow_station_step.py | 35 +++++++ 3 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_454_arrow_station_step.cjs create mode 100644 resources/tester/test_454_arrow_station_step.py diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index f54583bba..5351b86cd 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -4,7 +4,7 @@ * * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서 - * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠. + * 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454). * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. * · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`). @@ -139,7 +139,9 @@ function solve( return null; } -/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */ +/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). + * 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②) + * — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */ export function fitCurveStations( cols: Columns & Pick, limits: FitLimits, @@ -150,28 +152,64 @@ export function fitCurveStations( if (!(interval > 0) || planned.length < 3) return planned; let built = build(cols, planned, limits); const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM); + const tol = mcTolM(interval); + type Fit = { planned: Vertex[]; built: EditedPolyline }; + /** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */ + const near = (mc: number, last: number): number[] => { + const lo = Math.floor(mc / interval); + return [lo, lo + 1] + .filter((n) => n > last) + .sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc)); + }; + const onAt = (mc: number, last: number) => + near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol); + /** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ + const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => { + const fitted = solve(cols, limits, at.planned, k, n * interval, mc); + if ( + !fitted || + shortfallCrossed( + floor, + curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM), + ) + ) + return null; + if (previous < 0) return fitted; + const moved = Math.abs( + (mcOf(fitted.built, previous) ?? Infinity) - (mcOf(at.built, previous) ?? 0), + ); + return moved <= tol / 5 ? fitted : null; + }; + /** `k` 뒤 첫 곡선이 측점 위이거나 맞춰지나(앞보기 한 걸음). */ + const nextSettles = (at: Fit, k: number, last: number): boolean => { + 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) !== undefined) return true; + return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null); + } + return true; + }; 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) <= mcTolM(interval); - 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)) <= - mcTolM(interval) / 5); - if (fitted && keep) ({ planned, built } = fitted); - if (onStation || keep) lastStation = near; + const on = onAt(mc, lastStation); + if (on !== undefined) lastStation = on; + else if (k >= from && !cols.apexLock[k]) { + let pick: { fit: Fit; n: number } | null = null; + for (const n of near(mc, lastStation)) { + const fit = attempt({ planned, built }, k, mc, n, previous); + if (!fit) continue; + pick ??= { fit, n }; + if (nextSettles(fit, k, n)) { + pick = { fit, n }; + break; + } + } + if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n)); + } previous = k; } return planned; diff --git a/resources/tester/helper_454_arrow_station_step.cjs b/resources/tester/helper_454_arrow_station_step.cjs new file mode 100644 index 000000000..fd8ac2627 --- /dev/null +++ b/resources/tester/helper_454_arrow_station_step.cjs @@ -0,0 +1,96 @@ +/* 454(433-21 ②) 방향키 측점 No. 걸음 — 무작위 짧은 다리 노선(14 IP · 고정 R) 교점마다 네 방향 한 걸음 + * 누르고 측점 밖 막힘 수를 JSON 한 줄로(`test_454_arrow_station_step.py` 가 판정). + * 누름 판정은 편집기 방향키 길과 같음 — 하한 깸 = 막힘 · 미리보기(`fitDrag`) 측점 밖이 늘면 막힘. + */ +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 edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); + +const INTERVAL = 20; +const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL }; +const columns = (p) => ({ + planned: p, + 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), +}); +const copy = (c) => ({ + ...c, + planned: c.planned.map(([x, y]) => [x, y]), + curveRadius: [...c.curveRadius], +}); +const off = (b) => fit.offStationCount(b.vertices, b.curves, INTERVAL); +const floor = (b) => edits.curveShortfalls(b.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); + +function route(seed) { + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const line = [[0, 0]]; + let a = 0; + for (let i = 0; i < 14; i += 1) { + a += (rnd() - 0.5) * 1.6; + const length = 35 + rnd() * 60; + const p = line[line.length - 1]; + line.push([p[0] + Math.cos(a) * length, p[1] + Math.sin(a) * length]); + } + return line; +} + +const out = { press: 0, floorBlock: 0, offBlock: 0, offStart: 0, offAfterRelease: 0 }; +for (let seed = 1; seed <= 10; seed += 1) { + const hist = columns(fit.fitCurveStations(columns(route(seed)), LIMITS, 1)); + hist.curveRadius = edits + .rebuildLine(copy(hist), LIMITS) + .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); + out.offStart += off(edits.rebuildLine(copy(hist), LIMITS)); + for (let k = 1; k < hist.planned.length - 1; k += 1) + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ]) { + const st = copy(hist); + const b0 = edits.rebuildLine(st, LIMITS); + const node = st.planned[k]; + st.planned[k] = [node[0] + dx * INTERVAL, node[1] + dy * INTERVAL]; + out.press += 1; + if (edits.shortfallCrossed(floor(b0), floor(edits.rebuildLine(st, LIMITS)))) { + out.floorBlock += 1; + continue; + } + const pv = { ...st, planned: fit.fitDrag(st, hist.planned, k, LIMITS) }; + if (off(edits.rebuildLine(copy(pv), LIMITS)) > off(b0)) { + out.offBlock += 1; + continue; + } + const rel = { ...pv, planned: fit.fitCurveStations(pv, LIMITS, fit.fitStart(hist, pv)) }; + if (off(edits.rebuildLine(copy(rel), LIMITS))) out.offAfterRelease += 1; + } +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_454_arrow_station_step.py b/resources/tester/test_454_arrow_station_step.py new file mode 100644 index 000000000..8dfd47aa3 --- /dev/null +++ b/resources/tester/test_454_arrow_station_step.py @@ -0,0 +1,35 @@ +"""454(433-21 ②) 방향키 측점 No. 20 m 걸음 막힘 — 곡선 맞춤이 후보 측점 둘(내림 · 올림) + 뒤 곡선 +앞보기로 고름. 무작위 노선 열 개 · 교점마다 네 방향 = 520 누름(`helper_454_arrow_station_step.cjs`). +고치기 전 측점 밖 막힘 48 · 처음 맞춤 측점 밖 2 · 놓은 뒤 측점 밖 43 → 6 · 0 · 0. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_454_arrow_station_step.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).with_name("helper_454_arrow_station_step.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: + assert got["press"] == 520 + + +def test_측점_밖_막힘_줄어듦(got) -> None: + assert got["offBlock"] <= 10 + + +def test_처음_맞춤_놓은_뒤_측점_밖_없음(got) -> None: + assert got["offStart"] == 0 and got["offAfterRelease"] == 0