feat(B05 평면 측점 스냅): 스냅 간격 = 조작 단위 — 방향키 한 번 = 간격 · 끄는 동안 MC 맞춤 미리보기 · MC 측점 값 간격 자릿수 · 간격 저장(서버 짝) (390-5)

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 19:52:13 +09:00
co-authored by Claude Opus 5.5
parent dbb00605c0
commit e3b8123f8b
25 changed files with 578 additions and 94 deletions
@@ -571,6 +571,7 @@ function createPagePipeline(
intervalM: options.stationIntervalM,
types: enabled,
stationSnap: routePlan.station_snap ?? false,
stationSnapStep: routePlan.station_snap_step,
});
crossSource = null;
})
@@ -43,7 +43,7 @@ export function reuseOutside(
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */
export function createStationsStage(
options: Omit<StationsOptions, "stationSnap" | "types"> & {
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
types: () => ReadonlyArray<PlanType>;
},
): StageFn<"stations"> {
@@ -55,7 +55,12 @@ export function createStationsStage(
route.curves,
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
(ctx.inputs.structures as PlanStructure[] | null) ?? [],
{ ...options, types: options.types(), stationSnap: ctx.inputs.route?.stationSnap ?? false },
{
...options,
types: options.types(),
stationSnap: ctx.inputs.route?.stationSnap ?? false,
stationSnapStep: ctx.inputs.route?.stationSnapStep,
},
);
return reuseOutside(next, ctx.prev, ctx.range);
};
@@ -73,6 +73,8 @@ export interface RouteDraft {
curveArc: Array<number | null>;
/** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */
stationSnap: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */
stationSnapStep?: number | null;
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
terrain: string | null;
}
@@ -211,7 +211,8 @@ export interface StationTickOptions {
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
onLabel?: (box: StationLabelBox) => void;
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2).
* 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */
chainages?: ReadonlyArray<number>;
}
@@ -287,7 +288,7 @@ export function drawStationTicks(
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
context.stroke();
const label = stationLabel(chainage, interval);
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0);
const width = context.measureText(label).width + 6;
const half = width / 2;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
+2
View File
@@ -117,6 +117,8 @@ export interface RoutePlanResponse {
edited: boolean;
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
station_snap?: boolean;
/** 스냅 간격(m · 0.1 · 1 · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
@@ -90,6 +90,15 @@ def load_station_snap(project_root: Path) -> bool:
return _read_design_curve_table(project_root).get("station_snap") is True
def load_station_snap_step(project_root: Path) -> float | None:
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
칸 없으면 None(측점 No.).
"""
step = _read_design_curve_table(project_root).get("station_snap_step")
return float(step) if isinstance(step, int | float) and step > 0 else None
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
route_path = planned_route_working_path(project_root)
@@ -458,6 +467,7 @@ def run_section_generation(
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
station_snap=load_station_snap(project_root),
station_snap_step=load_station_snap_step(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -573,6 +583,7 @@ def sample_structure_sections(
design_curves=_load_design_curves(project_root),
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
station_snap=load_station_snap(project_root),
station_snap_step=load_station_snap_step(project_root),
)
def structural(entry: dict[str, Any]) -> bool:
@@ -6,9 +6,10 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
`resources/tester/test_224_2_station_snap.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ·
`_mc_scale` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` ·
`snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
간격별 MC 값 `test_390_5_snap_step_ops.py`.
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) ·
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
@@ -288,15 +289,25 @@ def _design_curve_spans(
CURVE_STATION_EDGE_M = 0.005
def _mc_scale(step_m: float | None) -> int:
"""MC 측점 값 자릿수 배수(390-5) — 스냅 간격 1 m 미만(0.1) = 10(소수 한 자리) · 그 밖 = 1(정수).
`mcScale` 짝."""
return 10 if step_m is not None and step_m < 1 else 1
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
step_m: float | None = None,
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 MC
(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른
측점과 같은 값이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리) · `step_m` = 스냅 간격
(0.1 · 1 · 10 · None = 측점 No. · 390-5).
"""
scale = _mc_scale(step_m)
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
@@ -309,7 +320,7 @@ def snap_curve_stations(
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = float(math.floor(middle + 0.5))
values[nearest] = math.floor(middle * scale + 0.5) / scale
return np.unique(np.asarray(values, dtype=np.float64))
@@ -358,10 +369,12 @@ def generate_sections(
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
"""
options = options or SectionGenerationOptions()
options.validate()
@@ -375,7 +388,7 @@ def generate_sections(
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans)
station_chainage = snap_curve_stations(station_chainage, spans, station_snap_step)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
+4 -1
View File
@@ -6,7 +6,8 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
`station_snap`) +
`draft` — 계약 `RouteDraft`(`A00_Common/design/logic/design_pipeline_types.ts`) 그대로
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
stationSnap, stationSnapStep, terrain}` · 오면 `vertices` · `station_snap`(· `_step`) ·
`terrain` 대신 이것.
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped,
@@ -63,6 +64,7 @@ class RouteDraftInput(BaseModel):
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
stationSnap: bool = False # noqa: N815
stationSnapStep: float | None = None # noqa: N815 — 390-5
terrain: Literal["las", "sheet"] | None = None
@@ -97,6 +99,7 @@ class RouteSaveRequest(RouteReplanRequest):
update={
"vertices": vertices,
"station_snap": draft.stationSnap,
"station_snap_step": draft.stationSnapStep,
"terrain": draft.terrain,
}
)
+8 -2
View File
@@ -41,7 +41,7 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
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
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
@@ -252,10 +252,12 @@ def _write_planned_polyline(
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
@@ -276,6 +278,8 @@ def _write_planned_polyline(
}
if station_snap is not None:
table["station_snap"] = bool(station_snap)
if station_snap and station_snap_step is not None:
table["station_snap_step"] = float(station_snap_step)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
@@ -539,6 +543,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"violation_count": outline.violation_count,
"edited": bool(working),
"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),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
}
@@ -589,6 +594,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
curve_flags=curve_flags,
radii=radii,
station_snap=request.station_snap,
station_snap_step=request.station_snap_step,
),
working_path,
nodes,
+2
View File
@@ -382,3 +382,5 @@ class RouteReplanRequest(BaseModel):
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
station_snap_step: float | None = None
"""스냅 간격(m · 0.1 · 1 · 10 · None = 측점 No. · 390-5) — 곡선표 `"station_snap_step"`."""
+27 -34
View File
@@ -62,11 +62,6 @@ import {
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
curveSpans,
snapCurveStations,
stationsAlong,
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
import {
lockColumn,
readRouteLocks,
@@ -161,19 +156,15 @@ export async function mountRouteEditor(
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 고른 간격 배수에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
let stationSnap = false;
let savedStationSnap = false;
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
* 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */
const stations = (): number[] | null => {
if (!stationSnap) return null;
const line = plannedLine.length ? plannedLine : planned;
return snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curveInfo),
);
};
/** 간격 · 저장 비교 · 편집 창 측점(끔이면 null · 그리기 · 누름 · 기울기가 봄)은 `_SnapStep` 몫(390-2 · 5). */
const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
if (toggle) setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음
draw();
});
const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo);
const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on));
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
@@ -261,11 +252,6 @@ export async function mountRouteEditor(
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
const snapStepM = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
if (toggle) setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit(snapStepM.text()); // 전체 · 한 걸음
draw();
});
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
let viewReady = false;
const viewMemory = createViewMemory(projectId, () =>
@@ -413,7 +399,7 @@ export async function mountRouteEditor(
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snapStepM()) : 0,
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
@@ -447,9 +433,8 @@ export async function mountRouteEditor(
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
function applyEdit(message: string, record = true, fitting = true): void {
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: snapStepM() };
const from = fit.fitStart(history?.current?.(), snapshotNow());
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
@@ -459,6 +444,11 @@ export async function mountRouteEditor(
historyControls.sync();
}
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
function fitLimits(): fit.FitLimits {
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
}
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
function snapshotNow(): RouteEditSnapshot {
return {
@@ -535,10 +525,16 @@ export async function mountRouteEditor(
curveHint,
contourHint,
commit: () => {
if (stationSnap) return applyEdit(snapStepM.text()); // 놓을 때(366-3)
if (stationSnap) return applyEdit(snap.text()); // 놓을 때(366-3)
history?.commit(snapshotNow());
historyControls.sync();
},
snapStepM: snap,
fitDrag: (index) => {
const base = stationSnap ? history?.current?.()?.planned : undefined;
if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits());
if (base) markEdited();
},
});
bindNodeSplice({
@@ -582,11 +578,9 @@ export async function mountRouteEditor(
nodes: () => ({ planned, curveOn, curveRadius }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () =>
(history?.isDirty() ?? false) ||
pickedTerrain() !== designTerrain ||
stationSnap !== savedStationSnap,
(history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(),
terrain: pickedTerrain,
stationSnap: () => stationSnap,
snap: snap.draft,
initial,
onApplied,
});
@@ -613,8 +607,7 @@ export async function mountRouteEditor(
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
savedStationSnap = plan.station_snap === true;
setStationSnap(savedStationSnap);
setStationSnap(snap.load(plan));
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
@@ -624,7 +617,7 @@ export async function mountRouteEditor(
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, () => stationSnap);
history = draftLink.watch(history, options.draft, snap.draft);
draftLink.restore(options.draft, restoreSnapshot);
bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3
historyControls.sync();
@@ -674,7 +667,7 @@ export async function mountRouteEditor(
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null);
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
const mine = ++terrainTicket;
void loadBackground().then((background) => {
if (closed || mine !== terrainTicket) return;
+10 -10
View File
@@ -28,8 +28,10 @@ import {
type RouteReplanResponse,
type RouteSaveBody,
} from "./B05_Profile_Api_Replan";
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> };
export interface RouteApplyParams {
@@ -43,8 +45,8 @@ export interface RouteApplyParams {
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
stationSnap: () => boolean;
/** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
snap: () => SnapSetting;
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
initial: {
current: () => unknown;
@@ -56,7 +58,7 @@ export interface RouteApplyParams {
interface RouteSource {
pending: () => Promise<RouteSaveBody | null>;
settings: () => { stationSnap: boolean; terrain: string | null };
settings: () => RouteSettings;
saved: () => void;
rebuild: () => void | Promise<void>;
}
@@ -64,10 +66,7 @@ interface RouteSource {
const sources = new Map<string, RouteSource>();
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
export function routeDraftOf(
nodes: RouteNodes,
settings: { stationSnap: boolean; terrain: string | null },
): RouteDraft {
export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft {
return {
rev: 0,
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
@@ -76,13 +75,14 @@ export function routeDraftOf(
curveLock: nodes.planned.map(() => null),
curveArc: nodes.planned.map(() => null),
stationSnap: settings.stationSnap,
stationSnapStep: settings.stationSnapStep ?? null,
terrain: settings.terrain,
};
}
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */
export function bindRouteApply(params: RouteApplyParams): void {
const settings = () => ({ stationSnap: params.stationSnap(), terrain: params.terrain() });
const settings = () => ({ ...params.snap(), terrain: params.terrain() });
sources.set(params.projectId, {
pending: async () => {
const nodes = params.nodes();
@@ -175,8 +175,8 @@ export async function saveRecommendedRoute(
showLoadingOverlay();
try {
await saveRouteDraft(projectId, {
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬으로 저장
draft: routeDraftOf(nodes, { ...settings, stationSnap: true }),
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
initial,
balanced_profile: balancedProfile,
as_initial: true,
@@ -20,6 +20,7 @@ import {
type RouteEditHistory,
type RouteEditSnapshot,
} from "./B05_Profile_UI_RouteEdit_History";
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
/** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */
export type RouteDraftEdit = Omit<RouteDraft, "rev" | "terrain">;
@@ -31,15 +32,15 @@ export interface RouteDraftBridge {
saved(): RouteDraft | null;
}
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님). */
export function draftOfSnapshot(snapshot: RouteEditSnapshot, stationSnap: boolean): RouteDraftEdit {
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님) · 스냅 켬끔 · 간격(390-5)은 `_SnapStep` 몫. */
export function draftOfSnapshot(snapshot: RouteEditSnapshot, snap: SnapSetting): RouteDraftEdit {
return {
nodes: snapshot.planned.map(([x, y]): [number, number] => [x, y]),
curveOn: [...snapshot.curveOn],
curveRadius: [...snapshot.curveRadius],
curveLock: [...snapshot.curveLock],
curveArc: [...snapshot.curveArc],
stationSnap,
...snap,
};
}
@@ -60,12 +61,11 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
export function watch(
history: RouteEditHistory,
bridge: RouteDraftBridge | undefined,
stationSnap: () => boolean,
snap: () => SnapSetting,
): RouteEditHistory {
if (!bridge) return history;
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
if (snapshot)
bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null);
if (snapshot) bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, snap()) : null);
return snapshot;
};
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
+10 -3
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Keys.ts
* 계획노선 편집 평면의 **교각점 방향키**(383-2 · 요구 241-16) — 고른 IP 를 화면 축으로 0.1 m
* (Shift 1 m)씩. 누르는 동안 옮기기만 · 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 MC 맞춤).
* (Shift 1 m)씩 · 측점 스냅 켬이면 스냅 간격씩(390-5 · 걸음마다 MC 를 간격 배수에 맞춘 미리보기).
* 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 정본 맞춤).
*
* 받는 때 = 마지막 누름이 평면 편집 창(`.b05-routeedit`) 안 · 입력칸 아님. 그때 방향키는 평면이
* 삼킴(IP 를 안 골랐어도) — 종단 계획고 ▲▼ · 횡단 조정창으로 새지 않음. 하한 검사 · 놓을 때 할
@@ -28,6 +29,8 @@ interface Hold {
index: number;
before: Vertex[];
start: Vertex;
/** 키로 옮긴 자리(맞추기 전) — 다음 걸음은 여기서(맞춤이 쌓이지 않음). */
at: Vertex;
moved: boolean;
}
@@ -43,9 +46,10 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
const state = params.state();
const node = state.planned[now.index];
if (!node) return;
const [dx, dy] = arrowStep(now.key, now.shift, metricAt);
const snapStepM = params.stations() !== null ? params.snapStepM() : undefined;
const [dx, dy] = arrowStep(now.key, now.shift, metricAt, snapStepM);
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
state.planned[now.index] = [node[0] + dx, node[1] + dy];
state.planned[now.index] = [now.at[0] + dx, now.at[1] + dy];
params.markEdited();
const after = params.state();
if (
@@ -60,6 +64,8 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
return;
}
now.moved = true;
now.at = state.planned[now.index];
params.fitDrag(now.index);
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
params.draw();
}
@@ -106,6 +112,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
index,
before: state.planned.map(([x, y]): Vertex => [x, y]),
start: [node[0], node[1]],
at: [node[0], node[1]],
moved: false,
};
hold = now;
@@ -99,6 +99,10 @@ export interface RouteEditPointerParams {
contourHint: () => string;
/** 끌기 한 번을 되돌리기에 쌓는다. */
commit: () => void;
/** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */
snapStepM: () => number;
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에. */
fitDrag: (index: number) => void;
}
export interface RouteEditPointer {
@@ -322,6 +326,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.draw();
return;
}
params.fitDrag(dragNode); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본)
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
@@ -3,16 +3,30 @@
* 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11).
*
* 간격 = 0.1 m · 1 m · 10 m · 측점 No.(편집 창 측점 간격 · 기본) — 곡선 MC 를 그 간격 배수에 맞춤.
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · [저장] 대상 아님(서버 · 추천은 측점 No. 그대로).
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · 스냅 켬이면 저장 노선에도 같이
* (`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.).
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
curveSpans,
snapCurveStations,
stationsAlong,
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
const STEPS = ["0.1", "1", "10", "station"];
/** 저장 · 초안에 실리는 스냅 두 칸. */
export type SnapSetting = Pick<RouteDraft, "stationSnap" | "stationSnapStep">;
type SavedSnap = { station_snap?: boolean; station_snap_step?: number | null };
/** [측점 스냅] 단추 켬끔 모양(`is-active` · `aria-pressed`). */
export function paintStationSnap(button: HTMLButtonElement, on: boolean): void {
button.classList.toggle("is-active", on);
@@ -25,12 +39,22 @@ export function snapFitText(step: string): string {
return `곡선 가운데(MC)를 ${where}에 맞췄습니다.`;
}
export type SnapStep = { (): number; text: () => string };
export type SnapStep = {
/** 지금 스냅 간격(m) — 측점 No. 면 측점 간격. */
(): number;
/** 스냅 뒤 상태줄 글. */
text: () => string;
/** 저장 · 초안 두 칸 — 끔이면 간격 null. */
draft: () => SnapSetting;
/** 저장 노선을 읽음 — 켬이면 고르기를 그 간격으로 · 돌려줌 = 켬끔. */
load: (plan: SavedSnap) => boolean;
/** 읽은 저장 노선과 켬끔 · 간격이 다른가([확인] 보낼지). */
changed: () => boolean;
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
};
/**
* 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈.
* 돌려주는 함수 = 지금 스냅 간격(m) · `.text()` = 스냅 뒤 상태줄 글.
*/
/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */
export function bindStationSnap(
chrome: Pick<RouteEditChrome, "overlay" | "stationSnapButton">,
projectId: string,
@@ -45,6 +69,34 @@ export function bindStationSnap(
onChange(false);
});
chrome.stationSnapButton.addEventListener("click", () => onChange(true));
const stepM = () => (select.value === "station" ? stationIntervalM : Number(select.value));
return Object.assign(stepM, { text: () => snapFitText(select.value) });
const on = () => chrome.stationSnapButton.getAttribute("aria-pressed") === "true";
const step = (): number | null => (select.value === "station" ? null : Number(select.value));
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
const stepM = () => step() ?? stationIntervalM;
return Object.assign(stepM, {
text: () => snapFitText(select.value),
draft,
load: (plan: SavedSnap) => {
const snapOn = plan.station_snap === true;
const kept = String(plan.station_snap_step ?? "station");
if (snapOn) select.value = STEPS.includes(kept) ? kept : "station";
loaded = { stationSnap: snapOn, stationSnapStep: snapOn ? step() : null };
return snapOn;
},
changed: () => {
const now = draft();
return (
now.stationSnap !== loaded.stationSnap || now.stationSnapStep !== loaded.stationSnapStep
);
},
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
on()
? snapCurveStations(
stationsAlong(line, stationIntervalM, false),
curveSpans(line, curves),
step(),
)
: null,
});
}
@@ -3,7 +3,8 @@
* 교각점 방향키(383-2 · 요구 241-16) — 화면 축 걸음 · 뗄 때 맞춘 결과 한 줄.
*
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 뗄 때 MC 를 일반측점에 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
* ========================================================================== */
@@ -25,12 +26,14 @@ export function isArrowKey(key: string): key is ArrowKey {
/**
* 방향키 한 번의 도면 좌표 걸음 — `metricAt(화면 x, y)` 로 화면 위 · 오른쪽 1 px 를 도면 좌표로 바꿔
* 그 방향 단위 벡터 × 걸음. 돌린 지도 · 확대와 무관하게 화면 축.
* 그 방향 단위 벡터 × 걸음. 돌린 지도 · 확대와 무관하게 화면 축. `snapStepM` = 스냅 켬일 때 간격
* (있으면 걸음 = 그 값 · Shift 무시).
*/
export function arrowStep(
key: ArrowKey,
shift: boolean,
metricAt: (x: number, y: number) => Vertex,
snapStepM?: number,
): Vertex {
const [x0, y0] = metricAt(0, 0);
const [dx, dy] =
@@ -44,7 +47,7 @@ export function arrowStep(
const [x1, y1] = metricAt(dx, dy);
const length = Math.hypot(x1 - x0, y1 - y0);
if (!(length > 0)) return [0, 0];
const step = KEY_STEP_M * (shift ? KEY_SHIFT_TIMES : 1);
const step = snapStepM ?? KEY_STEP_M * (shift ? KEY_SHIFT_TIMES : 1);
return [((x1 - x0) / length) * step, ((y1 - y0) / length) * step];
}
@@ -8,6 +8,8 @@
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
* · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로
* 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값.
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
* ========================================================================== */
@@ -173,3 +175,17 @@ export function fitCurveStations(
}
return planned;
}
/** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */
export function fitDrag(
cols: Columns & Pick<NodeColumns, "planned">,
base: ReadonlyArray<Vertex>,
k: number,
limits: FitLimits,
): Vertex[] {
if (base.length !== cols.planned.length)
return fitCurveStations(cols, limits, Math.max(1, k - 1));
const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex));
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1));
}
@@ -226,6 +226,8 @@ export interface StationsOptions {
intervalM?: number;
includeEndpoint?: boolean;
stationSnap?: boolean;
/** 스냅 간격(m · null = 측점 No.) — MC 측점 값 자릿수(390-5 · `station_snap_step` 짝). */
stationSnapStep?: number | null;
/** 구조물 정의(종류 · 군 · 자리꼴) — 심는 측점 판정. */
types: ReadonlyArray<PlanType>;
/** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */
@@ -286,6 +288,7 @@ export function buildStations(
? snapCurveStations(
original,
spans.map(([bc, ec]) => [bc, ec] as const),
options.stationSnapStep,
)
: original;
const gridIndex = gridIndices(original, grid, intervalM);
@@ -6,9 +6,10 @@
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
*
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations`
* ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`.
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` ·
* `mcScale` ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
* 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
* ========================================================================== */
type Vertex = [number, number];
@@ -155,12 +156,18 @@ export function curveSpans(
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 ·
* 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */
/** MC 측점 값 자릿수 배수 — 스냅 간격 1 m 미만(0.1) = 소수 한 자리(10) · 그 밖(1 · 10 m · 측점 No. = null) = 정수(1)
* (390-5). `_mc_scale` 짝. */
export const mcScale = (stepM?: number | null): number => (stepM != null && stepM < 1 ? 10 : 1);
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC·s+0.5)/s 로 옮김(s = `mcScale`) ·
* 측점 없는 호는 그대로 · 같은 값은 하나로(오름차순). `snap_curve_stations` 짝. */
export function snapCurveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
stepM?: number | null,
): number[] {
const scale = mcScale(stepM);
const values = [...stations];
for (const [start, end] of spans) {
const middle = (start + end) / 2;
@@ -170,7 +177,7 @@ export function snapCurveStations(
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
nearest = index;
});
if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5);
if (nearest >= 0) values[nearest] = Math.floor(middle * scale + 0.5) / scale;
}
return [...new Set(values)].sort((a, b) => a - b);
}
+12 -6
View File
@@ -6,8 +6,9 @@
* ========================================================================== */
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`).
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */
export function stationLabel(chainage: number, interval: number): string {
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로.
* `decimals = 1` = 소수 한 자리까지(B05 평면 스냅 간격 0.1 의 MC · 390-5 · 정수면 그대로 `5+0`). */
export function stationLabel(chainage: number, interval: number, decimals: 0 | 1 = 0): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
@@ -16,10 +17,15 @@ export function stationLabel(chainage: number, interval: number): string {
stationNumber += 1;
remainder = 0;
}
return stationPartsLabel(stationNumber, remainder);
return stationPartsLabel(stationNumber, remainder, decimals);
}
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */
export function stationPartsLabel(stationNumber: number, remainder: number): string {
return `${stationNumber}+${Math.round(remainder)}`;
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만(`decimals = 1` = 소수 한 자리까지). */
export function stationPartsLabel(
stationNumber: number,
remainder: number,
decimals: 0 | 1 = 0,
): string {
const scale = decimals ? 10 : 1;
return `${stationNumber}+${Math.round(remainder * scale) / scale}`;
}
@@ -0,0 +1,151 @@
/* 390-5 스냅 간격 = 조작 단위 — 방향키 걸음(`KeyNudge.arrowStep`) · 끌기 미리보기(`StationFit.fitDrag`) ·
* MC 측점 값 자릿수(`Frame.snapCurveStations` · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄
* (`test_390_5_snap_step_ops.py` 가 판정 · 파이썬 짝 거울은 그쪽).
* node helper_390_5_snap_step_ops.cjs '<{"grid":[…],"spans":[[bc,ec]…]}>'
*/
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 nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts"));
const station = loadTs(path.join(ROOT, "common_util", "common_util_station.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],
];
const STEPS = [0.1, 1, 10, INTERVAL];
function columns(planned) {
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),
};
}
const build = (planned) =>
curve.buildEditedPolyline(
planned,
planned.map(() => true),
planned.map(() => null),
LIMITS.minRadiusM,
);
function mcs(planned) {
const built = build(planned);
return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => (a + b) / 2);
}
const residue = (mc, step) => Math.abs(mc - Math.round(mc / step) * step);
const moved = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1])));
// ① 방향키 — 화면 축이 30° 돌고 1 px = 0.5 m 인 지도
const turn = Math.PI / 6;
const metricAt = (x, y) => [
100 + 0.5 * (x * Math.cos(turn) - y * Math.sin(turn)),
200 + 0.5 * (x * Math.sin(turn) + y * Math.cos(turn)),
];
const len = ([x, y]) => Math.hypot(x, y);
const keys = {
off: len(nudge.arrowStep("ArrowUp", false, metricAt)),
offShift: len(nudge.arrowStep("ArrowUp", true, metricAt)),
snap: Object.fromEntries(
STEPS.map((step) => [
step,
{
plain: len(nudge.arrowStep("ArrowRight", false, metricAt, step)),
shift: len(nudge.arrowStep("ArrowRight", true, metricAt, step)),
},
]),
),
};
// ② 끌기 미리보기 — 측점 No. 로 맞춘 노선에서 IP 3 을 끌어 감(간격별)
const base = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
const DRAG = 3;
const mouse = [base[DRAG][0] + 7.3, base[DRAG][1] + 4.1];
const drag = Object.fromEntries(
STEPS.map((step) => {
const limits = { ...LIMITS, intervalM: step };
const now = base.map((v, i) => (i === DRAG ? mouse : v));
const preview = fit.fitDrag(columns(now), base, DRAG, limits);
// 앞 프레임이 다른 자리를 이미 밀어 둔 상태에서도 같은 값(쌓이지 않음)
const stale = preview.map((v, i) => (i === DRAG ? mouse : [v[0] + 0.7, v[1] - 0.4]));
const again = fit.fitDrag(columns(stale), base, DRAG, limits);
// 놓을 때 정본 맞춤(`applyEdit` = fitStart → fitCurveStations)이 미리보기를 안 바꿈
const from = fit.fitStart(columns(base), columns(preview));
const release = fit.fitCurveStations(columns(preview), limits, from);
const after = mcs(preview);
const built = build(preview);
return [
step,
{
residues: after.map((mc) => residue(mc, step)),
tol: fit.mcTolM(step),
off: fit.offStationCount(built.vertices, built.curves, step),
dragMoved: Math.hypot(preview[DRAG][0] - mouse[0], preview[DRAG][1] - mouse[1]),
stale: moved(preview, again),
release: moved(preview, release),
before: moved(base.slice(0, DRAG - 1), preview.slice(0, DRAG - 1)),
},
];
}),
);
// ③ MC 측점 값 · 눈금 글
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
const snapped = Object.fromEntries(
[
["0.1", 0.1],
["1", 1],
["10", 10],
["station", null],
].map(([key, step]) => [key, frame.snapCurveStations(input.grid, input.spans, step)]),
);
const labels = {
int: station.stationLabel(119.8, 20),
one: station.stationLabel(119.8, 20, 1),
oneWhole: station.stationLabel(120, 20, 1),
oneSmall: station.stationLabel(100.1, 20, 1),
oneDefault: station.stationLabel(119.84, 20),
};
console.log(
JSON.stringify({ keys, drag, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale) }),
);
@@ -117,4 +117,4 @@ def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
def test_apply_saves_station_snap_on() -> None:
text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "routeDraftOf(nodes, { ...settings, stationSnap: true })" in text
assert "routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null })" in text
@@ -58,6 +58,6 @@ 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(snapStepM.text())") == 2
assert edit.count("applyEdit(snap.text())") == 2
snap = SNAP.read_text(encoding="utf-8")
assert "text: () => snapFitText(select.value)" in snap
@@ -0,0 +1,195 @@
"""390-5 스냅 간격 = 조작 단위(요구 243) — 스냅 켬이면 방향키 한 번 = 고른 간격 · 끄는 동안 MC 가
간격 배수에 맞은 자리로 보임 · MC 측점 값 = 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 = 정수) ·
간격을 저장 노선(곡선표 `station_snap_step`)에 같이 → 서버 측점 셈(파이썬 짝)이 같은 값.
TS 셈은 `helper_390_5_snap_step_ops.cjs`(Node) · 파이썬 짝 거울 · 저장 왕복 · 편집기 이음은 여기.
./venv/Scripts/python.exe -m pytest resources/tester/test_390_5_snap_step_ops.py -q
"""
import json
import random
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_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap_step # noqa: E402
from B05_Profile.B05_Profile_Route_Save import RouteDraftInput, RouteSaveRequest # noqa: E402
from B05_Profile.B05_Profile_Router_Replan import _write_planned_polyline # noqa: E402
from common_util.common_util_route_geometry import planned_route_working_path # noqa: E402
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_390_5_snap_step_ops.cjs")
GRID = [float(v) for v in range(0, 1001, 20)]
STEPS = {"0.1": 0.1, "1": 1.0, "10": 10.0, "station": None}
def _spans() -> list[list[float]]:
rng = random.Random(3905)
spans, at = [], 15.0
while at < 950:
start = round(at + rng.uniform(0, 30), 3)
end = round(start + rng.uniform(18, 60), 3)
spans.append([start, end])
at = end + rng.uniform(5, 25)
return spans
SPANS = _spans()
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER), json.dumps({"grid": GRID, "spans": SPANS})],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
# ── ① 방향키 ──
def test_스냅_끔_방향키는_0_1_Shift_1(got) -> None:
assert got["keys"]["off"] == pytest.approx(0.1)
assert got["keys"]["offShift"] == pytest.approx(1.0)
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_스냅_켬_방향키_한_번_고른_간격_Shift_무시(got, step) -> None:
key = got["keys"]["snap"][step]
assert key["plain"] == pytest.approx(float(step))
assert key["shift"] == pytest.approx(float(step))
# ── ② 끌기 미리보기 ──
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_끄는_동안_MC_가_간격_배수(got, step) -> None:
drag = got["drag"][step]
assert drag["off"] == 0
assert max(drag["residues"]) <= drag["tol"]
assert drag["dragMoved"] <= float(step) / 2 + 1e-6 # 끈 IP 는 접선 따라 ±간격/2 안만
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None:
drag = got["drag"][step]
assert drag["stale"] == 0
assert drag["release"] == 0
assert drag["before"] == 0 # 끈 IP 앞앞 IP 는 그대로
def test_끌기_방향키_편집기_이음() -> None:
pointer = (B05 / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(encoding="utf-8")
keys = (B05 / "B05_Profile_UI_RouteEdit_Keys.ts").read_text(encoding="utf-8")
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "params.fitDrag(dragNode)" in pointer
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
assert edit.count("\n") <= 695
# ── ③ MC 측점 값 · 파이썬 짝 ──
def test_자릿수_배수(got) -> None:
assert got["scale"] == [10, 1, 1, 1]
assert [core._mc_scale(v) for v in (0.1, 1.0, 10.0, None)] == [10, 1, 1, 1]
@pytest.mark.parametrize("key", list(STEPS))
def test_MC_측점_값_자릿수(got, key) -> None:
values = got["snapped"][key]
moved = [v for v in values if v not in GRID]
assert moved, "호 안 측점이 MC 로 옮겨져야 함"
scale = 10 if key == "0.1" else 1
assert all(abs(v * scale - round(v * scale)) < 1e-9 for v in values)
if key == "0.1":
assert any(abs(v - round(v)) > 1e-9 for v in moved) # 소수 한 자리 MC 가 실제로 생김
@pytest.mark.parametrize("key", list(STEPS))
def test_파이썬_짝_거울(got, key) -> None:
py = [float(v) for v in core.snap_curve_stations(GRID, SPANS, STEPS[key])]
assert py == got["snapped"][key]
def test_옛_부르기는_정수_그대로() -> None:
old = [float(v) for v in core.snap_curve_stations(GRID, SPANS)]
assert old == [float(v) for v in core.snap_curve_stations(GRID, SPANS, None)]
assert all(v == round(v) for v in old)
def test_눈금_글_0_1_때만_소수_한_자리(got) -> None:
labels = got["labels"]
assert labels["int"] == "5+20" and labels["oneDefault"] == "5+20" # 다른 화면 = 정수(09-29)
assert labels["one"] == "5+19.8" and labels["oneSmall"] == "5+0.1"
assert labels["oneWhole"] == "6+0"
overlay = (ROOT / "B04_PreProcess" / "B04_PreProcess_UI_MapOverlays.ts").read_text(
encoding="utf-8"
)
assert "stationLabel(chainage, interval, options.chainages ? 1 : 0)" in overlay
# ── 저장 왕복 ──
def test_초안_간격이_요청으로_펴짐() -> None:
nodes = [(0.0, 0.0), (50.0, 10.0), (100.0, 0.0)]
draft = RouteDraftInput(nodes=nodes, stationSnap=True, stationSnapStep=0.1)
resolved = RouteSaveRequest(draft=draft).resolved()
assert resolved.station_snap is True and resolved.station_snap_step == 0.1
old = RouteSaveRequest(draft=RouteDraftInput(nodes=nodes, stationSnap=True))
assert old.resolved().station_snap_step is None
@pytest.mark.parametrize(
("snap", "step", "want"),
[
(True, 0.1, 0.1),
(True, 1.0, 1.0),
(True, 10.0, 10.0),
(True, None, None),
(False, 0.1, None),
],
)
def test_곡선표_저장_왕복(tmp_path, snap, step, want) -> None:
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
points = [(0.0, 0.0), (120.0, 15.0), (240.0, -10.0), (360.0, 30.0)]
_write_planned_polyline(path, points, 30.0, station_snap=snap, station_snap_step=step)
assert load_station_snap_step(tmp_path) == want
def test_옛_곡선표는_측점_No(tmp_path) -> None:
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x,y\n0,0\n10,0\n", encoding="utf-8")
path.with_name(f"{path.stem}_curves.json").write_text(
json.dumps({"curves": [], "station_snap": True}), encoding="utf-8"
)
assert load_station_snap_step(tmp_path) is None
def test_서버_측점_셈이_저장_간격을_씀() -> None:
sections = (B05 / "B05_Profile_Engine_Sections.py").read_text(encoding="utf-8")
assert sections.count("station_snap_step=load_station_snap_step(project_root)") == 2
design = ROOT / "A00_Common" / "design"
page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8")
assert "stationSnapStep: routePlan.station_snap_step" in page
stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8")
assert "stationSnapStep: ctx.inputs.route?.stationSnapStep" in stage
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.