fix(455 433-13① 종단 ▲▼ 자동 R): 누른 꼭짓점 R 이 저장 자동 R 과 같으면 지워 기본 L · 사용자 R 은 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:32:43 +09:00
co-authored by Claude Opus 5.5
parent 19bca9287b
commit 9c76d32c1e
10 changed files with 150 additions and 6 deletions
+1
View File
@@ -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,
@@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): {
export interface ProfileEditStore {
edits(): AlignmentEdits;
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
savedRadii(): Readonly<Record<string, number>>;
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;
@@ -47,6 +47,8 @@ export interface PanelToolsContext {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
/** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */
savedRadii: () => Readonly<Record<string, number>>;
/** 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)
+4 -2
View File
@@ -79,6 +79,7 @@ export interface ProfileRenderContext {
};
store: {
edits: () => AlignmentEdits;
savedRadii: () => Readonly<Record<string, number>>;
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()));
},
}),
);
@@ -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<Record<string, number>> = {},
): 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 =
@@ -89,6 +89,7 @@ tools.createPanelTools({
base: () => ({}),
alignment: () => null,
edits: () => ({}),
savedRadii: () => ({}),
applyEdits: () => undefined,
selectedStationId: () => "irregular:p1",
irregularStations: () => [pipe],
@@ -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),
}),
);
+3 -1
View File
@@ -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]:
+3 -2
View File
@@ -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(
@@ -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")