diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8cbb093ff..e31cf0acb 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -280,6 +280,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { base: () => base, alignment: () => alignment, edits: () => store.edits(), + savedRadii: () => store.savedRadii(), applyEdits: (next) => applyEdits(next), selectedStationId: () => selectedStationId, irregularStations: () => irregularStations, diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 4bbb56270..c0923b005 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): { export interface ProfileEditStore { edits(): AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */ + savedRadii(): Readonly>; replace(next: AlignmentEdits): void; /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. * 이때의 값이 [초기화]의 기준점이 된다. */ @@ -202,6 +204,7 @@ function handleOf( return { edits: () => state.current, + savedRadii: () => state.saved.curve_radii ?? {}, replace: commit, markSaved() { state.unsaved = false; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 132e87ed9..54e24ff6d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -47,6 +47,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */ + savedRadii: () => Readonly>; /** false = 막힘(기울기 · 흙덮기). */ applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ @@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (tools.mode() === "shift") return tools.nudge(delta); const chainage = selectedChainage(); if (chainage === null) return false; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())); return true; } // 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1) diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 36095813d..08d7b87d1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -79,6 +79,7 @@ export interface ProfileRenderContext { }; store: { edits: () => AlignmentEdits; + savedRadii: () => Readonly>; resetStation: (chainageM: number) => void; }; detail: () => SectionDetailResponse | null; @@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => - base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)), + base && + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())), }) : null; @@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())); }, }), ); diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index a68c8b485..c26217af1 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. * 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1). + * 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠. */ export function adjustStation( base: AlignmentBase, edits: AlignmentEdits, chainageM: number, delta: number, + autoRadii: Readonly> = {}, ): AlignmentEdits { const key = chainageKey(chainageM); const current = buildAlignment(base, edits); @@ -580,6 +582,7 @@ export function adjustStation( const withOffset = (value: number): AlignmentEdits => { const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; const radii = { ...edits.curve_radii }; + if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key]; axes?.inner.forEach((chainage) => { const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; const z = diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a4a3c0054..fc88620ae 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -89,6 +89,7 @@ tools.createPanelTools({ base: () => ({}), alignment: () => null, edits: () => ({}), + savedRadii: () => ({}), applyEdits: () => undefined, selectedStationId: () => "irregular:p1", irregularStations: () => [pipe], diff --git a/resources/tester/helper_455_arrow_auto_radius.cjs b/resources/tester/helper_455_arrow_auto_radius.cjs new file mode 100644 index 000000000..1210f49fe --- /dev/null +++ b/resources/tester/helper_455_arrow_auto_radius.cjs @@ -0,0 +1,77 @@ +/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림. + * ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로 + * ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON. + */ +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) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const align = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); + +// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %) +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: false, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; +const KEY = "100.000"; +const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A) + +function press(radius, autoRadii) { + const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } }; + const next = + autoRadii === undefined + ? align.adjustStation(base, edits, 100, 0.1) + : align.adjustStation(base, edits, 100, 0.1, autoRadii); + const curve = align + .buildAlignment(base, next) + .curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6); + return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null }; +} + +process.stdout.write( + JSON.stringify({ + auto: press(AUTO, { [KEY]: AUTO }), + user: press(777, { [KEY]: AUTO }), + legacy: press(AUTO), + }), +); diff --git a/resources/tester/test_193_1_line_axis.py b/resources/tester/test_193_1_line_axis.py index ba53cb1fa..9a6e59951 100644 --- a/resources/tester/test_193_1_line_axis.py +++ b/resources/tester/test_193_1_line_axis.py @@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None: encoding="utf-8" ) assert "if (event.defaultPrevented) return;" in src - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src + assert ( + "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src + ) def _ground(level: float) -> list[dict]: diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py index 84fb63679..2c600b80a 100644 --- a/resources/tester/test_355_1_pivot_tilt.py +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case): def test_부르는_곳_셋이_같은_함수(): - assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + # 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김 + assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Render.ts" ) - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" ) assert "pivotAxes(current, chainageM)" in _read( diff --git a/resources/tester/test_455_arrow_auto_radius.py b/resources/tester/test_455_arrow_auto_radius.py new file mode 100644 index 000000000..0d4142297 --- /dev/null +++ b/resources/tester/test_455_arrow_auto_radius.py @@ -0,0 +1,52 @@ +"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L. + +까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로 +짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠. +계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_455_arrow_auto_radius.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_자동_R_은_지워_기본_L(out) -> None: + assert out["auto"]["radius"] is None + assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6) + # 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거 + assert out["legacy"]["radius"] == pytest.approx(3000.0) + assert out["legacy"]["length"] < 14.0 + + +def test_사용자_R_은_그대로(out) -> None: + assert out["user"]["radius"] == 777 + + +def test_부르는_곳_셋이_저장_R_을_넘김() -> None: + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert render.count("chainage, delta, store.savedRadii())") == 2 + tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools + assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts")