diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index c835a47e8..eaee72c90 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -13,6 +13,8 @@ * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. * ========================================================================== */ +import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot"; + export interface AlignmentPolicy { station_interval_m: number; curve_radius_ratio: number; @@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가) * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. + * 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1). */ export function adjustStation( base: AlignmentBase, @@ -562,17 +565,24 @@ export function adjustStation( const key = chainageKey(chainageM); const current = buildAlignment(base, edits); const target = planElevationAt(current, chainageM) + delta; - const baseElevation = interpolate( - base.basePvi.map((node) => node.chainage_m), - base.basePvi.map((node) => node.elevation_m), - chainageM, - ); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const baseElevation = interpolate(baseS, baseZ, chainageM); let offset = (edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta; - const withOffset = (value: number): AlignmentEdits => ({ - ...edits, - station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, - }); + const axes = pivotAxes(current, chainageM); + const withOffset = (value: number): AlignmentEdits => { + const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; + const radii = { ...edits.curve_radii }; + axes?.inner.forEach((chainage) => { + const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; + const z = + axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM); + offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6)); + delete radii[chainageKey(chainage)]; + }); + return { ...edits, station_offsets: offsets, curve_radii: radii }; + }; let next = withOffset(offset); for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { const error = target - planElevationAt(buildAlignment(base, next), chainageM); diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts new file mode 100644 index 000000000..ccc84efc4 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Pivot.ts + * 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은 + * 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은 + * 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`). + * + * 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로). + * `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음). + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + +/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */ +const COLLINEAR_EPSILON = 1e-6; +const SAME_STATION_M = 1e-6; + +export interface PivotAxes { + fromM: number; + fromZ: number; + toM: number; + toZ: number; + /** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */ + inner: number[]; +} + +/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */ +export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null { + const { pvi, segments } = alignment; + const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M); + if (index <= 0 || index >= pvi.length - 1) return null; + const grade = (at: number): number => { + const segment = segments[at]; + return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0; + }; + const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON; + if (same(index - 1, index)) return null; + let from = index - 1; + while (from > 0 && same(from - 1, index - 1)) from -= 1; + let to = index + 1; + while (to < pvi.length - 1 && same(to, index)) to += 1; + return { + fromM: pvi[from].chainage_m, + fromZ: pvi[from].elevation_m, + toM: pvi[to].chainage_m, + toZ: pvi[to].elevation_m, + inner: pvi + .slice(from + 1, to) + .map((node) => node.chainage_m) + .filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M), + }; +} diff --git a/resources/tester/helper_355_1_pivot_tilt.cjs b/resources/tester/helper_355_1_pivot_tilt.cjs new file mode 100644 index 000000000..9afd9741f --- /dev/null +++ b/resources/tester/helper_355_1_pivot_tilt.cjs @@ -0,0 +1,150 @@ +/* 355-1 종단 ▲▼ 이웃 꺾임 축 돌림 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · + * `_Straighten.ts` 를 그 자리에서 트랜스파일해 세 경우를 Node 로 돌린다. + * ① 처음 계획선 ▲ = 옛 값 ② 직선화 뒤 꺾임 ▲ = 사이 측점 한 직선 · 축 표고 그대로 · +0.1 + * ③ 직선 안쪽 ▲ = 옛 값. 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로. + */ +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 LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); + +// 전체 측점 폴리라인(20m 간격 · 0~200m) — 서버와 같이 꺾임마다 기본 L 라운드. +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 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 })), +}; + +/** 355-1 앞 adjustStation — 누른 측점 하나만 옮김. */ +function oldAdjust(edits, chainageM, delta) { + const key = align.chainageKey(chainageM); + const current = align.buildAlignment(base, edits); + const target = align.planElevationAt(current, chainageM) + delta; + const baseElevation = straight.baseElevationAt(base, chainageM); + let offset = + (edits.station_offsets[key] ?? align.planElevationAt(current, chainageM) - baseElevation) + + delta; + const withOffset = (value) => ({ + ...edits, + station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, + }); + let next = withOffset(offset); + for (let pass = 0; pass < 4; pass += 1) { + const error = target - align.planElevationAt(align.buildAlignment(base, next), chainageM); + if (Math.abs(error) < 1e-4) break; + offset += error; + next = withOffset(offset); + } + return next; +} + +const sorted = (record) => + Object.fromEntries( + Object.keys(record ?? {}) + .sort() + .map((key) => [key, record[key]]), + ); +const pviZ = (built, s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; +const out = {}; + +// ① 처음 계획선 — 마디마다 꺾임 → 축 = 누른 점 ±1 · 옛 값과 같음(▲ · ▼). +{ + const empty = align.emptyEdits(); + const up = align.adjustStation(base, empty, 100, 0.1); + const down = align.adjustStation(base, empty, 60, -0.1); + out.initial = { + sameUp: + JSON.stringify(sorted(up.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 100, 0.1).station_offsets)), + sameDown: + JSON.stringify(sorted(down.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 60, -0.1).station_offsets)), + upKeys: Object.keys(up.station_offsets), + radiiKeys: Object.keys(up.curve_radii ?? {}), + }; +} + +// ② 40~100 · 100~160 직선화 → 100 = 꺾임 · 두 축 40 · 160 · ▲ 100. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const before = align.buildAlignment(base, edits); + const next = align.adjustStation(base, edits, 100, 0.1); + const after = align.buildAlignment(base, next); + const p = pviZ(after, 100); + const line = (s, aM, aZ) => aZ + ((p - aZ) * (s - aM)) / (100 - aM); + const z40 = pviZ(before, 40); + const z160 = pviZ(before, 160); + out.kink = { + planRise: align.planElevationAt(after, 100) - align.planElevationAt(before, 100), + pivotRise: p - pviZ(before, 100), + axisMove: [pviZ(after, 40) - z40, pviZ(after, 160) - z160], + outsideMove: [20, 180].map((s) => pviZ(after, s) - pviZ(before, s)), + innerResidual: [60, 80] + .map((s) => pviZ(after, s) - line(s, 40, z40)) + .concat([120, 140].map((s) => pviZ(after, s) - line(s, 160, z160))), + innerMoved: [60, 80, 120, 140].map((s) => pviZ(after, s) - pviZ(before, s)), + curvesAt: after.curves.map((c) => c.chainage_m).sort((a, b) => a - b), + oldWouldBreak: (() => { + const old = align.buildAlignment(base, oldAdjust(edits, 100, 0.1)); + return [80, 120].map((s) => pviZ(old, s) - pviZ(before, s)); + })(), + }; +} + +// ③ 직선 안쪽(80 · 40~100 직선 위) — 꺾임 아님 → 옛 값. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const next = align.adjustStation(base, edits, 80, 0.1); + out.inside = { + same: + JSON.stringify(sorted(next.station_offsets)) === + JSON.stringify(sorted(oldAdjust(edits, 80, 0.1).station_offsets)), + planRise: + align.planElevationAt(align.buildAlignment(base, next), 80) - + align.planElevationAt(align.buildAlignment(base, edits), 80), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py index b75c20104..1f2fc4833 100644 --- a/resources/tester/test_213_1_hidden_station_pvi.py +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -15,6 +15,7 @@ from __future__ import annotations import copy import json +import re import shutil import subprocess import sys @@ -136,6 +137,9 @@ def ts_alignment(tmp_path_factory): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") data = _synthetic() base = { "policy": AlignmentPolicy.from_dict({}).as_dict(), diff --git a/resources/tester/test_213_2_ghost_edit_drop.py b/resources/tester/test_213_2_ghost_edit_drop.py index 2e84ee6e5..203185130 100644 --- a/resources/tester/test_213_2_ghost_edit_drop.py +++ b/resources/tester/test_213_2_ghost_edit_drop.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path @@ -163,6 +164,9 @@ def test_followed_station_ts(tmp_path): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") src, dst = tmp_path / "in.json", tmp_path / "out.json" src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8") subprocess.run( # noqa: S603 diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py new file mode 100644 index 000000000..eb24bcbf5 --- /dev/null +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""355-1 B05 종단 ▲▼ — 누른 점이 꺾임이면 이웃 꺾임을 축으로 사이 직선이 따라 돎(요구 235-15). + +계산(`B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts` adjustStation · +`_Pivot.ts` pivotAxes)은 Node 헬퍼(`helper_355_1_pivot_tilt.cjs`)가 트랜스파일해 돌리고, +부르는 곳 셋(▲▼ 버튼 · 표 값 입력 · 방향키)이 같은 함수인지는 소스로 본다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_355_1_pivot_tilt.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_계획선은_옛_값(out): + initial = out["initial"] + assert initial["sameUp"] and initial["sameDown"] + assert initial["upKeys"] == ["100.000"] + assert initial["radiiKeys"] == [] + + +def test_꺾임_누르면_사이_측점이_한_직선(out): + kink = out["kink"] + assert kink["planRise"] == pytest.approx(0.1, abs=1e-4) + assert kink["axisMove"] == [0, 0] + assert kink["outsideMove"] == [0, 0] + assert all(abs(value) < 1e-6 for value in kink["innerResidual"]) + # 축에서 멀수록 더 오름(40 → 60 → 80 → 100 · 160 → 140 → 120 → 100) + moved = kink["innerMoved"] + assert 0 < moved[0] < moved[1] < kink["pivotRise"] + assert 0 < moved[3] < moved[2] < kink["pivotRise"] + # 옛 길은 사이 측점이 그대로라 P±1 에서 꺾였음 + assert kink["oldWouldBreak"] == [0, 0] + + +def test_직선_안쪽은_옛_값(out): + assert out["inside"]["same"] + assert out["inside"]["planRise"] == pytest.approx(0.1, abs=1e-4) + + +def test_부르는_곳_셋이_같은_함수(): + assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + "B05_Profile/B05_Profile_UI_Profile_Render.ts" + ) + assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" + ) + assert "pivotAxes(current, chainageM)" in _read( + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" + )