feat(B05 평면): 366-3 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에 — 새 logic StationFit(고친 자리 앞 곡선부터 · 고정 아닌 IP 를 들어오는 접선 따라 ±간격/2 · 하한 새로 깸 · 앞 MC 흔듦 = 버림) · 놓을 때 · applyEdit · 켤 때 전체 한 걸음 · 되돌리기 · 추천 적용 · 초안 되살림은 안 맞춤 · 상태줄 「측점 밖 곡선 N개」 · 단추 title 새 규칙 · Node 시험

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-17 11:55:50 +09:00
co-authored by Claude Opus 5.5
parent 6e16817611
commit 141bf31731
7 changed files with 388 additions and 7 deletions
+12 -4
View File
@@ -91,6 +91,7 @@ import {
import type { DesignPageHost } from "../A00_Common/design/design_page";
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
@@ -159,7 +160,7 @@ export async function mountRouteEditor(
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
let stationSnap = false;
let savedStationSnap = false;
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
@@ -265,6 +266,7 @@ export async function mountRouteEditor(
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
stationSnapButton.addEventListener("click", () => {
setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
draw();
});
@@ -415,6 +417,7 @@ export async function mountRouteEditor(
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
@@ -445,8 +448,12 @@ export async function mountRouteEditor(
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
function applyEdit(message: string, record = true, fitting = true): void {
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM };
const from = fit.fitStart(history?.current?.(), snapshotNow());
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
@@ -476,7 +483,7 @@ export async function mountRouteEditor(
const locks = record ? routeLocksOf(planned, apexLock) : null;
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
if (locks) apexLock = lockColumn(planned, locks);
applyEdit(message, record);
applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3)
}
const historyControls = bindHistoryControls({
@@ -532,6 +539,7 @@ export async function mountRouteEditor(
curveHint,
contourHint,
commit: () => {
if (stationSnap) return applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 놓을 때(366-3)
history?.commit(snapshotNow());
historyControls.sync();
},
@@ -85,7 +85,7 @@ export function createRouteEditChrome(
<i class="b05-route__view-separator" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
aria-pressed="false"
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
<label class="b05-routeedit__terrain" hidden
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
<select class="ui-select ui-select--sm" data-act="terrain">
@@ -0,0 +1,171 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
* 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
*
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
* ========================================================================== */
import {
buildEditedPolyline,
type EditedCurve,
type EditedPolyline,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
shortfallCrossed,
type NodeColumns,
} from "./B05_Profile_UI_RouteEdit_Edits";
import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame";
/** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */
export const MC_TOL_M = 0.05;
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
export interface FitLimits {
minRadiusM: number;
limitRadiusM: number;
limitArcM: number;
intervalM: number;
}
type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curveArc" | "apexLock">;
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline {
const radius = [...cols.curveRadius];
applyArcLocks(planned, cols.curveLock, cols.curveArc, radius);
applyCurveLimits(planned, cols.curveOn, radius, limits.limitRadiusM, limits.limitArcM);
return buildEditedPolyline(planned, cols.curveOn, radius, limits.minRadiusM);
}
/** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */
function mcOf(built: EditedPolyline, k: number): number | null {
const curve = built.curves.find((item) => item.node_first === k);
const span = curve ? curveSpans(built.vertices, [curve])[0] : undefined;
return span ? (span[0] + span[1]) / 2 : null;
}
/** 측점 밖 곡선 수 — MC 가 일반측점(No.1 이상) 위가 아닌 곡선 · 한 측점에 둘이면 하나만 위. */
export function offStationCount(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number {
if (!(intervalM > 0)) return 0;
const spans = curveSpans(line, curves);
const hits = new Set<number>();
for (const [start, end] of spans) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
if (n >= 1 && Math.abs(mc - n * intervalM) <= MC_TOL_M) hits.add(n);
}
return spans.length - hits.size;
}
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
export function fitStart(
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
now: Columns & Pick<NodeColumns, "planned">,
): number {
if (!before) return 1;
const keys = ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"] as const;
const at = now.planned.findIndex(
(vertex, i) =>
vertex[0] !== before.planned[i]?.[0] ||
vertex[1] !== before.planned[i]?.[1] ||
keys.some((key) => now[key][i] !== before[key][i]),
);
const changed =
at < 0 && now.planned.length !== before.planned.length ? now.planned.length - 1 : at;
return Math.max(1, changed - 1);
}
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
function solve(
cols: Columns,
limits: FitLimits,
planned: Vertex[],
k: number,
target: number,
mc: number,
): { planned: Vertex[]; built: EditedPolyline; mc: number } | null {
const base = planned[k];
const before = planned[k - 1];
const length = Math.hypot(base[0] - before[0], base[1] - before[1]);
if (!(length > 1e-9)) return null;
const ux = (base[0] - before[0]) / length;
const uy = (base[1] - before[1]) / length;
const reach = limits.intervalM / 2;
const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
const at = (d: number) => {
const next = planned.slice();
next[k] = [round4(base[0] + ux * d), round4(base[1] + uy * d)];
const built = build(cols, next, limits);
const found = mcOf(built, k);
return found === null ? null : { planned: next, built, mc: found };
};
let d0 = 0;
let f0 = mc - target;
let d1 = clamp(-f0);
for (let step = 0; step < 10; step += 1) {
const tried = at(d1);
if (!tried || d1 === d0) return null;
const f1 = tried.mc - target;
if (Math.abs(f1) <= MC_TOL_M / 5) return tried;
const slope = (f1 - f0) / (d1 - d0);
if (Math.abs(slope) < 0.2) return null;
d0 = d1;
f0 = f1;
d1 = clamp(d1 - f1 / slope);
}
return null;
}
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */
export function fitCurveStations(
cols: Columns & Pick<NodeColumns, "planned">,
limits: FitLimits,
from: number,
): Vertex[] {
let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
const interval = limits.intervalM;
if (!(interval > 0) || planned.length < 3) return planned;
let built = build(cols, planned, limits);
const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
let previous = -1; // 앞 곡선 꺾임점
for (let k = 1; k < planned.length - 1; k += 1) {
const mc = mcOf(built, k);
if (mc === null) continue;
const near = Math.max(lastStation + 1, Math.round(mc / interval));
const onStation = Math.abs(mc - near * interval) <= MC_TOL_M;
const fitted =
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
? null
: solve(cols, limits, planned, k, near * interval, mc);
const keep =
fitted &&
!shortfallCrossed(
floor,
curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
) &&
(previous < 0 ||
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
MC_TOL_M / 5);
if (fitted && keep) ({ planned, built } = fitted);
if (onStation || keep) lastStation = near;
previous = k;
}
return planned;
}
@@ -168,6 +168,8 @@ export interface CurveSummaryInput {
minRadiusM: number;
/** 아직 한 번도 안 그렸나(막 열었을 때). */
fresh: boolean;
/** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
offStation?: number;
}
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
@@ -192,6 +194,7 @@ export function curveSummary(input: CurveSummaryInput): string {
return (
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
`${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
`${edits ? ` · ${edits}` : ""}`
);
}
@@ -0,0 +1,114 @@
/* 366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts` 를
* Node 로 돌려 JSON 한 줄을 냄(`test_366_3_station_fit.py` 가 판정).
* `.cjs` = 루트 package.json 이 "type": "module" 이라서.
*/
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) =>
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 LOGIC = path.join(ROOT, "B05_Profile", "logic");
const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
const curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts"));
const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
const INTERVAL = 20;
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
// 지그재그 노선 — 꺾임 다섯(교각 다양) · 직선 넉넉
const ROUTE = [
[0, 0],
[93, 7],
[171, 61],
[262, 48],
[337, 112],
[431, 101],
[503, 160],
];
function columns(planned, lock = []) {
const n = planned.length;
return {
planned,
curveOn: planned.map(() => true),
curveRadius: planned.map(() => null),
curveLock: planned.map(() => null),
curveArc: planned.map(() => null),
apexLock: planned.map((_, i) => i === 0 || i === n - 1 || lock.includes(i)),
};
}
function measure(planned) {
const built = curve.buildEditedPolyline(
planned,
planned.map(() => true),
planned.map(() => null),
LIMITS.minRadiusM,
);
const spans = frame.curveSpans(built.vertices, built.curves);
const mcs = spans.map(([a, b]) => (a + b) / 2);
const total = frame.stationsAlong(built.vertices, INTERVAL, false);
const length = built.vertices.reduce(
(sum, v, i) =>
i ? sum + Math.hypot(v[0] - built.vertices[i - 1][0], v[1] - built.vertices[i - 1][1]) : 0,
0,
);
return {
curves: built.curves.length,
mcs,
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
innerStations: total.filter((s) => s > 0 && s < length).length,
off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
};
}
const moved = (a, b) => a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1]));
const before = measure(ROUTE);
const all = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
const locked = fit.fitCurveStations(columns(ROUTE, [3]), LIMITS, 1);
const fromLater = fit.fitCurveStations(columns(ROUTE), LIMITS, 4);
const everyLocked = fit.fitCurveStations(columns(ROUTE, [1, 2, 3, 4, 5]), LIMITS, 1);
console.log(
JSON.stringify({
before,
all: { ...measure(all), moved: moved(ROUTE, all) },
locked: { ...measure(locked), moved: moved(ROUTE, locked) },
fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
start: {
moved4: fit.fitStart(
columns(ROUTE),
columns(ROUTE.map((v, i) => (i === 4 ? [v[0] + 1, v[1]] : v))),
),
radius2: fit.fitStart(columns(ROUTE), {
...columns(ROUTE),
curveRadius: ROUTE.map((_, i) => (i === 2 ? 40 : null)),
}),
dropLast: fit.fitStart(columns(ROUTE), columns(ROUTE.filter((_, i) => i !== 5))),
same: fit.fitStart(columns(ROUTE), columns(ROUTE)),
none: fit.fitStart(null, columns(ROUTE)),
},
}),
);
@@ -46,9 +46,9 @@ def test_적용은_본문_평면에_되돌리기_한_칸():
assert "apply: (snapshot, message) => restoreSnapshot(snapshot, message, true)," in main
restore = main[main.index("function restoreSnapshot(") :]
restore = restore[: restore.index("\n }\n")]
assert "applyEdit(message, record);" in restore
assert "applyEdit(message, record, false);" in restore # 366-3 — 추천 적용은 측점 맞춤 안 함
apply_edit = main[main.index("function applyEdit(") :]
assert "if (record) history?.commit(snapshotNow());" in apply_edit[:600]
assert "if (record) history?.commit(snapshotNow());" in apply_edit[:900]
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
on_apply = initial[initial.index("onApply: (picked) => {") :]
assert on_apply[:500].count("params.apply(") == 1
@@ -0,0 +1,85 @@
"""366-3 측점 스냅 = 곡선 가운데(MC)를 일반측점에 — `logic/B05_Profile_RouteEdit_StationFit.ts`
를 Node(`helper_366_3_station_fit.cjs`)로 돌려 판정 + 편집기 이음(놓을 때 · applyEdit · 켤 때 ·
되돌리기 · 추천 적용은 안 맞춤 · 상태줄 「측점 밖 곡선 N개」).
./venv/Scripts/python.exe -m pytest resources/tester/test_366_3_station_fit.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_366_3_station_fit.cjs")
TOL = 0.05
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_맞추기_전_곡선은_측점_밖(got) -> None:
assert got["before"]["off"] == got["before"]["curves"] == 5
def test_MC_는_일반측점_위(got) -> None:
fit = got["all"]
assert fit["curves"] == 5 and fit["off"] == 0
assert max(fit["residues"]) <= TOL
stations = [round(mc / 20) for mc in fit["mcs"]]
assert all(n >= 1 for n in stations) and len(set(stations)) == len(stations)
def test_곡선_수는_측점_수_이하(got) -> None:
assert got["all"]["curves"] <= got["all"]["innerStations"]
def test_시점_종점_고정_IP_그대로_밀림은_간격_절반_안(got) -> None:
for case in ("all", "locked", "fromLater"):
moved = got[case]["moved"]
assert moved[0] == 0 and moved[-1] == 0
assert max(moved) <= 10 + 1e-6
assert got["locked"]["moved"][3] == 0
def test_못_놓은_곡선은_그대로_개수(got) -> None:
assert got["locked"]["off"] == 1 # 고정 IP 곡선 하나만 밖 · 나머지는 맞춤
assert got["everyLocked"]["off"] == 5 and max(got["everyLocked"]["moved"]) == 0
def test_고친_자리부터만(got) -> None:
moved = got["fromLater"]["moved"]
assert moved[1:4] == [0, 0, 0] and moved[4] > 0
assert got["fromLater"]["residues"][3:] == pytest.approx([0, 0], abs=TOL)
def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -> None:
assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
def test_시작_자리는_고친_꺾임점_앞(got) -> None:
start = got["start"]
assert start["moved4"] == 3 and start["radius2"] == 1
assert start["dropLast"] == 4 # 지운 마지막 꺾임점 → 새 끝 앞 곡선부터
assert start["same"] == 1 and start["none"] == 1
def test_편집기_이음() -> None:
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
assert "fitCurveStations(" in edit
# 되돌리기 · 추천 [적용] · 초안 되살림이 지나는 restoreSnapshot 은 안 맞춤
at = edit.index("function restoreSnapshot(")
assert "applyEdit(message, record, false);" in edit[at : at + 700]
assert len(edit.splitlines()) <= 700
edits = (B05 / "logic" / "B05_Profile_UI_RouteEdit_Edits.ts").read_text("utf-8")
assert "측점 밖 곡선 " in edits
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8")
assert "곡선 가운데(MC)를 가까운 일반측점에" in chrome