From 054f716f015c7b316c0f981ff7206d1abd4c9347 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 20:49:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=BA=BE?= =?UTF-8?q?=EC=9E=84=EC=A0=90=20=EC=B0=BD):=20=EB=B0=98=EC=A7=80=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EA=B8=B8=EC=9D=B4=20=EC=B9=B8?= =?UTF-8?q?=20=3D=20=EA=B7=B8=EB=A6=B0=20=EA=B0=92=20=C2=B7=20=EB=88=8C?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EC=83=81=ED=83=9C=EC=A4=84=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EC=9D=80=20=EA=B0=92=20=C2=B7=20=EA=B7=B8=EB=A6=B0=20?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20(398-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 까닭: 접선은 앞뒤 직선 짧은 쪽 절반까지(buildEditedPolyline · 서버 짝 · 09-07부터)라 큰 R 이 조용히 눌림 · 창 칸은 넣은 값을 그대로 보여(09-12부터) 곡선이 안 바뀐 것처럼 보임 — 검증_sub1 노선 IP 5 = 40 · 60 넣어도 12.4 m. 스냅 · 묶음 · 383-1 무관. - 칸 = 그린 반지름 · 곡선 길이 - 눌리면 상태줄 「반지름 60m를 넣었지만 25.4m로 그렸습니다 — 앞뒤 직선 짧은 쪽(16m)의 절반까지만 접선이 들어갑니다.」 - 시험 test_398_2_curve_input(스냅 끔 · 켬 반지름 40 → 60 · 곡선 길이 18 → 25 · 눌림 · 빈 값 · 고정) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 34 +++- resources/tester/helper_398_2_curve_input.cjs | 191 ++++++++++++++++++ resources/tester/test_398_2_curve_input.py | 76 +++++++ 3 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_398_2_curve_input.cjs create mode 100644 resources/tester/test_398_2_curve_input.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 4ae2590c2..d2ebe8cc1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -68,9 +68,9 @@ export function createCurveBar(params: CurveBarParams): CurveBar { if (picked < 0) return; curveRadius[picked] = value; // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. - params.applyEdit( - value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.", - ); + const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."; + params.applyEdit(message); + noteShortfall(message, "반지름"); }, onArcLength: (value) => { const { picked, nodeInfo, curveArc, curveRadius } = params.state(); @@ -80,9 +80,10 @@ export function createCurveBar(params: CurveBarParams): CurveBar { const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); curveArc[picked] = value; curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; - params.applyEdit( - value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.", - ); + const message = + value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."; + params.applyEdit(message); + noteShortfall(message, "곡선 길이"); }, onLock: (lock) => { const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state(); @@ -122,6 +123,24 @@ export function createCurveBar(params: CurveBarParams): CurveBar { params.container, ); + /** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 · 그린 값 · 까닭으로(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`; + const status = params.container.querySelector(".b05-routeedit__status"); + if (!status) return; + status.textContent = (status.textContent ?? "").replace( + message, + `${name} ${m(asked * per)}를 넣었지만 ${m(drawn.radius_m * per)}로 그렸습니다 — ` + + `앞뒤 직선 짧은 쪽(${m(drawn.tangent_m * 2)})의 절반까지만 접선이 들어갑니다.`, + ); + } + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ function sync(): void { const { @@ -169,7 +188,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar { return; } const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); - const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; + // 칸 = 그린 R(398-2) — 넣은 값을 그대로 보이면 접선 자리에 눌린 것이 안 보임. + const shown = pickedCurve?.radius_m ?? curveRadius[picked] ?? null; const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null; const optional = curveOptionalAt(innerAngle); const deflection = deflectionRad(innerAngle); diff --git a/resources/tester/helper_398_2_curve_input.cjs b/resources/tester/helper_398_2_curve_input.cjs new file mode 100644 index 000000000..9a0f40ceb --- /dev/null +++ b/resources/tester/helper_398_2_curve_input.cjs @@ -0,0 +1,191 @@ +/* 398-2 꺾임점 창 반지름 · 곡선 길이 넣기(요구 249) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄 + * (`test_398_2_curve_input.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는 + * 같은 길(스냅 켬 = `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내. + * node helper_398_2_curve_input.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 shows = []; +const STUBS = { + "./B05_Profile_UI_RouteEdit_Label": { + createCurveLabel: (given) => { + handlers = given; + return { show: (state) => shows.push(state), 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 fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts")); +const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts")); + +// IP1 = 앞뒤 직선 길어 R 60 들어감 · IP2 = 뒤 직선 16 m 라 접선 8 m 까지(큰 R 눌림) +const ROUTE = [ + [0, 0], + [120, 0], + [220, 70], + [236, 70], + [340, 0], +]; +const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 }; + +function editor(snap) { + 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(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + apexLock: Array(n).fill(false), + limitRadiusM: LIMITS.limitRadiusM, + limitArcM: LIMITS.limitArcM, + }; + 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 made = null; + const applyEdit = (message) => { + if (snap) state.planned = fit.fitCurveStations(state, LIMITS, 1); + markEdited(); + made.sync(); + status.textContent = `예상노선 — ${message} 꼬리`; + }; + made = 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(); + const drawn = (k) => state.curveInfo.find((c) => c.node_first === k) ?? null; + const arcOf = (k) => { + const c = drawn(k); + return c ? c.radius_m * edits.deflectionRad(c.inner_angle_deg) : null; + }; + const mc = (k) => { + const c = drawn(k); + if (!c) return null; + const [[a, b]] = loadTs( + path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"), + ).curveSpans(state.line, [c]); + return (a + b) / 2; + }; + const pick = (k) => { + state.picked = k; + made.sync(); + }; + const lastShow = () => shows[shows.length - 1]; + return { state, status, handlers: () => handlers, drawn, arcOf, mc, pick, lastShow, markEdited }; +} + +const round = (v) => (v === null ? null : Math.round(v * 1000) / 1000); +const out = {}; + +for (const snap of [false, true]) { + const key = snap ? "snapOn" : "snapOff"; + const ed = editor(snap); + const h = ed.handlers(); + const row = {}; + // IP1 — 반지름 40 → 60 · 곡선 길이 18 → 25 + ed.pick(1); + h.onRadius(40); + row.r40 = round(ed.drawn(1).radius_m); + 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)); + h.onArcLength(18); + row.l18 = round(ed.arcOf(1)); + h.onArcLength(25); + row.l25 = round(ed.arcOf(1)); + row.l25Shown = round(ed.lastShow().arcLengthShown); + row.l25Status = ed.status.textContent; + // IP2 — 접선 자리 모자람(넣은 값 · 그린 값 · 까닭) + ed.pick(2); + h.onRadius(60); + row.capR = round(ed.drawn(2).radius_m); + row.capShown = round(ed.lastShow().radiusShown); + row.capStatus = ed.status.textContent; + h.onArcLength(40); + row.capArcStatus = ed.status.textContent; + h.onRadius(null); + row.autoStatus = ed.status.textContent; + out[key] = row; +} + +// 「고정」 — 길이 고정 켬 = 교각이 바뀌어도 그 길이 · 끔 = 반지름이 남고 길이가 따라 바뀜 +for (const lock of [true, false]) { + const ed = editor(false); + const h = ed.handlers(); + ed.pick(1); + h.onArcLength(25); + if (lock) h.onLock("arc"); + ed.state.planned[2] = [220, 50]; // IP1 교각이 바뀜 + ed.markEdited(); + out[lock ? "arcLocked" : "arcFree"] = { + arc: round(ed.arcOf(1)), + radius: round(ed.drawn(1).radius_m), + }; +} +{ + const ed = editor(false); + const h = ed.handlers(); + ed.pick(1); + h.onRadius(60); + h.onLock("radius"); + ed.state.planned[2] = [220, 50]; + ed.markEdited(); + out.radiusLocked = { radius: round(ed.drawn(1).radius_m), lock: ed.state.curveLock[1] }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_398_2_curve_input.py b/resources/tester/test_398_2_curve_input.py new file mode 100644 index 000000000..ff5f488f9 --- /dev/null +++ b/resources/tester/test_398_2_curve_input.py @@ -0,0 +1,76 @@ +"""398-2 꺾임점 창 반지름 · 곡선 길이 넣기(요구 249) — 넣은 값으로 곡선이 바뀜 · +스냅 켬 = 맞춤 뒤 값 · 접선 자리 모자라 눌리면 칸 = 그린 값 · 상태줄 = 넣은 값 · 그린 값 · 까닭 · +「고정」 켬 = 그 값 지킴. + +까닭: 접선은 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝)라 큰 R 이 조용히 눌렸는데 +창 칸은 넣은 값을 그대로 보여 바뀐 게 없어 보였음. + +TS 셈은 `helper_398_2_curve_input.cjs`(Node · `_CurveBar` 배선 그대로). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_398_2_curve_input.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_398_2_curve_input.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) + + +@pytest.mark.parametrize("snap", ["snapOff", "snapOn"]) +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"] + + +@pytest.mark.parametrize("snap", ["snapOff", "snapOn"]) +def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None: + row = got[snap] + 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"] + + +def test_스냅_켬_MC_가_측점에(got) -> None: + assert got["snapOn"]["r60Mc"] == pytest.approx(120, abs=0.05) # 20 m 간격 + assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05) + + +@pytest.mark.parametrize("snap", ["snapOff", "snapOn"]) +def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None: + row = got[snap] + assert row["capR"] < 60 and row["capShown"] == pytest.approx(row["capR"]) + assert f"반지름 60m를 넣었지만 {round(row['capR'], 1)}m로 그렸습니다" in row["capStatus"] + assert "절반까지만 접선이 들어갑니다." in row["capStatus"] + assert "곡선 길이 40m를 넣었지만" in row["capArcStatus"] + assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음 + + +def test_고정(got) -> None: + assert got["arcLocked"]["arc"] == pytest.approx(25) # 교각이 바뀌어도 길이 그대로 + assert got["arcFree"]["arc"] != pytest.approx(25, abs=0.1) + assert got["radiusLocked"] == {"radius": pytest.approx(60), "lock": "radius"} + + +def test_편집기_줄_수_그대로() -> None: + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert edit.count("\n") <= 695 From 156ee5e79ca3bfd8b63ea15a839a8a103971c8f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:07:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=BA=BE?= =?UTF-8?q?=EC=9E=84=EC=A0=90=20=EC=B0=BD):=20=EB=88=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=A4=84=20=EA=B8=80=20=EC=A7=A7=EA=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EB=B0=98=EC=A7=80=EB=A6=84=2060m=20?= =?UTF-8?q?=E2=86=92=2023.2m(=EC=95=9E=EB=92=A4=20=EC=A7=81=EC=84=A0?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=A7=EC=95=84=20=EC=A0=91=EC=84=A0=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A8=EC=9E=90=EB=9E=8C)=E3=80=8D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=A4=84=20=EB=91=90=20=EC=A4=84=20=EC=95=88=20(398-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면에서 옛 글 끝(까닭)이 두 줄 자름에 잘림. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts | 7 +++---- resources/tester/test_398_2_curve_input.py | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index d2ebe8cc1..87dc69dc4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -123,8 +123,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar { params.container, ); - /** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 · 그린 값 · 까닭으로(398-2) — 접선은 앞뒤 직선 - * 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */ + /** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은 + * 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */ function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void { const { picked, curveRadius, curveInfo, nodeInfo } = params.state(); const asked = curveRadius[picked]; @@ -136,8 +136,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { if (!status) return; status.textContent = (status.textContent ?? "").replace( message, - `${name} ${m(asked * per)}를 넣었지만 ${m(drawn.radius_m * per)}로 그렸습니다 — ` + - `앞뒤 직선 짧은 쪽(${m(drawn.tangent_m * 2)})의 절반까지만 접선이 들어갑니다.`, + `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`, ); } diff --git a/resources/tester/test_398_2_curve_input.py b/resources/tester/test_398_2_curve_input.py index ff5f488f9..4d3cce911 100644 --- a/resources/tester/test_398_2_curve_input.py +++ b/resources/tester/test_398_2_curve_input.py @@ -59,9 +59,9 @@ def test_스냅_켬_MC_가_측점에(got) -> None: def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None: row = got[snap] assert row["capR"] < 60 and row["capShown"] == pytest.approx(row["capR"]) - assert f"반지름 60m를 넣었지만 {round(row['capR'], 1)}m로 그렸습니다" in row["capStatus"] - assert "절반까지만 접선이 들어갑니다." in row["capStatus"] - assert "곡선 길이 40m를 넣었지만" in row["capArcStatus"] + want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)" + assert want in row["capStatus"] + assert "곡선 길이 40m → " in row["capArcStatus"] assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음