diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 9eba943e4..14c71d043 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -571,6 +571,7 @@ function createPagePipeline( intervalM: options.stationIntervalM, types: enabled, stationSnap: routePlan.station_snap ?? false, + stationSnapStep: routePlan.station_snap_step, }); crossSource = null; }) diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index fcd29e639..c2df06dc0 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -43,7 +43,7 @@ export function reuseOutside( /** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */ export function createStationsStage( - options: Omit & { + options: Omit & { types: () => ReadonlyArray; }, ): 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); }; diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index b25e5a20c..dae2d46c4 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -73,6 +73,8 @@ export interface RouteDraft { curveArc: Array; /** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */ stationSnap: boolean; + /** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */ + stationSnapStep?: number | null; /** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */ terrain: string | null; } diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index e46176d19..8cbc5c527 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -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; } @@ -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; // 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를 diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 9379b468f..c051965d5 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -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 | null }; /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 6c38e87f6..a69c0a568 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index e7ee4aad1..87d7080f1 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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} # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 6afb130af..552e96362 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -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, } ) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index a103411ca..64964fc03 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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, diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 5221c9d7d..2ca2d0c92 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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"`.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 641dd9c37..149e8076c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index b18034fed..9e7da0873 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -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 }; 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; - settings: () => { stationSnap: boolean; terrain: string | null }; + settings: () => RouteSettings; saved: () => void; rebuild: () => void | Promise; } @@ -64,10 +66,7 @@ interface RouteSource { const sources = new Map(); /** 노드 · 곡선 편집값 → 계약 `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, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index de49bffad..11e75ec11 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -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; @@ -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; }; /** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts index 0e4314064..b90b7fb78 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index e01b6d2f1..de3998356 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -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()}`; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts index c149ef32f..8ca3e6c87 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts @@ -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; + +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, curves: ReadonlyArray) => number[] | null; +}; -/** - * 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. - * 돌려주는 함수 = 지금 스냅 간격(m) · `.text()` = 스냅 뒤 상태줄 글. - */ +/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */ export function bindStationSnap( chrome: Pick, 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, curves: ReadonlyArray) => + on() + ? snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curves), + step(), + ) + : null, + }); } diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts index 8594280d2..ed76e01d0 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts @@ -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]; } diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 609e1cadb..23f1aa63e 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -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, + base: ReadonlyArray, + 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)); +} diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index 7782a524b..c9b28861c 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -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; /** 종류 → 옵션 기본값(카드 이름) — `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); diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts index 67bc9aeb8..5d33896c6 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts @@ -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, spans: ReadonlyArray, + 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); } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index ec199c0e2..2b8ee8076 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -54,7 +54,7 @@ import { import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { useStationKeys } from "./B06_Section_Station_Keys"; -import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; +import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { withPipeOptions } from "./B06_Section_Section_Adjusts"; @@ -156,16 +156,26 @@ export function holdScreenAlignment(line: ScreenLine): () => void { }; } -/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */ -export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean { +/** 화면 선을 얹은 견줌 꼴 — 화면 선이 없으면 상세 그대로. */ +function againstScreen(detail: SectionDetailResponse, routeId: number) { const screen = screenLine?.(routeId); - if (!screen) return hasStaleDesigns(detail); - return hasStaleDesigns({ + if (!screen) return detail; + return { longitudinal: { design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])], }, cross_sections: detail.cross_sections, - }); + }; +} + +/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */ +export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean { + return hasStaleDesigns(againstScreen(detail, routeId)); +} + +/** 화면 선 기준 낡은 측점 지문(모두 맞으면 "") — 상세 가게가 같은 낡음에 cross 를 되풀이하지 않게(387-8). */ +export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: number): string { + return staleKey(againstScreen(detail, routeId)); } /** diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 818a387d2..a78f05d03 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -31,7 +31,7 @@ import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; -import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; +import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types"; import { @@ -74,7 +74,11 @@ async function withDraftWalls( await withDraftShapes(detail, projectId); withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도) await withPipeOptions(detail, projectId); // 관 옵션 벽 높이 · 형태 · 흙막이 단(387-4b) - return withStructureAreas(detail); + withStructureAreas(detail); + // 초안 자리 미리 뜬 횡단 = 서버가 저장 계획선으로 셈 · 묶음 cross 뒤에 얹힐 수 있음 — 다 돈 뒤 지금 계획선과 + // 갈리면 cross 한 번 더(387-8 · 낡음 판정 = 가게 몫 · 종단 판이 접혀 그리기가 안 돌아도) + recrossSaved(projectId, keyOf(projectId, routeId), detail, routeId); + return detail; } /** @@ -175,20 +179,36 @@ export async function swapSectionStations( return { detail: swap.detail, moves: swap.moves }; } +/** 상세마다 마지막으로 cross 를 부탁한 낡음 지문 — 다시 셈이 못 고친 낡음에 되풀이하지 않음(387-8). */ +const askedStale = new WeakMap(); + /** 저장 노선 측점을 갈아끼움 — 그 차례 cross 는 옛 상세를 봐서 새 상세 설계 · 면적이 안 셈 → 묶음이 다 돈 뒤(새 측점 * 종단 · 화면 선이 선 뒤 · 그 전에 세면 계획고 0.5 mm 갈림) cross 한 번 더(387-4b · B05 만 연 면적 = B06). 노선 초안 - * 동안은 묶음 새 노선 상세가 맡음. 묶음은 늦게 불러옴(가게 ↔ 묶음 순환 없음). */ -function recrossSaved(projectId: string, key: string, detail: SectionDetailResponse): void { + * 동안은 묶음 새 노선 상세가 맡음. 묶음은 늦게 불러옴(가게 ↔ 묶음 순환 없음). + * `staleOf`(노선 번호)를 주면 화면 선 대비 낡을 때만 · 같은 낡음은 한 번만(387-8 · 묶음이 아직 안 섰으면 그 첫 차례 몫). */ +function recrossSaved( + projectId: string, + key: string, + detail: SectionDetailResponse, + staleOf?: number, +): void { void Promise.all([ import("../A00_Common/design/design_pipeline_cross"), import("../A00_Common/design/design_pipeline_page"), ]) .then(async ([cross, page]) => { - await page.reconcilePagePipeline(projectId); + // 낡음 확인 = 지금 차례만 기다림(맞춤은 묶음 몫 · 읽을 때마다 맞추면 되돌리기 차례가 갈림) + if (staleOf === undefined) await page.reconcilePagePipeline(projectId); + else await page.pagePipeline(projectId)?.settled(); // 그 사이 다른 상세 · 다시 셈 창구 없음(묶음만 · 시험) · 노선 초안 = 안 부탁 if (cache.get(key) !== detail) return; - if (!page.pipelineOwnsCross(projectId) || page.pagePipeline(projectId)?.input("route")) - return; + const pipe = page.pagePipeline(projectId); + if (!page.pipelineOwnsCross(projectId) || pipe?.input("route")) return; + if (staleOf !== undefined) { + const stale = pipe?.generation ? staleKeyAgainstScreen(detail, staleOf) : ""; + if (!stale || askedStale.get(detail) === stale) return; + askedStale.set(detail, stale); + } cross.forceCross(); page.pipelineCross(projectId); }) diff --git a/common_util/common_util_station.ts b/common_util/common_util_station.ts index 3fb2d306d..471d77ad7 100644 --- a/common_util/common_util_station.ts +++ b/common_util/common_util_station.ts @@ -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}`; } diff --git a/resources/tester/helper_387_8_folded_profile.cjs b/resources/tester/helper_387_8_folded_profile.cjs new file mode 100644 index 000000000..cd0e0dc96 --- /dev/null +++ b/resources/tester/helper_387_8_folded_profile.cjs @@ -0,0 +1,209 @@ +/* 387-8 헬퍼 — 종단 판이 접힘(그리기 · 그리기 낡음 판정 없음) + 관 초안 둔 채 다시 열기: 진짜 상세 가게 + * (`B06_Section_Section_Store`) · 진짜 낡음 판정(`B06_Section_Cross_Refresh` staleKeyAgainstScreen)이 묶음 cross + * 뒤에 얹힌 미리 뜬 측점(서버가 옛 계획고로 셈)을 보고 묶음에 cross 한 번 더 부탁하는지. 묶음 · 초안 얹기 · 서버는 가짜 — + * 가짜 cross = 상세 측점마다 계획고를 지금 계획선으로(면적 = 계획고 함수). 짝 = `test_387_8_folded_profile.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const P = "P387"; +const ROUTE = 7; +const PLAN = 100; // 지금 계획선(평평) + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; + +const anyModule = (extra = {}) => + new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) }); + +/** 가짜 계산 묶음 — 부탁 수 · 강제 수 · cross = 상세 계획고 맞춤. */ +const pipe = { generation: 1, route: null, crosses: 0, forced: 0, fix: true }; +const areaOf = (elevation) => Math.round((elevation - 90) * 1000) / 1000; +function cross() { + pipe.crosses += 1; + if (!pipe.fix) return; + for (const section of storeDetail().cross_sections) { + section.design = { + ...section.design, + design_elevation_m: PLAN, + cut_area_m2: areaOf(PLAN), + }; + } +} +let previewAt = null; // 관 초안 자리(미리 뜬 측점) +const STUBS = { + "design_pipeline_page.ts": { + reconcilePagePipeline: async () => 0, + pipelineOwnsCross: () => true, + pagePipeline: () => ({ + generation: pipe.generation, + input: () => pipe.route, + settled: async () => undefined, + }), + pipelineCross: () => cross(), + }, + "design_pipeline_cross.ts": { forceCross: () => void (pipe.forced += 1) }, + // 초안 측점 — 관 초안 자리에 서버가 미리 뜬 횡단(저장 계획선 = 옛 계획고 102)을 얹음 + "B06_Section_Section_Drafts.ts": { + forgetDraftStations: () => undefined, + withDraftShapes: async () => undefined, + withDraftStations: async (detail) => { + if (previewAt === null || detail.cross_sections.some((s) => s.preview)) return; + detail.cross_sections.push({ + chainage_m: previewAt, + kind: "irregular", + preview: true, + samples: [], + design: { design_elevation_m: 102, cut_area_m2: areaOf(102) }, + }); + detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m); + }, + }, + "B05_Profile_Api_PlusStations.ts": { + loadSavedPlusStations: async () => undefined, + plusStationsOf: () => [], + }, + "B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null }, + "design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) }, + "B06_Section_Api_Fetch.ts": anyModule(), + "B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }), + "B06_Section_Swap_Bus.ts": anyModule(), + "B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }), + "B06_Section_UI_Page_Persist.ts": anyModule(), + "B06_Section_UI_Standard_Panel.ts": anyModule(), + "B06_Section_Cross_Design_Session.ts": anyModule(), + "common_util_cross_design.ts": anyModule(), + "B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(), + "B06_Section_UI_Cross_Ford_Geom.ts": anyModule(), + "B06_Section_UI_Cross_Box_Geom.ts": anyModule(), + "B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "config_frontend.ts": anyModule({ PIPE_TYPES: [] }), +}; + +const cache = new Map(); +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(full) { + const stub = STUBS[path.basename(full)]; + if (stub) return stub; + 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, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const store = loadTs(B06("B06_Section_Section_Store")); +const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const storeDetail = () => store.peekSectionDetail(P); + +/** 저장 정본 — 격자 0 · 20 · 40 · 60(지금 계획선으로 셈한 설계) · 계획선 평평 100. */ +const canon = () => ({ + longitudinal: { + design_profiles: [ + { + samples: [ + { chainage_m: 0, elevation_m: PLAN }, + { chainage_m: 60, elevation_m: PLAN }, + ], + }, + ], + }, + cross_sections: [0, 20, 40, 60].map((c) => ({ + chainage_m: c, + kind: c === 0 ? "bp" : "regular", + samples: [], + design: { design_elevation_m: PLAN, cut_area_m2: areaOf(PLAN) }, + })), +}); +const tick = () => new Promise((r) => setTimeout(r, 20)); +const sum = () => + Math.round(storeDetail().cross_sections.reduce((a, s) => a + s.design.cut_area_m2, 0) * 1000) / + 1000; +const mixed = () => + storeDetail() + .cross_sections.filter((s) => s.design.design_elevation_m !== PLAN) + .map((s) => s.chainage_m); +const counts = () => ({ crosses: pipe.crosses, forced: pipe.forced }); + +async function open(fresh = true) { + if (fresh) { + store.invalidateSectionDetail(P); + writeState("section-detail", canon(), P, ROUTE); + } + await store.loadSectionDetail(P, ROUTE); + await tick(); +} + +(async () => { + const out = {}; + // 펼친 때 기준 — 미리 뜬 측점이 첫 cross 전에 얹혀 같이 셈 + previewAt = 30; + await open(); + cross(); + out.expanded = { sum: sum(), mixed: mixed() }; + // 접힌 종단 판 + 관 초안 다시 열기 — 미리 뜬 측점이 cross 뒤에 얹힘(그리기 판정 없음) → 가게가 한 번 더 부탁 + Object.assign(pipe, { crosses: 0, forced: 0 }); + await open(); + out.folded = { sum: sum(), mixed: mixed(), ...counts() }; + // 같은 상세 다시 읽음(보기 전환) — 낡음 없음 = 부탁 없음 + await open(false); + out.again = counts(); + // 다시 셈이 못 고치는 낡음 — 한 번만 부탁(되풀이 없음) + Object.assign(pipe, { crosses: 0, forced: 0, fix: false }); + await open(); + await open(false); + await open(false); + out.unfixed = counts(); + pipe.fix = true; + // 묶음이 아직 안 섬(첫 차례 몫) · 노선 초안(묶음 새 노선 상세 몫) = 안 부탁 + Object.assign(pipe, { crosses: 0, forced: 0, generation: 0 }); + await open(); + out.notStarted = counts(); + Object.assign(pipe, { crosses: 0, forced: 0, generation: 1, route: { rev: 1 } }); + await open(); + out.routeDraft = counts(); + pipe.route = null; + // 관 초안 없음 — 낡음 없음 = 부탁 없음 + Object.assign(pipe, { crosses: 0, forced: 0 }); + previewAt = null; + await open(); + out.noDraft = { sum: sum(), ...counts() }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/helper_390_5_snap_step_ops.cjs b/resources/tester/helper_390_5_snap_step_ops.cjs new file mode 100644 index 000000000..bc613af8c --- /dev/null +++ b/resources/tester/helper_390_5_snap_step_ops.cjs @@ -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) }), +); diff --git a/resources/tester/test_383_1_route_station_default.py b/resources/tester/test_383_1_route_station_default.py index 11fc6f0bc..889991380 100644 --- a/resources/tester/test_383_1_route_station_default.py +++ b/resources/tester/test_383_1_route_station_default.py @@ -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 diff --git a/resources/tester/test_387_8_folded_profile.py b/resources/tester/test_387_8_folded_profile.py new file mode 100644 index 000000000..2e3f29cb6 --- /dev/null +++ b/resources/tester/test_387_8_folded_profile.py @@ -0,0 +1,55 @@ +"""387-8 종단 판이 접혀도(그리기 낡음 판정 없음) 관 초안 둔 채 다시 열기 = 펼친 때 값 — 미리 뜬 측점 안 섞임. + +· 관 초안 자리 미리 뜬 횡단(서버가 저장 계획선 = 옛 계획고로 셈)이 묶음 cross 뒤에 얹히면 상세 가게가 묶음이 다 돈 뒤 + 화면 선 기준 낡음을 보고 cross 한 번 더(낡음 판정 = 가게 몫 · 원칙 4 — 보기가 숨어도 계산 · 판정은 돎) +· 같은 낡음 = 한 번만 · 묶음이 아직 안 섬 · 노선 초안 = 안 부탁 + +./venv/Scripts/python.exe -m pytest resources/tester/test_387_8_folded_profile.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_387_8_folded_profile.cjs" + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_접힌_종단_판_다시_열기_펼친_때와_같음(out: dict) -> None: + assert out["folded"]["mixed"] == [] and out["expanded"]["mixed"] == [] + assert out["folded"]["sum"] == out["expanded"]["sum"] + assert (out["folded"]["crosses"], out["folded"]["forced"]) == (1, 1) + + +def test_되풀이_없음(out: dict) -> None: + assert out["again"] == {"crosses": 1, "forced": 1} # 다시 읽어도 더 안 부탁 + assert out["unfixed"] == {"crosses": 1, "forced": 1} # 못 고친 낡음 = 한 번만 + + +@pytest.mark.parametrize("case", ["notStarted", "routeDraft", "noDraft"]) +def test_안_부탁(out: dict, case: str) -> None: + assert (out[case]["crosses"], out[case]["forced"]) == (0, 0) + + +def test_부르는_곳() -> None: + store = (ROOT / "B06_Section/B06_Section_Section_Store.ts").read_text(encoding="utf-8") + walls = store.split("async function withDraftWalls", 1)[1].split("\n}\n", 1)[0] + assert "recrossSaved(projectId, keyOf(projectId, routeId), detail, routeId);" in walls + body = store.split("function recrossSaved", 1)[1].split("\n}\n", 1)[0] + assert "staleKeyAgainstScreen(detail, staleOf)" in body and "askedStale" in body + # 종단 판 그리기는 기다림 표시만(다시 셈 부탁 없음 · 387-4) + preview = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Preview.ts").read_text(encoding="utf-8") + assert "forceCross" not in preview diff --git a/resources/tester/test_390_4_snap_step_text.py b/resources/tester/test_390_4_snap_step_text.py index 14c7ace46..3f4766817 100644 --- a/resources/tester/test_390_4_snap_step_text.py +++ b/resources/tester/test_390_4_snap_step_text.py @@ -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 diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py new file mode 100644 index 000000000..8c2f210f2 --- /dev/null +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -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.