Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-18 16:18:12 +09:00
36 changed files with 1217 additions and 215 deletions
+2
View File
@@ -71,6 +71,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard";
import { listedBase } from "./design_profile_base";
import { tidyEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
import * as pagePipeline from "./design_pipeline_page";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
@@ -303,6 +304,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
function applyEdits(next: AlignmentEdits): boolean {
if (!base || !alignment) return false;
next = tidyEdits(base, next); // 직선 위 R · 0 오프셋 지움 · 모양 그대로(426)
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
+9 -7
View File
@@ -13,9 +13,10 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat";
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · 427) ·
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
* 늘 저장분에서 셈. */
export function listedBase(
storedBase: AlignmentBase | null,
@@ -35,14 +36,15 @@ export function listedBase(
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
const moved = followMovedNodes(
storedBase.basePvi,
gone,
born,
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
);
return {
...storedBase,
basePvi: followMovedNodes(
storedBase,
gone,
born,
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
),
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
@@ -27,7 +27,9 @@ import {
import {
baseElevationAt,
defaultCurveLength,
pinEnds,
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight";
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
const SAME_M = 1e-6;
@@ -131,7 +133,8 @@ export function buildFreeEdits(
const toM = path[path.length - 1].chainage_m;
if (toM - fromM <= SAME_M) return edits;
const inner = new Map<string, number>();
buildAlignment(base, edits).pvi.forEach((node) => {
const current = buildAlignment(base, edits);
current.pvi.forEach((node) => {
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
inner.set(chainageKey(node.chainage_m), node.chainage_m);
}
@@ -141,6 +144,10 @@ export function buildFreeEdits(
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
const offsets = { ...edits.station_offsets };
const radii = { ...edits.curve_radii };
// 끝이 꼭짓점 아닌 측점이면 지금 높이로 꼭짓점(426) — 직선화와 같음
pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => {
offsets[key] = Number(value.toFixed(6));
});
inner.forEach((chainage, key) => {
const target = elevationOnPath(path, chainage);
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
@@ -150,7 +157,7 @@ export function buildFreeEdits(
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
const deltaGrade = Math.abs(gradeOut - gradeIn);
if (deltaGrade < 1e-9) continue;
if (isStraightNode(path[index - 1], path[index], path[index + 1])) continue;
radii[chainageKey(path[index].chainage_m)] = Number(
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
);
+3
View File
@@ -19,6 +19,7 @@ import { readState, writeState } from "../A00_Common/b_page_state";
import { heightAt } from "../common_util/common_util_surface_sampler";
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
export type AnalysisTerrain = "las" | "sheet";
@@ -121,6 +122,8 @@ export interface RoutePlanResponse {
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 노드마다 넣은 편집값(430) — 넣은 R · 고정 · 고정 길이 · 켬끔 · 노드 수와 다르거나 옛 노선이면 null. */
node_edits?: RouteNodeEdit[] | null;
}
export interface RouteReplanResponse {
@@ -13,6 +13,9 @@
수치 최적화(직선 분할 DP·표고 결정)는 [[B05_Profile_Engine_Grade_Solver]]가,
기준 해석과 오케스트레이션은 [[B05_Profile_Engine_Grade]]가 담당한다. 이 모듈은
scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다.
직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts`
(거울 시험 `test_426_profile_straight.py`).
"""
from dataclasses import dataclass
@@ -27,6 +30,17 @@ ALIGNMENT_SCHEMA_VERSION = 1
_KEY_DECIMALS = 3
# 직선으로 볼 꼭짓점 높이 어긋남(m) — TS `STRAIGHT_HEIGHT_M` 과 같은 값(426).
STRAIGHT_HEIGHT_M = 1e-5
def is_straight_bend(delta: float, span_left: float, span_right: float) -> bool:
"""꼭짓점이 이웃 둘 잇는 선에서 `STRAIGHT_HEIGHT_M` 안이면 직선 — 높이 = |Δ|·a·b/(a+b)."""
if not (span_left > 0 and span_right > 0):
return True
return abs(delta) * span_left * span_right / (span_left + span_right) < STRAIGHT_HEIGHT_M
def chainage_key(value: float) -> str:
"""chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙)."""
return f"{float(value):.{_KEY_DECIMALS}f}"
@@ -303,8 +317,8 @@ def build_curves(
delta = grade_out - grade_in
chainage = float(pvi_s[index])
key = chainage_key(chainage)
if abs(delta) < 1e-9:
continue
if is_straight_bend(delta, float(spans[index - 1]), float(spans[index])):
continue # 직선 판정 한 벌(426)
if only_explicit and overrides.get(key) is None:
continue
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
@@ -0,0 +1,48 @@
"""노드마다 넣은 편집값(430) — 곡선 켬끔 · 넣은 R · 고정(R | 길이) · 고정 길이 → 곡선표 `edits`.
⚠ 짝: `logic/B05_Profile_UI_RouteEdit_Edits.ts` `flattenServerPlan`(`RouteNodeEdit`)이 읽음 ·
거울 시험 `resources/tester/test_430_route_edit_rules.py`.
곡선표 `curves` 는 **그린** R(접선 자리에 눌린 값)만 있어, 그것으로 다시 열면 눌린 R 이
넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐 — 넣은 값을 따로 둠. 자리 = 저장 노드
(앞 노드에서 0.5 m 안인 점은 `build_planned_polyline` 과 같이 거름).
"""
from __future__ import annotations
import math
from typing import Any
from common_util.common_util_route_polyline import DUPLICATE_TOLERANCE_M
_LOCKS = ("radius", "arc")
def node_edits(
points: list[tuple[float, float]],
flags: list[bool] | None,
radii: list[float | None] | None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> list[dict[str, Any]]:
"""점마다의 편집값 — 겹친 점은 빼고 저장 노드와 같은 자리로. 칸이 짧으면 기본값(켬 · 자동)."""
def at(values: list | None, index: int, default: Any = None) -> Any:
return values[index] if values is not None and index < len(values) else default
kept: list[tuple[float, float]] = []
edits: list[dict[str, Any]] = []
for index, point in enumerate(points):
if kept and math.dist(kept[-1], point) <= DUPLICATE_TOLERANCE_M:
continue
kept.append(point)
lock = at(locks, index)
edits.append(
{
"curve": bool(at(flags, index, True)),
"radius_m": at(radii, index),
"lock": lock if lock in _LOCKS else None,
"arc_m": at(arcs, index),
}
)
return edits
+2 -2
View File
@@ -219,8 +219,8 @@ def _seat_born_nodes(
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). TS 짝 = `followMovedNodes`
(common_util_structure_station.ts) · 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). TS 짝 = `seatBornNodes`
(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = `test_427_1_follow_seat.py`.
"""
samples = stored.get("samples") or []
if not base or len(samples) < 2:
+23 -3
View File
@@ -42,6 +42,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
@@ -211,8 +212,8 @@ def _curves_path(path: Path) -> Path:
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path) -> list[dict]:
"""저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트)."""
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
target = _curves_path(path)
if not target.is_file():
return []
@@ -221,7 +222,7 @@ def _read_curves(path: Path) -> list[dict]:
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
curves = data.get(key) if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
@@ -235,11 +236,14 @@ def _write_planned_polyline(
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
@@ -262,6 +266,8 @@ def _write_planned_polyline(
table["station_snap"] = bool(station_snap)
if station_snap and station_snap_step is not None:
table["station_snap_step"] = float(station_snap_step)
if curve_flags is not None or radii is not None:
table["edits"] = node_edits(points, curve_flags, radii, locks, arcs)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
@@ -507,6 +513,13 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다.
simplify=not saved_nodes,
)
saved_edits = await asyncio.to_thread(
_read_curves,
planned_route_working_path(project_root)
if working
else planned_route_initial_path(project_root),
"edits",
)
return {
"status": "success",
"project_id": str(project_id),
@@ -527,6 +540,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
# 넣은 R · 고정 · 고정 길이(430) — 저장 노드와 자리가 같을 때만(옛 곡선표 = null)
"node_edits": saved_edits
if saved_nodes and len(saved_edits) == len(outline.nodes)
else None,
}
@@ -561,6 +578,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
curve_flags = [bool(vertex.curve) for vertex in request.vertices]
radii = [vertex.radius_m for vertex in request.vertices]
draft = getattr(body, "draft", None) # R · 길이 고정(430) — 초안에만 있음
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
@@ -576,6 +594,8 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
radii=radii,
station_snap=request.station_snap,
station_snap_step=request.station_snap_step,
locks=list(draft.curveLock) if draft else None,
arcs=list(draft.curveArc) if draft else None,
),
working_path,
nodes,
@@ -16,6 +16,7 @@
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
import { bentEditCount } from "./logic/B05_Profile_Profile_Straight";
export interface BalanceBarParams {
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
@@ -99,7 +100,7 @@ export function renderBalanceBar(params: BalanceBarParams): void {
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
const editedCount = Object.keys(alignment.edits.station_offsets).length;
const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426)
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
entries.forEach(([label, value, tone]) => {
const item = document.createElement("span");
+34 -34
View File
@@ -39,11 +39,7 @@ import {
type AnalysisTerrain,
} from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
type EditedCurve,
type EditedNode,
} from "./logic/B05_Profile_UI_RouteEdit_Curve";
import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
@@ -69,14 +65,7 @@ import {
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
import {
applyArcLocks,
applyCurveLimits,
curveSummary,
flattenServerPlan,
spliceNode,
type CurveLock,
} from "./logic/B05_Profile_UI_RouteEdit_Edits";
import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
bindHistoryControls,
createRouteEditHistory,
@@ -147,7 +136,7 @@ export async function mountRouteEditor(
let curveRadius: Array<number | null> = [];
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
let curveLock: CurveLock[] = [];
let curveLock: edits.CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점
* (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다.
@@ -161,6 +150,7 @@ export async function mountRouteEditor(
const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
if (toggle) setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음
tellDraft(); // 끔 · 간격 바꿈도 계산 줄에 바로(430)
draw();
});
const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo);
@@ -361,12 +351,11 @@ export async function mountRouteEditor(
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
* 서버가 정본으로 다시 내는 것은 그대로다. */
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
// 길이 고정 R → 지정값 하한(R·L · 내각 155° 이상은 R 만) 끌어올림 → 선(셈은 `_Edits` · 0-9 ④).
const built = edits.rebuildLine(
{ planned, curveOn, curveRadius, curveLock, curveArc },
fitLimits(),
);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
@@ -388,7 +377,7 @@ export async function mountRouteEditor(
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
edits.curveSummary({
nodeCount: planned.length,
curveOn,
curveRadius,
@@ -399,6 +388,7 @@ export async function mountRouteEditor(
minRadiusM,
fresh: nodeInfo.length === 0,
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0,
flags: edits.routeFlags(planned, curveOn, curveInfo), // 겹친 노드 · 못 그린 곡선(430)
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
@@ -432,9 +422,14 @@ export async function mountRouteEditor(
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤).
* 하한 검사 한 곳(430) — 지난 걸음이 지키던 하한을 새로 깨면 그 걸음으로 되돌리고 까닭을 상태줄에. */
function applyEdit(message: string, record = true, fitting = true): void {
const from = fit.fitStart(history?.current?.(), snapshotNow());
const before = history?.current?.();
markEdited();
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
if (stop && before) return restoreSnapshot(before, stop, false);
const from = fit.fitStart(before, snapshotNow());
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
@@ -445,6 +440,13 @@ export async function mountRouteEditor(
historyControls.sync();
}
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */
function tellDraft(): void {
if (!history) return;
const dirty = history.isDirty() || pickedTerrain() !== designTerrain || snap.changed();
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
}
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
function fitLimits(): fit.FitLimits {
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
@@ -525,10 +527,10 @@ export async function mountRouteEditor(
routeHead,
curveHint,
contourHint,
commit: () => {
if (stationSnap) return applyEdit(snap.text()); // 놓을 때(366-3)
history?.commit(snapshotNow());
historyControls.sync();
commit: (index) => {
const before = history?.current?.()?.planned ?? []; // 놓을 때 맞춤(366-3) · 하한 · 보고(430)
applyEdit(stationSnap ? snap.text() : "옮겼습니다.");
if (stationSnap && index !== undefined) curveBar.noteFit(snap.text(), before, index);
},
snapStepM: snap,
fitDrag: (index) => {
@@ -545,7 +547,7 @@ export async function mountRouteEditor(
segmentAt: pointer.segmentAt,
nodeAt: pointer.nodeAt,
insert: (segment, px, py) => {
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
edits.spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
},
@@ -553,7 +555,7 @@ export async function mountRouteEditor(
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
if (apexLock[index])
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
spliceNode(snapshotNow(), index);
edits.spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
},
@@ -577,7 +579,7 @@ export async function mountRouteEditor(
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () =>
(history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(),
@@ -611,7 +613,7 @@ export async function mountRouteEditor(
limitArcM = plan.limit_curve_length_m ?? 0;
setStationSnap(snap.load(plan));
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
const flat = edits.flattenServerPlan(nodes, plan.curves ?? [], plan.node_edits);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다.
({ nodes: nodeInfo, curves: curveInfo } = flat);
@@ -666,9 +668,7 @@ export async function mountRouteEditor(
let terrainTicket = 0;
terrainSelect.addEventListener("change", () => {
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const mine = ++terrainTicket;
void loadBackground().then((background) => {
if (closed || mine !== terrainTicket) return;
@@ -39,7 +39,10 @@ import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
type Vertex = [number, number];
type RouteSettings = SnapSetting & { terrain: string | null };
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> } & {
curveLock?: RouteDraft["curveLock"];
curveArc?: Array<number | null>;
};
export interface RouteApplyParams {
/** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */
@@ -72,15 +75,15 @@ interface RouteSource {
const sources = new Map<string, RouteSource>();
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(R · 길이 고정도 실음 — 서버가 곡선표 `edits` 에 둠 · 430). */
export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft {
return {
rev: 0,
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false),
curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null),
curveLock: nodes.planned.map(() => null),
curveArc: nodes.planned.map(() => null),
curveLock: nodes.planned.map((_, index) => nodes.curveLock?.[index] ?? null),
curveArc: nodes.planned.map((_, index) => nodes.curveArc?.[index] ?? null),
stationSnap: settings.stationSnap,
stationSnapStep: settings.stationSnapStep ?? null,
terrain: settings.terrain,
@@ -10,8 +10,10 @@
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
CURVE_OPTIONAL_INNER_ANGLE_DEG,
curveOptionalAt,
deflectionRad,
radiusFloorM,
@@ -63,6 +65,8 @@ export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
/** 끌기 · 손잡이 놓을 때 맞춤 결과를 상태줄 `message` 뒤에(430 · 스냅 끔이면 그대로). */
noteFit: (message: string, before: Vertex[], picked: number) => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
@@ -120,10 +124,17 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
const { picked, curveOn, nodeInfo } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
// 내각 155° 미만 = 곡선을 둬야 하는 자리(별표2 Ⅰ.2.다.(1)) — 막지 않고 경고(430).
const inner = nodeInfo[picked]?.inner_angle_deg ?? null;
const warn =
!on && inner !== null && !curveOptionalAt(inner)
? ` — 내각 ${inner.toFixed(1)}° < ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 곡선 자리(경고)`
: "";
params.applyEdit(on ? "곡선을 넣었습니다." : `곡선을 지웠습니다${warn}.`);
if (warn) showToast(`곡선을 지웠습니다${warn}.`, "warning", 5000);
},
},
params.container,
@@ -133,7 +144,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state();
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
if (!status) return;
const asked = curveRadius[picked];
@@ -144,17 +155,29 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
? message.replace(/\.$/, "")
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
const tail = snap
? fitNote(
before,
planned,
picked,
offStationMcs(line, curveInfo, snap.stepM),
snap.stationM,
snap.stepM,
)
: "";
status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`);
status.textContent = (status.textContent ?? "").replace(
message,
`${head}${fitTail(before, picked, false)}.`,
);
}
/** 맞춤이 민 교점 · 못 맞춘 곡선 꼬리 — 스냅 끔이면 ""(`hand` = 고른 IP 를 손이 옮김). */
function fitTail(before: Vertex[], picked: number, hand: boolean): string {
const { curveInfo, planned, line, snap } = params.state();
if (!snap) return "";
const off = offStationMcs(line, curveInfo, snap.stepM);
return fitNote(before, planned, picked, off, snap.stationM, snap.stepM, hand);
}
/** 끌기 · 손잡이 놓을 때(430) — 상태줄 `message` 뒤에 맞춤 결과(`before` = 누르기 전 교점). */
function noteFit(message: string, before: Vertex[], picked: number): void {
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
const tail = fitTail(before, picked, true);
if (status && tail)
status.textContent = (status.textContent ?? "").replace(
message,
`${message.replace(/\.$/, "")}${tail}.`,
);
}
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
@@ -238,5 +261,5 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
});
}
return { label, sync };
return { label, sync, noteFit };
}
@@ -34,6 +34,7 @@ import {
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import {
curveShortfalls,
floorNote,
radiusFloorM,
shortfallCrossed,
type CurveLock,
@@ -98,8 +99,8 @@ export interface RouteEditPointerParams {
routeHead: () => string;
curveHint: () => string;
contourHint: () => string;
/** 끌기 한 번을 되돌리기에 쌓는다. */
commit: () => void;
/** 끌기 한 번을 되돌리기에 쌓는다 — `index` = 끈 교각점(430 · 놓을 때 맞춤 보고 · 방향키는 안 줌). */
commit: (index?: number) => void;
/** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */
snapStepM: () => number;
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 ·
@@ -308,6 +309,9 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
// 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const kept = [state.planned[node], state.curveRadius[node], state.curveOn[node]] as const;
state.planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
@@ -319,8 +323,21 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.setPicked(node);
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
dragMoved = true;
params.markEdited();
const after = params.state();
if (
shortfallCrossed(
before,
curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM),
)
) {
[state.planned[node], state.curveRadius[node], state.curveOn[node]] = kept;
params.markEdited();
status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`;
params.draw();
return;
}
dragMoved = true;
params.syncCurveBar();
status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`;
params.draw();
@@ -350,9 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
state.planned[dragNode] = previous;
params.markEdited();
fitPreview(state.planned);
status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`;
params.draw();
return;
}
@@ -395,7 +410,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
params.commit();
params.commit(dragHandle?.node ?? dragNode);
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void params.crossPreview.refresh();
@@ -0,0 +1,59 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Seat.ts
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로.
*
* 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes`
* (common_util_structure_station.ts)가 고름 · 거울 시험 = resources/tester/test_427_1_follow_seat.py.
* ========================================================================== */
import {
buildCurves,
evaluateAt,
type AlignmentBase,
type AlignmentNode,
} from "./B05_Profile_UI_Profile_Alignment";
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */
export function seatBornNodes(
base: Pick<AlignmentBase, "chainage" | "ground" | "policy">,
nodes: AlignmentNode[],
born: ReadonlyArray<number>,
): AlignmentNode[] {
if (!nodes.length || base.chainage.length < 2) return nodes;
const first = nodes[0].chainage_m;
const last = nodes[nodes.length - 1].chainage_m;
const fresh = born.filter(
(value) =>
first < value &&
value < last &&
nodes.every((node) => Math.abs(value - node.chainage_m) >= 0.006),
);
if (!fresh.length) return nodes;
const rows = [
...nodes.map((node) => ({
s: node.chainage_m,
z: node.elevation_m,
node: node as AlignmentNode | null,
})),
...fresh.map((s) => ({ s, z: evaluateAt(base.chainage, base.ground, [], s), node: null })),
].sort((a, b) => a.s - b.s);
const baseS = rows.map((row) => row.s);
const target = rows.map((row) => row.z);
let baseZ = [...target];
for (let round = 0; round < 20; round += 1) {
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
const curves = buildCurves(pvi, base.policy, {}, []);
const error = rows.map((row, index) =>
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
);
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
baseZ = baseZ.map((z, index) => z + error[index]);
}
return rows.map(
(row, index) =>
row.node ?? {
chainage_m: Number(row.s.toFixed(6)),
elevation_m: Number(baseZ[index].toFixed(6)),
},
);
}
@@ -0,0 +1,56 @@
/* =============================================================================
* B05_Profile_Profile_Straight.ts
* 종단 「직선인가」 판정 한 벌(426 · 분석 424 6장 · 10장) — 꼭짓점이 이웃 둘을 잇는 선에서
* `STRAIGHT_HEIGHT_M` 안이면 직선(꺾임 아님).
*
* 짝: `B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend` · `STRAIGHT_HEIGHT_M` —
* 거울 시험 `test_426_profile_straight.py`.
*
* 기울기 정확 비교(1e-9 · 1e-6)는 꼭짓점 높이를 소수 여섯째 자리로 끊은 값(±5e-7 m)이 측점
* 간격 1 m 안(구조물 측점)에서 기울기 차로 커져 직선을 꺾임으로 읽었다 — 높이로 잰다.
* 부르는 곳: 곡선 넣기(`buildCurves`) · ▲▼ 축(`pivotAxes`) · 직선 찾기(`detectStraightRun`) ·
* 편집 정리(`tidyEdits`) · 편집 측점 수. 표의 대수차 빈칸은 안전망으로 따로 둔다.
* ========================================================================== */
import type { AlignmentNode, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
/** 직선으로 볼 꼭짓점 높이 어긋남(m) — 파이썬 `STRAIGHT_HEIGHT_M` 과 같은 값. */
export const STRAIGHT_HEIGHT_M = 1e-5;
/** 대수차 · 좌우 마디 길이로 본 직선 — 꼭짓점이 이웃 잇는 선에서 떨어진 높이 = |Δ|·a·b/(a+b). */
export function isStraightBend(delta: number, spanLeft: number, spanRight: number): boolean {
const span = spanLeft + spanRight;
if (!(spanLeft > 0 && spanRight > 0)) return true;
return (Math.abs(delta) * spanLeft * spanRight) / span < STRAIGHT_HEIGHT_M;
}
/** 세 꼭짓점의 가운데가 직선 위인가. */
export function isStraightNode(
prev: AlignmentNode,
node: AlignmentNode,
next: AlignmentNode,
): boolean {
const spanLeft = node.chainage_m - prev.chainage_m;
const spanRight = next.chainage_m - node.chainage_m;
if (!(spanLeft > 0 && spanRight > 0)) return true;
const delta =
(next.elevation_m - node.elevation_m) / spanRight -
(node.elevation_m - prev.elevation_m) / spanLeft;
return isStraightBend(delta, spanLeft, spanRight);
}
/** index 번째 꼭짓점이 직선 위인가 — 시종점은 꺾임으로 본다(false). */
export function straightAt(pvi: ReadonlyArray<AlignmentNode>, index: number): boolean {
if (index <= 0 || index >= pvi.length - 1) return false;
return isStraightNode(pvi[index - 1], pvi[index], pvi[index + 1]);
}
/** 「편집 측점 N개」 — 높이를 고친 꼭짓점 중 실제로 꺾인 것(시종점은 오프셋이 있으면). */
export function bentEditCount(alignment: ProfileAlignment): number {
const last = alignment.pvi.length - 1;
return alignment.pvi.filter((node, index) => {
if (node.source !== "user") return false;
if (index === 0 || index === last) return true;
return !straightAt(alignment.pvi, index);
}).length;
}
@@ -98,7 +98,8 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
}
/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 ·
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」).
* `handMoved` = 끌기 · 손잡이로 고른 IP 를 손이 옮김(430 · 놓을 때) — 고른 IP 는 「밀림」에 안 셈. */
export function fitNote(
before: ReadonlyArray<Vertex>,
after: ReadonlyArray<Vertex>,
@@ -106,10 +107,11 @@ export function fitNote(
offMcs: ReadonlyArray<number>,
stationM: number,
stepM: number,
handMoved = false,
): string {
const start = before[picked] ?? [0, 0];
const report = nudgeReport(before, after, picked, start, start);
const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0);
const pushed = report.pushed + (!handMoved && report.movedM > SAME_M ? 1 : 0);
const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`);
const more = names.length > 3 ? ` 외 ${names.length - 3}` : "";
const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : "";
@@ -14,16 +14,10 @@
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
* ========================================================================== */
import type { EditedCurve, EditedPolyline, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import {
buildEditedPolyline,
type EditedCurve,
type EditedPolyline,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
rebuildLine,
shortfallCrossed,
type NodeColumns,
} from "./B05_Profile_UI_RouteEdit_Edits";
@@ -47,10 +41,7 @@ type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curv
/** 편집 창 `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);
return rebuildLine({ ...cols, planned, curveRadius: [...cols.curveRadius] }, limits);
}
/** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */
@@ -14,6 +14,7 @@
* ========================================================================== */
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
import { isStraightBend } from "./B05_Profile_Profile_Straight";
export interface AlignmentPolicy {
station_interval_m: number;
@@ -305,7 +306,7 @@ export function buildCurves(
const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft;
const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight;
const delta = gradeOut - gradeIn;
if (Math.abs(delta) < 1e-9) continue;
if (isStraightBend(delta, spanLeft, spanRight)) continue; // 직선 판정 한 벌(426)
const chainage = pvi[index].chainage_m;
const key = chainageKey(chainage);
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
@@ -10,9 +10,8 @@
* ========================================================================== */
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { straightAt } from "./B05_Profile_Profile_Straight";
/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */
const COLLINEAR_EPSILON = 1e-6;
const SAME_STATION_M = 1e-6;
export interface PivotAxes {
@@ -26,21 +25,17 @@ export interface PivotAxes {
/** 누른 점의 두 축과 사이 변화점 — 시종점 · 계획선 밖이면 null. */
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
const { pvi, segments } = alignment;
const { pvi } = alignment;
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
// 꼭짓점 아닌 자리 = 그 점을 품은 마디 하나가 좌우 마디
const right = index >= 0 ? index : pvi.findIndex((node) => node.chainage_m > chainageM);
if (right <= 0 || (index >= 0 && index >= pvi.length - 1)) return null;
const [leftSeg, rightSeg] = index >= 0 ? [index - 1, index] : [right - 1, right - 1];
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;
// 이웃끼리 — 꼭짓점이 직선 위(높이 기준 · 426)면 건넘
let from = leftSeg;
while (from > 0 && same(from - 1, leftSeg)) from -= 1;
while (from > 0 && straightAt(pvi, from)) from -= 1;
let to = rightSeg + 1;
while (to < pvi.length - 1 && same(to, rightSeg)) to += 1;
while (to < pvi.length - 1 && straightAt(pvi, to)) to += 1;
return {
fromM: pvi[from].chainage_m,
fromZ: pvi[from].elevation_m,
@@ -26,9 +26,8 @@ import {
chainageKey,
controlElevationAt,
} from "./B05_Profile_UI_Profile_Alignment";
import { isStraightNode, straightAt } from "./B05_Profile_Profile_Straight";
/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */
const COLLINEAR_EPSILON = 1e-6;
/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */
const SAME_STATION_M = 1e-6;
@@ -63,17 +62,10 @@ export function baseElevationAt(base: AlignmentBase, chainageM: number): number
return last.elevation_m;
}
/** 변화점 사이 기울기 (index 번째 구간). */
function gradeAt(alignment: ProfileAlignment, index: number): number {
const segment = alignment.segments[index];
if (!segment || segment.length_m <= 1e-12) return 0;
return segment.height_m / segment.length_m;
}
/**
* 그 자리를 지나는 **최대 직선 구간**을 찾는다.
*
* 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점
* 사이 꼭짓점이 직선 위(높이 기준 · 426)면 한 직선으로 본다. 전체 측점
* 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만
* 두 측점 이상으로 이어진 직선이 된다.
*/
@@ -89,18 +81,10 @@ export function detectStraightRun(
chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M,
);
if (index < 0) return null;
const grade = gradeAt(alignment, index);
let first = index;
let last = index;
while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) {
first -= 1;
}
while (
last < alignment.segments.length - 1 &&
Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON
) {
last += 1;
}
while (first > 0 && straightAt(alignment.pvi, first)) first -= 1;
while (last < alignment.segments.length - 1 && straightAt(alignment.pvi, last + 1)) last += 1;
// 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다.
if (last === first) return null;
const fromM = alignment.segments[first].from_m;
@@ -122,12 +106,7 @@ export function detectStraightRun(
* (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다.
*/
export function hasStraightRuns(alignment: ProfileAlignment): boolean {
for (let index = 1; index < alignment.segments.length; index += 1) {
if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) {
return true;
}
}
return false;
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
@@ -177,7 +156,47 @@ export function straightenBetween(
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates, inner.map(chainageKey));
return withOffsets(
edits,
[...updates, ...pinEnds(base, current, [fromM, toM])],
inner.map(chainageKey),
);
}
/**
* 범위 끝이 꼭짓점 아닌 측점(관 · 구조물 · 덧 · 곡선 측점)이면 지금 높이로 꼭짓점을 세운다(426) —
* 안 세우면 끝 높이가 옮긴 안쪽 점을 따라 바뀌어 직선이 다음 꼭짓점부터 시작했다.
*/
export function pinEnds(
base: AlignmentBase,
current: ProfileAlignment,
ends: number[],
): Array<[string, number]> {
return ends
.filter((at) => !current.pvi.some((node) => Math.abs(node.chainage_m - at) < SAME_STATION_M))
.map((at) => [chainageKey(at), controlElevationAt(current, at) - baseElevationAt(base, at)]);
}
/**
* 편집 정리(426) — 직선 위 꼭짓점의 기억 R 을 지우고, 0 으로 돌아와 모양을 안 바꾸는 오프셋을
* 지운다(자동 꼭짓점 자리 · 또는 직선 위). 모양은 그대로 — 적용 한 곳(`applyEdits`)이 부른다.
*/
export function tidyEdits(base: AlignmentBase, edits: AlignmentEdits): AlignmentEdits {
const current = buildAlignment(base, edits);
const pvi = current.pvi;
const straight = new Set(
pvi
.filter((_node, index) => straightAt(pvi, index))
.map((node) => chainageKey(node.chainage_m)),
);
const auto = new Set(base.basePvi.map((node) => chainageKey(node.chainage_m)));
const offsets = { ...current.edits.station_offsets };
const radii = { ...current.edits.curve_radii };
Object.keys(radii).forEach((key) => straight.has(key) && delete radii[key]);
Object.entries(offsets).forEach(([key, value]) => {
if (Math.abs(value) < 1e-6 && (auto.has(key) || straight.has(key))) delete offsets[key];
});
return { station_offsets: offsets, curve_radii: radii };
}
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
@@ -209,9 +228,8 @@ export function tiltStraightRun(
);
const startZ = controlElevationAt(current, run.fromM);
const endZ = controlElevationAt(current, run.toM);
const span = run.toM - run.fromM;
const pivotZ =
startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta;
// 지금 경첩 높이 + 걸음(426) — 처음 직선 위 높이로 다시 잡으면 ▲ 두 번째가 안 오르고 ▼ 가 두 칸
const pivotZ = controlElevationAt(current, pivot) + delta;
const updates: Array<[string, number]> = [
[chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)],
@@ -236,7 +254,16 @@ export function tiltStraightRun(
updates,
inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey),
);
if (deltaGrade < 1e-9) return next;
const straightPivot = isStraightNode(
{ chainage_m: run.fromM, elevation_m: startZ },
{ chainage_m: pivot, elevation_m: pivotZ },
{ chainage_m: run.toM, elevation_m: endZ },
);
if (straightPivot) {
const curveRadii = { ...next.curve_radii };
delete curveRadii[chainageKey(pivot)];
return { ...next, curve_radii: curveRadii };
}
return {
...next,
curve_radii: {
@@ -42,7 +42,7 @@ export interface EditedCurve {
}
/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */
const DUPLICATE_TOLERANCE_M = 0.5;
export const DUPLICATE_TOLERANCE_M = 0.5;
/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */
const ARC_STEP_DEG = 5.0;
/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면
@@ -18,12 +18,18 @@
* 다만 **내각 155° 이상은 뺀다**(별표2 Ⅰ.2.다.(1) 「내각 155도 이상 되는 장소는 곡선을
* 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로
* 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m).
*
* ⚠ 짝(430) — `flattenServerPlan` 이 읽는 넣은 값 `edits` ↔ `B05_Profile_Route_NodeEdits.py`(쓰기) ·
* 거울 시험 `test_430_route_edit_rules.py`.
* ========================================================================== */
import {
buildEditedPolyline,
DUPLICATE_TOLERANCE_M,
innerAngleDeg,
type EditedCurve,
type EditedNode,
type EditedPolyline,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
@@ -154,6 +160,97 @@ export function shortfallCrossed(before: readonly number[], after: readonly numb
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
/** 선을 짜는 값 — 기본 반지름 · 못 넘는 하한(R · L). */
export interface CurveLimits {
minRadiusM: number;
limitRadiusM: number;
limitArcM: number;
}
/** 편집 창 `markEdited` 의 셈 — 길이를 붙든 자리 R 다시 잡기 → 지정값 하한까지 끌어올림(`curveRadius`
* 를 고침) → 선. 교각이 바뀌었을 수 있어 R 부터 잡고 그린다. */
export function rebuildLine(
cols: Omit<NodeColumns, "apexLock">,
limits: CurveLimits,
): EditedPolyline {
applyArcLocks(cols.planned, cols.curveLock, cols.curveArc, cols.curveRadius);
applyCurveLimits(
cols.planned,
cols.curveOn,
cols.curveRadius,
limits.limitRadiusM,
limits.limitArcM,
);
return buildEditedPolyline(cols.planned, cols.curveOn, cols.curveRadius, limits.minRadiusM);
}
/** 하한 글 꼬리 — 「(곡선반지름 12m · 곡선 길이 20m)」. */
export function floorNote(limitRadiusM: number, limitArcM: number): string {
return `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""})`;
}
/** 자리 번호 셋까지 「IP3 · IP5 외 2」. */
const ipList = (seats: ReadonlyArray<number>): string =>
seats
.slice(0, 3)
.map((seat) => `IP${seat}`)
.join(" · ") + (seats.length > 3 ? ` 외 ${seats.length - 3}` : "");
/**
* 이번 편집이 하한을 **새로** 깼으면 까닭 한 줄 · 아니면 ""(430 · 모든 평면 조작이 `applyEdit` 한 곳에서).
* `before` = 지난 걸음 사진 · `nodes` = 지금 노드 요약(스냅 맞춤 전 — 맞춤은 스스로 하한을 지킴).
* 노드를 하나 끼우거나 뺐으면 처음 달라진 자리에서 줄을 맞춰 견줌(새 노드 = 지키던 자리) · 둘 넘게 다르면 안 견줌.
*/
export function floorStop(
before: NodeColumns,
planned: ReadonlyArray<Vertex>,
nodes: ReadonlyArray<EditedNode>,
limits: CurveLimits,
): string {
const gap = planned.length - before.planned.length;
if (Math.abs(gap) > 1) return "";
const built = rebuildLine({ ...before, curveRadius: [...before.curveRadius] }, limits);
const was = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
const now = curveShortfalls(nodes, limits.limitRadiusM, limits.limitArcM);
const differ = planned.findIndex(
(vertex, i) => vertex[0] !== before.planned[i]?.[0] || vertex[1] !== before.planned[i]?.[1],
);
const at = differ < 0 ? planned.length : differ;
const old = (i: number): number =>
(gap === 0 || i < at ? was[i] : gap > 0 ? (i === at ? 0 : was[i - 1]) : was[i + 1]) ?? 0;
const seats = now.flatMap((value, i) => (value > 1e-6 && old(i) <= 1e-6 ? [i] : []));
if (!seats.length) return "";
return `하한에 걸려 되돌렸습니다 — ${ipList(seats)}${floorNote(limits.limitRadiusM, limits.limitArcM)}.`;
}
/** 상태줄 표시(430) — 겹친 노드(앞 노드에서 0.5 m 안 · 선을 짤 때 버림 · 짝 `DUPLICATE_TOLERANCE_M`) ·
* 못 그린 곡선(켬인데 곡선이 없음 · 곧은 자리 · 겹친 노드는 뺌). */
export function routeFlags(
planned: ReadonlyArray<Vertex>,
curveOn: ReadonlyArray<boolean>,
curves: ReadonlyArray<EditedCurve>,
): { overlap: number[]; undrawn: number[] } {
const overlap: number[] = [];
let last: Vertex | null = null;
planned.forEach((vertex, i) => {
if (last && Math.hypot(vertex[0] - last[0], vertex[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
overlap.push(i);
else last = vertex;
});
const drawn = new Set(curves.map((curve) => curve.node_first));
const undrawn = planned.flatMap((vertex, i) =>
i === 0 ||
i === planned.length - 1 ||
curveOn[i] === false ||
drawn.has(i) ||
overlap.includes(i) ||
!(innerAngleDeg(planned[i - 1], vertex, planned[i + 1]) < 179.9)
? []
: [i],
);
return { overlap, undrawn };
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
@@ -170,6 +267,8 @@ export interface CurveSummaryInput {
fresh: boolean;
/** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
offStation?: number;
/** 겹친 노드 · 못 그린 곡선 자리(430 · `routeFlags`). */
flags?: { overlap: number[]; undrawn: number[] };
}
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
@@ -187,6 +286,7 @@ export function curveSummary(input: CurveSummaryInput): string {
]
.filter(Boolean)
.join(" · ");
const { overlap, undrawn } = input.flags ?? { overlap: [], undrawn: [] };
if (input.fresh) {
const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [저장] 때 반영` : "";
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
@@ -195,6 +295,8 @@ export function curveSummary(input: CurveSummaryInput): string {
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
`${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
`${overlap.length ? ` · 겹친 노드 ${ipList(overlap)}(0.5 m 안 · 선에서 빠짐)` : ""}` +
`${undrawn.length ? ` · 못 그린 곡선 ${ipList(undrawn)}` : ""}` +
`${edits ? ` · ${edits}` : ""}`
);
}
@@ -219,6 +321,15 @@ interface ServerNode {
violations?: string[];
}
/** 노드마다 넣은 편집값(430) — 곡선표 `edits` 칸 · 서버 `node_edits`(⚠ 짝 `B05_Profile_Route_NodeEdits.py`). */
export interface RouteNodeEdit {
curve: boolean;
/** 넣은 R(그린 R 아님 · 접선 자리에 눌렸어도 넣은 값) · null = 자동. */
radius_m: number | null;
lock: CurveLock;
arc_m: number | null;
}
/**
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
*
@@ -231,8 +342,15 @@ interface ServerNode {
* ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안
* 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집
* 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다.
*
* `edits`(430 · 노드 수와 같을 때만 서버가 줌)가 있으면 곡선 켬끔 · R · 고정 · 고정 길이는 **넣은 값**으로 —
* 그린 R 로 열면 접선 자리에 눌린 R 이 넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐.
*/
export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan {
export function flattenServerPlan(
nodes: ServerNode[],
curves: EditedCurve[],
edits?: ReadonlyArray<RouteNodeEdit> | null,
): FlattenedPlan {
const replaced = new Map<number, EditedCurve>();
const dropped = new Set<number>();
curves.forEach((curve) => {
@@ -263,11 +381,14 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
tangent_m: null,
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
});
out.curveOn.push(curve !== undefined);
const edit = edits?.[index];
out.curveOn.push(edit ? edit.curve : curve !== undefined);
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null);
out.curveArc.push(null);
out.curveRadius.push(
edit ? edit.radius_m : curve ? Math.round(curve.radius_m * 100) / 100 : null,
);
out.curveLock.push(edit?.lock ?? null);
out.curveArc.push(edit?.arc_m ?? null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
+11 -62
View File
@@ -28,12 +28,6 @@
* ========================================================================== */
import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog";
import {
buildCurves,
evaluateAt,
type AlignmentBase,
type AlignmentNode,
} from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
const EPS = 1e-6;
@@ -345,19 +339,19 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
}
/**
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지우고 새로 세운다 — 서버
* `_follow_moved_stations` · `_seat_born_nodes`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 ·
* 옛 소수 측점의 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과
* 새 측점이 같은 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 ·
* 나머지 새 측점은 자동 계획선 규칙(원지반 지남 · 다른 꼭짓점 그대로)으로 세움 · 짝 없는 옛 꼭짓점은 지움.
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지우고, 새로 세울 측점을 고른다 —
* 서버 `_follow_moved_stations`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 · 옛 소수 측점의
* 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과 새 측점이 같은
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 새 측점은
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 옛
* 꼭짓점은 지움. 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
*/
export function followMovedNodes(
base: Pick<AlignmentBase, "basePvi" | "chainage" | "ground" | "policy">,
export function followMovedNodes<N extends { chainage_m: number }>(
nodes: ReadonlyArray<N>,
gone: ReadonlyArray<number>,
born: ReadonlyArray<number>,
edited: ReadonlyArray<string> = [],
): AlignmentNode[] {
): { nodes: N[]; seat: number[] } {
const target = (old: number): number | null => {
const near = born.find((value) => Math.abs(value - old) < 0.006);
if (near !== undefined) return near;
@@ -379,56 +373,11 @@ export function followMovedNodes(
if (!edited.some((key) => Math.abs(Number(key) - old) < 0.006)) seat.push(left[index]);
});
}
const kept = base.basePvi.flatMap((node) => {
const kept = nodes.flatMap((node) => {
const old = gone.find((value) => Math.abs(value - node.chainage_m) < 0.006);
if (old === undefined) return [node];
const to = moves.get(old) ?? null;
return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }];
});
return seatBornNodes(base, kept, seat);
}
/** 새 측점에 원지반을 지나는 꼭짓점(서버 `_seat_born_nodes` 짝 · `pass_through` 규칙 · 다른 꼭짓점 그대로). */
function seatBornNodes(
base: Pick<AlignmentBase, "chainage" | "ground" | "policy">,
nodes: AlignmentNode[],
born: ReadonlyArray<number>,
): AlignmentNode[] {
if (!nodes.length || base.chainage.length < 2) return nodes;
const first = nodes[0].chainage_m;
const last = nodes[nodes.length - 1].chainage_m;
const fresh = born.filter(
(value) =>
first < value &&
value < last &&
nodes.every((node) => Math.abs(value - node.chainage_m) >= 0.006),
);
if (!fresh.length) return nodes;
const rows = [
...nodes.map((node) => ({
s: node.chainage_m,
z: node.elevation_m,
node: node as AlignmentNode | null,
})),
...fresh.map((s) => ({ s, z: evaluateAt(base.chainage, base.ground, [], s), node: null })),
].sort((a, b) => a.s - b.s);
const baseS = rows.map((row) => row.s);
const target = rows.map((row) => row.z);
let baseZ = [...target];
for (let round = 0; round < 20; round += 1) {
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
const curves = buildCurves(pvi, base.policy, {}, []);
const error = rows.map((row, index) =>
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
);
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
baseZ = baseZ.map((z, index) => z + error[index]);
}
return rows.map(
(row, index) =>
row.node ?? {
chainage_m: Number(row.s.toFixed(6)),
elevation_m: Number(baseZ[index].toFixed(6)),
},
);
return { nodes: kept, seat };
}
@@ -159,9 +159,13 @@ Promise.resolve(
out.second = { count: snap.snapOldStations(again), toasts: [...toasts] };
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const gone = [
{ id: "pipe-80.17", chainage_m: 80.17 },
@@ -15,8 +15,6 @@ function loadTs(file) {
const box = { exports: {} };
const localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
// 427 — 꼭짓점 세움이 종단 선형(B05 logic)을 부름 · 상대 경로 TS 는 그대로 읽음
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
throw new Error(`stub 없음: ${request} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
@@ -28,7 +26,7 @@ const station = loadTs("common_util/common_util_structure_station.ts");
const out = {
planted: station.plantedChainages(input.pipes, input.structures, input.types),
list: station.oneStationList(input.server, input.fresh),
nodes: station.followMovedNodes(input.base, input.gone, input.born, input.edited),
nodes: station.followMovedNodes(input.base.basePvi, input.gone, input.born, input.edited).nodes,
};
// 368-2 추가 측점 — 화면이 둔 읽는 곳(setPlusStations)으로 심는 자리에 듦
station.setPlusStations(() => input.plus);
@@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) {
return box.exports;
}
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
+5 -1
View File
@@ -21,9 +21,13 @@ function loadTs(file, stubs = {}) {
}
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
@@ -20,6 +20,7 @@ const STUBS = {
},
centerDirectionOf: () => null,
},
"@ui/ui_template_elements": { showToast: () => {} }, // 곡선 삭제 경고(430)
};
function loadTs(file) {
@@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) {
return box.exports;
}
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
"./B05_Profile_Profile_Straight": straightJudge,
});
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
"./B05_Profile_Profile_Straight": straightJudge,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
@@ -0,0 +1,189 @@
/* 426 종단 직선 판정 한 벌 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · `_Straighten.ts` ·
* `_Pivot.ts` · `_Profile_Straight.ts` · `design_profile_free.ts` 를 그 자리에서 트랜스파일해 돌린다.
* 붙은 측점(100/100.4 · 120/120.25) 직선화 · 끝점 꼭짓점 아님 · 자유지정 R · 기울이기 누름 쌓임 ·
* 편집 측점 수 · 0 오프셋 정리 + 파이썬 거울 입력(base · edits · TS 곡선)을 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);
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"));
const pivot = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Pivot.ts"));
const judge = loadTs(path.join(LOGIC, "B05_Profile_Profile_Straight.ts"));
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
const 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,
};
const regular = Array.from({ length: 11 }, (_, i) => i * 20);
const groundAt = (s) => 100 + 0.015 * s + 0.4 * Math.sin(s / 7) + 0.0371 * Math.cos(s * 3.1);
const all = [...regular, 100.4, 120.25].sort((a, b) => a - b);
/** 붙은 측점까지 꼭짓점(A) · 정규 측점만 꼭짓점(B). */
function makeBase(pviS) {
return {
policy,
onlyExplicitCurves: false,
basePvi: pviS.map((s) => ({ chainage_m: s, elevation_m: Number(groundAt(s).toFixed(6)) })),
chainage: all,
ground: all.map((s) => Number(groundAt(s).toFixed(6))),
stations: all.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
}
const baseA = makeBase(all);
const baseB = makeBase(regular);
const empty = align.emptyEdits();
const build = (base, edits) => align.buildAlignment(base, edits);
const curvesIn = (alignment, from, to) =>
alignment.curves
.filter((c) => c.chainage_m > from + 1e-6 && c.chainage_m < to - 1e-6)
.map((c) => c.chainage_m);
const planAt = (alignment, s) => align.planElevationAt(alignment, s);
const r6 = (v) => Number(v.toFixed(6));
// ① 붙은 측점 직선화 40 → 140 (A)
const sA = straight.straightenBetween(baseA, empty, 40, 140);
const alA = build(baseA, sA);
// 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 안쪽 꼭짓점 수 — 시험이 반올림 흠을 실제로 거치는지
let noiseBends = 0;
alA.pvi.forEach((node, i) => {
if (i === 0 || i === alA.pvi.length - 1) return;
if (node.chainage_m <= 40 + 1e-6 || node.chainage_m >= 140 - 1e-6) return;
const g = (a, b) => (b.elevation_m - a.elevation_m) / (b.chainage_m - a.chainage_m);
if (Math.abs(g(node, alA.pvi[i + 1]) - g(alA.pvi[i - 1], node)) >= 1e-9) noiseBends += 1;
});
const runA = straight.detectStraightRun(alA, 70);
const axesA = pivot.pivotAxes(alA, 80);
const upA = align.adjustStation(baseA, sA, 80, 0.1);
const alUp = build(baseA, upA);
const gradesUp = alUp.segments
.filter((seg) => seg.from_m >= 40 - 1e-6 && seg.to_m <= 140 + 1e-6)
.map((seg) => r6(seg.grade_percent));
const straightened = {
noiseBends,
curvesInside: curvesIn(alA, 40, 140),
radiiKeys: Object.keys(sA.curve_radii),
run: runA ? [runA.fromM, runA.toM] : null,
axes: axesA ? [axesA.fromM, axesA.toM] : null,
pressRise: r6(planAt(alUp, 80) - planAt(alA, 80)),
curvesAfterPress: curvesIn(alUp, 40, 140),
gradesUp,
editedCount: judge.bentEditCount(alA),
offsetCount: Object.keys(sA.station_offsets).length,
};
// ② 끝점 꼭짓점 아님(B) — 100.4 → 160 직선화 · 120.25 → 180 자유지정
const alB = build(baseB, empty);
const sB = straight.straightenBetween(baseB, empty, 100.4, 160);
const alSB = build(baseB, sB);
const line = (a, b, s) => a[1] + ((b[1] - a[1]) * (s - a[0])) / (b[0] - a[0]);
const endA = [100.4, planAt(alB, 100.4)];
const endB = [160, align.controlElevationAt(alB, 160)];
const endpoint = {
// 끝 꼭짓점 = 직선화 전 그 자리 계획고(꼭짓점 높이 · 그 라운드는 직선의 접선)
endHeld: r6(align.controlElevationAt(alSB, 100.4) - planAt(alB, 100.4)),
endIsVertex: alSB.pvi.some((n) => Math.abs(n.chainage_m - 100.4) < 1e-6),
curvesInside: curvesIn(alSB, 100.4, 160),
innerResidual: [120, 120.25, 140].map((s) => r6(planAt(alSB, s) - line(endA, endB, s))),
};
const z140 = planAt(alB, 140) + 1;
const fEdits = free.buildFreeEdits(baseB, empty, [
{ chainage_m: 120.25, elevation_m: align.controlElevationAt(alB, 120.25) },
{ chainage_m: 140, elevation_m: z140 },
{ chainage_m: 180, elevation_m: align.controlElevationAt(alB, 180) },
]);
const alF = build(baseB, fEdits);
// 자유지정 뒤 ▼ 로 140 을 다시 120.25 · 180 잇는 선 위로 → 직선 → 정리하면 R 없음
const back =
line(
[120.25, align.controlElevationAt(alF, 120.25)],
[180, align.controlElevationAt(alF, 180)],
140,
) - planAt(alF, 140);
const downF = align.adjustStation(baseB, fEdits, 140, back);
const tidyF = straight.tidyEdits(baseB, downF);
const alTidyF = build(baseB, tidyF);
const freeCase = {
endHeld: r6(align.controlElevationAt(alF, 120.25) - align.controlElevationAt(alB, 120.25)),
radiusAt140: fEdits.curve_radii["140.000"] ?? null,
curveAt140: alF.curves.some((c) => Math.abs(c.chainage_m - 140) < 1e-6),
curvesInsideF: curvesIn(alF, 120.25, 180),
radiusKeptRaw: downF.curve_radii["140.000"] !== undefined,
radiusAfterTidy: tidyF.curve_radii["140.000"] ?? null,
curvesAfterTidy: curvesIn(alTidyF, 120.25, 180),
sameShape: r6(planAt(alTidyF, 140) - planAt(build(baseB, downF), 140)),
};
// ③ 고른 직선 기울이기 — 누름 쌓임 · 되돌아오면 R 없음 (A)
const run = straight.detectStraightRun(alA, 70);
let e = sA;
const rises = [];
const pivotM = 80; // 40 · 140 가운데 = 90 → 가장 가까운 안쪽 꼭짓점(80 · 100 중 앞선 80)
[0.1, 0.1, -0.1, -0.1].forEach((d) => {
e = straight.tiltStraightRun(baseA, e, run, d);
rises.push(r6(align.controlElevationAt(build(baseA, e), pivotM) - planAt(alA, pivotM)));
});
const tiltOnce = straight.tiltStraightRun(baseA, sA, run, 0.1);
const tilt = {
rises,
finalRadii: Object.keys(e.curve_radii),
finalCurves: curvesIn(build(baseA, e), 40, 140),
editedAfterOne: judge.bentEditCount(build(baseA, tiltOnce)),
};
// ④ 0 오프셋 정리
const tidyZero = straight.tidyEdits(baseB, {
station_offsets: { "60.000": 0, "80.000": 0.3 },
curve_radii: {},
});
console.log(
JSON.stringify({
straightened,
endpoint,
freeCase,
tilt,
tidyZero: Object.keys(tidyZero.station_offsets),
mirror: {
base: baseA,
cases: [
{ edits: sA, curves: alA.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) },
{ edits: upA, curves: alUp.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) },
{
edits: tiltOnce,
curves: build(baseA, tiltOnce).curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]),
},
],
},
}),
);
@@ -1,5 +1,6 @@
/* 427-1 관 · 구조물 옮김 때 꼭짓점 따라감 — 진짜 `common_util_structure_station.ts` 의 `followMovedNodes` 를
* Node 로 돌림. 입력 argv[2] JSON [{ base, gone, born, edited }, …] · 표준출력 = 꼭짓점 목록 한 벌씩.
/* 427-1 관 · 구조물 옮김 때 꼭짓점 따라감 — 진짜 `common_util_structure_station.ts` 의 `followMovedNodes` +
* `B05_Profile_Grade_Seat.ts` 의 `seatBornNodes`(화면 `listedBase` 차례)를 Node 로 돌림.
* 입력 argv[2] JSON [{ base, gone, born, edited }, …] · 표준출력 = 꼭짓점 목록 한 벌씩.
*/
const fs = require("fs");
const path = require("path");
@@ -24,7 +25,9 @@ function loadTs(file) {
const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const station = loadTs("common_util/common_util_structure_station.ts");
const out = cases.map((item) =>
station.followMovedNodes(item.base, item.gone, item.born, item.edited),
);
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts");
const out = cases.map((item) => {
const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited);
return seat.seatBornNodes(item.base, moved.nodes, moved.seat);
});
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,137 @@
/* 430 평면 형상 제어 규칙 한 곳 — `logic/B05_Profile_UI_RouteEdit_Edits.ts`(하한 검사 · 겹친 노드 ·
* 못 그린 곡선 · 넣은 값 되살림) · `_KeyNudge.fitNote` 를 Node 로 돌려 JSON 한 줄
* (`test_430_route_edit_rules.py` 가 판정).
* node helper_430_route_edit_rules.cjs <서버 곡선표 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 LOGIC = path.join(ROOT, "B05_Profile", "logic");
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 edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts"));
const LIMITS = { minRadiusM: 30, limitRadiusM: 20, limitArcM: 0 };
// IP1 · IP2 · IP3 — 직선 넉넉(곡선 R 30 다 들어감)
const ROUTE = [
[0, 0],
[100, 0],
[200, 60],
[300, 0],
[400, 0],
];
function cols(planned) {
const n = planned.length;
return {
planned: planned.map((v) => [v[0], v[1]]),
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),
};
}
/** 지난 걸음 `before` 에서 `change` 로 고친 뒤 하한 검사 글. */
function stop(before, change) {
const now = cols(before.planned);
change(now);
const built = edits.rebuildLine(now, LIMITS);
return edits.floorStop(before, now.planned, built.nodes, LIMITS);
}
const base = cols(ROUTE);
const out = {
// 같은 노드 수 — IP2 를 IP1 쪽 8 m 앞으로(IP1 · IP2 접선 자리 모자람 → R 하한 아래)
moveBreak: stop(base, (c) => (c.planned[2] = [108, 4])),
moveOk: stop(base, (c) => (c.planned[2] = [200, 70])),
// 끼우기 — IP1 바로 뒤 4 m 에 새 노드(IP1 접선 자리 2 m)
insertBreak: stop(base, (c) => {
c.planned.splice(2, 0, [104, 1]);
for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(2, 0, k === "curveOn" ? true : k === "apexLock" ? false : null);
}),
// 지우기 — IP2 를 빼도 IP1 · IP3 넉넉
deleteOk: stop(base, (c) => {
for (const k of ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(2, 1);
}),
// 둘 넘게 다르면 안 견줌
manyGap: stop(base, (c) => {
c.planned.splice(1, 0, [50, 0], [52, 0]);
for (const k of ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"])
c[k].splice(
1,
0,
...(k === "curveOn" ? [true, true] : k === "apexLock" ? [false, false] : [null, null]),
);
}),
};
// 이미 하한을 밑돌던 자리는 「새로」가 아님 — 밑돈 노선에서 다시 조금 옮겨도 통과
const broken = cols(ROUTE);
broken.planned[2] = [108, 4];
out.alreadyBroken = stop(broken, (c) => (c.planned[2] = [108.5, 4.2]));
// 겹친 노드 · 못 그린 곡선
const overlap = [
[0, 0],
[100, 0],
[100.3, 0.2],
[200, 60],
[300, 0],
];
out.flags = edits.routeFlags(overlap, Array(5).fill(true), []);
out.flagsOff = edits.routeFlags(ROUTE, [true, false, false, false, true], []);
out.summary = edits.curveSummary({
nodeCount: 5,
curveOn: Array(5).fill(true),
curveRadius: Array(5).fill(null),
curveLock: Array(5).fill(null),
apexLock: [],
curveCount: 2,
violationCount: 0,
minRadiusM: 30,
fresh: false,
flags: out.flags,
});
// fitNote — 손으로 옮긴 고른 IP 는 「밀림」에 안 셈(놓을 때 · 430) · 입력 뒤는 셈(408-1)
const after = ROUTE.map((v, i) => (i === 2 ? [v[0] + 3, v[1]] : i === 3 ? [v[0] + 1, v[1]] : v));
out.noteHand = nudge.fitNote(ROUTE, after, 2, [], 20, 20, true);
out.noteInput = nudge.fitNote(ROUTE, after, 2, [], 20, 20);
// 짝 — 서버 곡선표(파이썬이 씀)를 그대로 펴서 넣은 값이 돌아오는가
const server = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const flat = edits.flattenServerPlan(server.nodes, server.curves, server.edits);
out.flat = {
planned: flat.planned,
curveOn: flat.curveOn,
curveRadius: flat.curveRadius,
curveLock: flat.curveLock,
curveArc: flat.curveArc,
};
const old = edits.flattenServerPlan(server.nodes, server.curves, null);
out.flatOld = { curveRadius: old.curveRadius, curveLock: old.curveLock };
process.stdout.write(JSON.stringify(out));
@@ -49,6 +49,7 @@ def test_편집기_배선() -> None:
assert main.count("setAnalysisTerrain(") == 1 # 바꿀 때 한 곳뿐 — 열 때 쓰기 없음
handler = main.split('terrainSelect.addEventListener("change"', 1)[1][:900]
assert "setAnalysisTerrain(projectId" in handler
assert "options.draft?.edited(" in handler # 바꾸면 계산 묶음에 알림
assert "tellDraft()" in handler # 바꾸면 계산 묶음에 알림(430 — 스냅과 한 곳)
assert "options.draft?.edited(" in main.split("function tellDraft()", 1)[1][:400]
# 설계 지형이 수치지형도면 확정 모델(수치지형도 서피스) 등고선을 「LAS」로 쓰지 않음
assert 'analysisTerrain(projectId) === "las" && designTerrain === "las"' in main
@@ -58,6 +58,7 @@ def test_간격별_상태줄_글(texts, step, want) -> None:
def test_편집기는_고른_간격_글을_씀() -> None:
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "일반측점에 맞췄" not in edit
assert edit.count("applyEdit(snap.text())") == 2
assert edit.count("applyEdit(snap.text())") == 1 # 켬 · 간격 바꿈
assert 'applyEdit(stationSnap ? snap.text() : "옮겼습니다.")' in edit # 놓을 때(430)
snap = SNAP.read_text(encoding="utf-8")
assert "text: () => snapFitText(select.value)" in snap
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""426 B05 종단 직선 판정 한 벌 — 높이 기준(1e-5 m) · 끝점 꼭짓점 · 누름 쌓임 · 편집 측점 수.
요구 259.
계산(`logic/B05_Profile_Profile_Straight.ts` · `_Alignment.ts` buildCurves · `_Pivot.ts` ·
`_Straighten.ts` · `design_profile_free.ts`)은 Node 헬퍼(`helper_426_profile_straight.cjs`)가
트랜스파일해 돌리고, 파이썬 짝(`B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend`)은
같은 base · 편집으로 곡선 자리 · R 이 같은지 거울로 본다.
./venv/Scripts/python.exe -m pytest resources/tester/test_426_profile_straight.py -q
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
STRAIGHT_HEIGHT_M,
AlignmentPolicy,
build_alignment,
is_straight_bend,
)
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_426_profile_straight.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_붙은_측점_직선화_뒤_R_없음_한_직선(out):
got = out["straightened"]
# 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 반올림 흠이 실제로 있음 — 시험이 그 길을 거침
assert got["noiseBends"] > 0
assert got["curvesInside"] == [] and got["radiiKeys"] == []
assert got["run"] == [40, 140]
assert got["axes"] == [40, 140]
def test_직선_안_누름_톱니_없음(out):
got = out["straightened"]
assert got["pressRise"] == pytest.approx(0.1, abs=1e-4)
assert got["curvesAfterPress"] == [80] # 누른 점 하나만 꺾임
left, right = got["gradesUp"][:2], got["gradesUp"][2:]
assert max(left) - min(left) < 1e-3 and max(right) - min(right) < 1e-3 # 0.4 m 조각 톱니 없음
def test_편집_측점_수는_꺾인_꼭짓점(out):
assert out["straightened"]["offsetCount"] > 0
assert out["straightened"]["editedCount"] == 0
assert out["tilt"]["editedAfterOne"] == 1
def test_끝점_꼭짓점_아니면_지금_높이로_세움(out):
got = out["endpoint"]
assert got["endHeld"] == pytest.approx(0, abs=1e-6)
assert got["endIsVertex"]
assert got["curvesInside"] == []
assert all(abs(value) < 1e-5 for value in got["innerResidual"])
def test_자유지정_R_뒤_직선되면_R_지움(out):
got = out["freeCase"]
assert got["endHeld"] == pytest.approx(0, abs=1e-6)
assert got["radiusAt140"] and got["curveAt140"] and got["curvesInsideF"] == [140]
assert got["radiusKeptRaw"] # ▼ 만으로는 기억 R 이 남음 → 적용 한 곳의 정리가 지움
assert got["radiusAfterTidy"] is None and got["curvesAfterTidy"] == []
assert got["sameShape"] == pytest.approx(0, abs=1e-9)
def test_고른_직선_기울이기_누름_쌓임(out):
got = out["tilt"]
assert got["rises"] == pytest.approx([0.1, 0.2, 0.1, 0.0], abs=1e-6)
assert got["finalRadii"] == [] and got["finalCurves"] == []
def test_0_오프셋_정리(out):
assert out["tidyZero"] == ["80.000"]
def test_파이썬_짝_같은_기준():
assert STRAIGHT_HEIGHT_M == 1e-5
assert is_straight_bend(2.5e-6, 0.4, 20.0) # 반올림 흠(0.4 m 조각) = 직선
assert not is_straight_bend(1e-3, 20.0, 20.0) # 0.1 % 꺾임 = 꺾임
assert "STRAIGHT_HEIGHT_M = 1e-5" in _read("B05_Profile/logic/B05_Profile_Profile_Straight.ts")
def test_파이썬_거울_곡선_자리_R(out):
base = out["mirror"]["base"]
policy = AlignmentPolicy.from_dict(base["policy"])
for case in out["mirror"]["cases"]:
got = build_alignment(
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
chainage=np.array(base["chainage"]),
ground=np.array(base["ground"]),
stations=base["stations"],
policy=policy,
edits=case["edits"],
)
py = [[c["chainage_m"], c["r_m"]] for c in got["curves"]]
assert [c[0] for c in py] == pytest.approx([c[0] for c in case["curves"]], abs=1e-6)
assert [c[1] for c in py] == pytest.approx([c[1] for c in case["curves"]], rel=1e-6)
def test_부르는_곳():
assert "tidyEdits(base, next)" in _read("A00_Common/design/design_profile.ts")
assert "bentEditCount(alignment)" in _read("B05_Profile/B05_Profile_UI_Profile_Balance.ts")
assert "isStraightBend(delta, spanLeft, spanRight)" in _read(
"B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"
)
assert "is_straight_bend(delta," in _read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py")
@@ -0,0 +1,185 @@
"""430 평면 형상 제어 규칙 한 곳 — 하한 검사(`applyEdit` 한 곳 · 끼우기 · 지우기 · 손잡이) ·
곡선 삭제 155° 경고 · 놓을 때 맞춤 보고 · 스냅 끔 · 간격 즉시 계산 줄 · R · 길이 고정 ·
넣은 R 곡선표 저장(짝) · 겹친 노드 · 못 그린 곡선 표시.
TS 셈은 Node(`helper_430_route_edit_rules.cjs`) · 짝 = 파이썬 `_write_planned_polyline` 이 쓴
곡선표를 TS `flattenServerPlan` 이 펴서 넣은 값이 돌아오는가.
./venv/Scripts/python.exe -m pytest resources/tester/test_430_route_edit_rules.py -q
"""
import csv
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits # noqa: E402
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_430_route_edit_rules.cjs")
# IP2 뒤 직선 16 m — 넣은 R 200 은 접선 자리에 눌려 그려짐 · IP3 바로 뒤 0.3 m 겹친 점
NODES = [(0.0, 0.0), (120.0, 0.0), (220.0, 70.0), (236.0, 70.0), (236.2, 70.2), (340.0, 0.0)]
FLAGS = [True, True, True, False, True, True]
RADII = [None, 40.0, 200.0, None, None, None]
LOCKS = [None, "radius", "arc", None, "radius", "bogus"]
ARCS = [None, None, 33.5, None, None, None]
@pytest.fixture(scope="module")
def saved(tmp_path_factory) -> dict:
path = tmp_path_factory.mktemp("route") / "planned_route.csv"
replan._write_planned_polyline(
path,
NODES,
30.0,
simplify=False,
curve_flags=FLAGS,
radii=RADII,
locks=LOCKS,
arcs=ARCS,
)
with replan._nodes_path(path).open(encoding="utf-8") as handle:
nodes = [(float(row["x"]), float(row["y"])) for row in csv.DictReader(handle)]
outline = build_planned_polyline(nodes, min_radius_m=30.0, simplify=False)
return {
"path": path,
"nodes": [node.as_dict() for node in outline.nodes],
"curves": replan._read_curves(path),
"edits": replan._read_curves(path, "edits"),
}
@pytest.fixture(scope="module")
def got(saved, tmp_path_factory) -> dict:
server = tmp_path_factory.mktemp("server") / "plan.json"
payload = {key: saved[key] for key in ("nodes", "curves", "edits")}
server.write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER), str(server)],
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["moveBreak"].startswith("하한에 걸려 되돌렸습니다 — IP1")
assert "(곡선반지름 20m)" in got["moveBreak"]
assert got["moveOk"] == ""
def test_끼우기_새로_깨면_까닭_지우기_넉넉하면_통과(got) -> None:
assert "IP1" in got["insertBreak"] and "IP2" in got["insertBreak"]
assert got["deleteOk"] == ""
assert got["manyGap"] == "" # 둘 넘게 다르면 안 견줌
def test_이미_밑돈_자리는_새로가_아님(got) -> None:
assert got["alreadyBroken"] == ""
# ── 겹친 노드 · 못 그린 곡선 ─────────────────────────────────────────────────
def test_겹친_노드_못_그린_곡선(got) -> None:
assert got["flags"] == {"overlap": [2], "undrawn": [1, 3]}
assert got["flagsOff"] == {"overlap": [], "undrawn": []} # 곡선 끈 자리는 「못 그림」 아님
assert "겹친 노드 IP2(0.5 m 안 · 선에서 빠짐)" in got["summary"]
assert "못 그린 곡선 IP1 · IP3" in got["summary"]
def test_놓을_때_손으로_옮긴_IP_는_밀림에_안_셈(got) -> None:
assert got["noteHand"] == " · 함께 밀린 교점 1곳"
assert got["noteInput"] == " · 함께 밀린 교점 2곳"
# ── 넣은 R · 고정 곡선표 저장(짝) ────────────────────────────────────────────
def test_곡선표_edits_는_저장_노드와_자리가_같음(saved) -> None:
assert len(saved["edits"]) == len(saved["nodes"]) == len(NODES) - 1 # 겹친 점 하나 빠짐
assert saved["edits"][2] == {"curve": True, "radius_m": 200.0, "lock": "arc", "arc_m": 33.5}
assert saved["edits"][1]["lock"] == "radius"
assert saved["edits"][3]["curve"] is False
assert saved["edits"][4]["lock"] is None # 모르는 고정 값은 버림
def test_다시_열면_넣은_R_고정이_돌아옴(got, saved) -> None:
flat = got["flat"]
drawn = next(c["radius_m"] for c in saved["curves"] if c["node_first"] == 2)
assert drawn < 200 - 1 # 그린 R 은 눌림
assert flat["curveRadius"][1:3] == [40, 200] # 넣은 값
assert flat["curveLock"][1:3] == ["radius", "arc"]
assert flat["curveArc"][2] == 33.5
assert flat["curveOn"][3] is False
assert got["flatOld"]["curveRadius"][2] == pytest.approx(drawn, abs=0.01) # 옛 곡선표 = 그린 R
def test_node_edits_짧은_칸은_기본값() -> None:
edits = node_edits([(0, 0), (10, 0), (20, 5)], None, [None, 15.0])
assert edits[1] == {"curve": True, "radius_m": 15.0, "lock": None, "arc_m": None}
assert edits[2] == {"curve": True, "radius_m": None, "lock": None, "arc_m": None}
def test_편집값_없이_쓴_곡선표는_edits_없음(tmp_path) -> None:
path = tmp_path / "planned_route.csv"
replan._write_planned_polyline(path, NODES, 30.0, simplify=False)
assert replan._read_curves(path, "edits") == []
# ── 배선 ────────────────────────────────────────────────────────────────────
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_편집기_배선() -> None:
main = _read("B05_Profile_UI_RouteEdit.ts")
apply_edit = main.split("function applyEdit(", 1)[1][:700]
assert "edits.floorStop(before, planned, nodeInfo, fitLimits())" in apply_edit
assert "restoreSnapshot(before, stop, false)" in apply_edit
assert "curveBar.noteFit(snap.text(), before, index)" in main # 놓을 때 맞춤 보고
snap = main.split("bindStationSnap(chrome", 1)[1][:400]
assert "tellDraft();" in snap # 끔 · 간격 바꿈 즉시 계산 줄
assert "snap.changed()" in main.split("function tellDraft()", 1)[1][:400]
assert "plan.node_edits" in main
assert "nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc })" in main
assert len(main.splitlines()) <= 695
def test_손잡이_끌기_하한_검사() -> None:
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
handle = pointer.split("// 곡선 시작·끝점을 끈다", 1)[1][:2200]
assert "shortfallCrossed(" in handle
assert "params.commit(dragHandle?.node ?? dragNode)" in pointer
def test_곡선_삭제_155_경고() -> None:
bar = _read("B05_Profile_UI_RouteEdit_CurveBar.ts")
on = bar.split("onCurveOn:", 1)[1][:900]
assert "!curveOptionalAt(inner)" in on and '"warning"' in on
def test_저장에_고정_실음_읽기에_node_edits() -> None:
apply = _read("B05_Profile_UI_RouteEdit_Apply.ts")
assert "nodes.curveLock?.[index] ?? null" in apply
assert "nodes.curveArc?.[index] ?? null" in apply
router = _read("B05_Profile_Router_Replan.py")
assert '"node_edits":' in router
assert "locks=list(draft.curveLock) if draft else None" in router