feat(B05 노선 편집 R 입력): 반지름 · 곡선 길이 뒤 상태줄 함께 밀린 교점 · 못 맞춘 곡선 보고 + 뒤쪽 MC 시험 (408-1)

- 맞춤 함수 그대로(fitCurveStations) · 스냅 켬일 때만 꼬리 — KeyNudge nudgeReport 재사용 fitNote
- StationFit offStationMcs(측점 밖 MC 목록) · offStationCount 는 그 길이
- 시험 test_408_radius_downstream 새로 · test_398_2 뒤쪽 곡선 MC 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-18 09:05:41 +09:00
co-authored by Claude Opus 5.5
parent 0c2ce402f0
commit 7846827fab
8 changed files with 327 additions and 24 deletions
+2
View File
@@ -418,6 +418,8 @@ export async function mountRouteEditor(
apexLock,
limitRadiusM,
limitArcM,
line: plannedLine,
snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
@@ -22,6 +22,8 @@ import {
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
@@ -38,6 +40,10 @@ export interface CurveBarState {
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
/** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */
line: Vertex[];
/** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */
snap: { stepM: number; stationM: number } | null;
}
export interface CurveBarParams {
@@ -64,16 +70,16 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
const { picked, curveRadius, planned } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "반지름");
noteResult(message, "반지름", planned);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
@@ -83,7 +89,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const message =
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "곡선 길이");
noteResult(message, "곡선 길이", planned);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
@@ -124,20 +130,31 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
);
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */
function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void {
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return;
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state();
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
if (!status) return;
status.textContent = (status.textContent ?? "").replace(
message,
`${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`,
);
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
const head =
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
? message.replace(/\.$/, "")
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
const tail = snap
? fitNote(
before,
planned,
picked,
offStationMcs(line, curveInfo, snap.stepM),
snap.stationM,
snap.stepM,
)
: "";
status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`);
}
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
@@ -5,9 +5,11 @@
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
* ========================================================================== */
import { stationLabel } from "../../common_util/common_util_station";
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
/** 방향키 한 번(m) · Shift 배수. */
@@ -94,3 +96,22 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요.";
return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`;
}
/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 ·
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */
export function fitNote(
before: ReadonlyArray<Vertex>,
after: ReadonlyArray<Vertex>,
picked: number,
offMcs: ReadonlyArray<number>,
stationM: number,
stepM: number,
): string {
const start = before[picked] ?? [0, 0];
const report = nudgeReport(before, after, picked, start, start);
const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0);
const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`);
const more = names.length > 3 ? ` 외 ${names.length - 3}` : "";
const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : "";
return ` · 함께 밀린 교점 ${pushed}곳${off}`;
}
@@ -66,15 +66,25 @@ export function offStationCount(
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number {
if (!(intervalM > 0)) return 0;
const spans = curveSpans(line, curves);
return offStationMcs(line, curves, intervalM).length;
}
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
export function offStationMcs(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number[] {
if (!(intervalM > 0)) return [];
const hits = new Set<number>();
for (const [start, end] of spans) {
const off: number[] = [];
for (const [start, end] of curveSpans(line, curves)) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
else off.push(mc);
}
return spans.length - hits.size;
return off;
}
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
@@ -69,6 +69,8 @@ function editor(snap) {
apexLock: Array(n).fill(false),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: LIMITS.intervalM } : null,
};
const status = { textContent: "" };
const markEdited = () => {
@@ -139,11 +141,14 @@ for (const snap of [false, true]) {
ed.pick(1);
h.onRadius(40);
row.r40 = round(ed.drawn(1).radius_m);
const later = () => [2, 3].map((k) => round(ed.mc(k))); // 뒤쪽 곡선 MC(408-1)
row.r40Later = later();
h.onRadius(60);
row.r60 = round(ed.drawn(1).radius_m);
row.r60Shown = round(ed.lastShow().radiusShown);
row.r60Status = ed.status.textContent;
row.r60Mc = round(ed.mc(1));
row.r60Later = later();
h.onArcLength(18);
row.l18 = round(ed.arcOf(1));
h.onArcLength(25);
@@ -0,0 +1,161 @@
/* 408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄
* (`test_408_radius_downstream.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는
* 같은 길(스냅 켬 = `fitStart` → `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내.
* node helper_408_radius_downstream.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B05 = path.join(ROOT, "B05_Profile");
const cache = new Map();
let handlers = null;
const STUBS = {
"./B05_Profile_UI_RouteEdit_Label": {
createCurveLabel: (given) => {
handlers = given;
return { show: () => {}, hide: () => {}, destroy: () => {} };
},
centerDirectionOf: () => null,
},
};
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) =>
STUBS[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 curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
const frame = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"));
const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts"));
// 곡선 넷(IP1~4) · 직선이 길어 맞춤이 접선 절반에 안 막힘
const ROUTE = [
[0, 0],
[110, 0],
[210, 60],
[320, 60],
[420, -10],
[540, -10],
];
const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 };
const KEYS = ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"];
const copy = (state) =>
Object.fromEntries(
KEYS.map((key) => [key, state[key].map((v) => (Array.isArray(v) ? [...v] : v))]),
);
function editor(snap, apexLock = []) {
const n = ROUTE.length;
const state = {
picked: -1,
planned: ROUTE.map((v) => [v[0], v[1]]),
nodeInfo: [],
curveInfo: [],
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(30),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
apexLock: Array.from({ length: n }, (_, i) => apexLock.includes(i)),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: 20 } : null,
};
const status = { textContent: "" };
const markEdited = () => {
edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius);
edits.applyCurveLimits(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.limitRadiusM,
LIMITS.limitArcM,
);
const built = curve.buildEditedPolyline(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.minRadiusM,
);
state.line = built.vertices;
state.curveInfo = built.curves;
state.nodeInfo = built.nodes;
};
let committed = null;
const applyEdit = (message) => {
const from = fit.fitStart(committed, state);
if (snap) state.planned = fit.fitCurveStations(state, LIMITS, from);
markEdited();
status.textContent = `예상노선 — ${message} 꼬리`;
committed = copy(state);
};
bar.createCurveBar({
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) },
container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) },
state: () => state,
toScreen: (v) => [v[0], v[1]],
applyEdit,
onUnselect: () => {},
});
// 처음 = 스냅 켤 때처럼 전체 맞춤 한 걸음
markEdited();
applyEdit("측점 스냅을 켰습니다.");
const mcs = () =>
frame
.curveSpans(state.line, state.curveInfo)
.map(([a, b]) => Math.round(((a + b) / 2) * 1000) / 1000);
const off = () => fit.offStationCount(state.line, state.curveInfo, LIMITS.intervalM);
const pick = (k) => {
state.picked = k;
};
return { state, status, h: () => handlers, mcs, off, pick };
}
const out = {};
const row = (ed) => ({
mcs: ed.mcs(),
off: ed.off(),
status: ed.status.textContent,
planned: ed.state.planned.map((v) => [...v]),
});
for (const snap of [true, false]) {
const key = snap ? "on" : "off";
const ed = editor(snap);
out[`${key}Start`] = row(ed);
ed.pick(1);
ed.h().onRadius(60); // 앞쪽 곡선 R 키움
out[`${key}R60`] = row(ed);
ed.h().onArcLength(50); // 곡선 길이 입력도
out[`${key}L50`] = row(ed);
ed.h().onRadius(null); // 빈 값 = 자동
out[`${key}Auto`] = row(ed);
}
// IP3 고정 = 그 곡선은 못 맞춤 → 상태줄 「못 맞춘 곡선 No.…」
{
const ed = editor(true, [3]);
out.lockStart = row(ed);
ed.pick(1);
ed.h().onRadius(60);
out.lockR60 = row(ed);
}
process.stdout.write(JSON.stringify(out));
+15 -3
View File
@@ -38,7 +38,7 @@ def test_반지름_40_60_곡선이_바뀜(got, snap) -> None:
row = got[snap]
assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60)
assert row["r60Shown"] == pytest.approx(60)
assert "반지름을 바꿨습니다." in row["r60Status"]
assert "반지름을 바꿨습니다" in row["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
@@ -47,7 +47,7 @@ def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None:
tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값
assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol)
assert row["l25Shown"] == pytest.approx(row["l25"])
assert "곡선 길이를 바꿨습니다." in row["l25Status"]
assert "곡선 길이를 바꿨습니다" in row["l25Status"]
def test_스냅_켬_MC_가_측점에(got) -> None:
@@ -55,6 +55,18 @@ def test_스냅_켬_MC_가_측점에(got) -> None:
assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05)
def test_스냅_켬_R_바꾼_뒤_뒤쪽_곡선_MC(got) -> None:
"""408-1 — 고친 곡선 뒤 곡선도 맞춤 · 측점 위였던 곡선은 R 바꾼 뒤에도 측점 위."""
on = got["snapOn"]
def hit(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= 0.05
assert [hit(mc) for mc in on["r60Later"]] >= [hit(mc) for mc in on["r40Later"]]
assert all(hit(mc) for mc in on["r60Later"])
assert "함께 밀린 교점 " in on["r60Status"] and "함께 밀린" not in got["snapOff"]["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None:
row = got[snap]
@@ -62,7 +74,7 @@ def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -
want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)"
assert want in row["capStatus"]
assert "곡선 길이 40m → " in row["capArcStatus"]
assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
assert "반지름을 자동으로 되돌렸습니다" in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
def test_고정(got) -> None:
@@ -0,0 +1,75 @@
"""408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — 반지름 · 곡선 길이 입력 = 교점 끌기와 같은
`fitCurveStations` 로 고친 곡선부터 끝 곡선까지 MC 를 측점에 다시 맞춤 · 상태줄 「함께 밀린 교점 N곳 ·
못 맞춘 곡선 No.…」(`KeyNudge.fitNote`) · 스냅 끔 = 맞춤 · 보고 없음.
TS 셈은 `helper_408_radius_downstream.cjs`(Node · `_CurveBar` 배선 그대로).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_radius_downstream.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_408_radius_downstream.cjs")
TOL = 0.05 # MC_TOL_M
STEPS = ["R60", "L50", "Auto"]
@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 _on_station(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= TOL
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤쪽_곡선_MC_가_측점_위(got, step) -> None:
row = got[f"on{step}"]
assert len(row["mcs"]) == 4 and all(_on_station(mc) for mc in row["mcs"])
assert row["off"] <= got["onStart"]["off"]
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤_교점이_밀림_시점은_그대로(got, step) -> None:
before = got["onStart"]["planned"]
after = got[f"on{step}"]["planned"]
assert after[0] == before[0] and after[-1] == before[-1]
assert all(after[k] != before[k] for k in (2, 3, 4)) # 고친 곡선 뒤 교점이 맞춤으로 밀림
def test_상태줄_보고(got) -> None:
assert "반지름을 바꿨습니다 · 함께 밀린 교점 4곳." in got["onR60"]["status"]
assert "곡선 길이를 바꿨습니다 · 함께 밀린 교점 4곳." in got["onL50"]["status"]
assert "반지름을 자동으로 되돌렸습니다 · 함께 밀린 교점 " in got["onAuto"]["status"]
assert all("못 맞춘 곡선" not in got[f"on{step}"]["status"] for step in STEPS)
@pytest.mark.parametrize("step", STEPS)
def test_스냅_끔_맞춤_보고_없음(got, step) -> None:
row = got[f"off{step}"]
assert "함께 밀린" not in row["status"]
assert row["planned"] == got["offStart"]["planned"] # 교점 그대로
assert not all(_on_station(mc) for mc in row["mcs"])
def test_고정_교점_곡선은_못_맞춤으로(got) -> None:
row = got["lockR60"]
assert row["off"] == got["lockStart"]["off"] == 1
assert row["planned"][3] == got["lockStart"]["planned"][3] # 고정 IP3 그대로
mc = row["mcs"][2]
n, rest = int(mc // 20), round(mc - int(mc // 20) * 20)
assert f"함께 밀린 교점 3곳 · 못 맞춘 곡선 No.{n}+{rest}." in row["status"]
assert all(_on_station(row["mcs"][i]) for i in (0, 1, 3)) # 고정 곡선 뒤도 맞춤