diff --git a/.gitattributes b/.gitattributes index 15e48d527..edae4fffe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,3 @@ B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary -# graphify 그래프 — 줄끝을 바꾸면 서명(.sig)이 깨지고 창마다 헛 diff 가 남는다. -docs/wiki/graphify-out/** -text diff --git a/.prettierignore b/.prettierignore index 2e94d1794..3940c7f6a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,3 @@ dist/ venv/ storage/ tmp/ -graphify-out/ diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b7d5234e5..8d6e8082e 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -74,7 +74,7 @@ export const STATE_REGISTRY = { "routeedit-locks": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */ "routeedit-terrain": { bucket: "pref", scope: "project" }, - /** B05 계획노선 편집 창 — 측점 스냅 간격(0.1 · 1 · 10 · station) */ + /** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */ "routeedit-snap-step": { bucket: "pref", scope: "project" }, "masshaul-open": { bucket: "pref", diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 8f29d14e0..9b2c1a00a 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -2,9 +2,8 @@ * design_dock.ts * B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b). * - * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**: - * [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는 - * 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 + * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**: + * [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 * 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫). * ========================================================================== */ @@ -26,15 +25,13 @@ export interface DesignDockActions { reset: DesignDockAction; save: DesignDockAction; confirm: DesignDockAction; - /** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */ - switchView: DesignDockAction; } export interface DesignDock { root: HTMLElement; - /** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ + /** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ setActions: (actions: Partial) => void; - /** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */ + /** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */ setView: (view: DesignView) => void; /** 「계산 중」 점 — 묶음 차례가 떠 있는 동안 켬(386-2). */ setBusy: (busy: boolean) => void; @@ -44,11 +41,6 @@ function label(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */ -export function switchViewLabel(view: DesignView): string { - return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile"); -} - /** * 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」 * 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). @@ -93,20 +85,13 @@ export function createDesignDock( variant: "filled", onClick: press("confirm"), }); - const switchButton = createButton({ - label: switchViewLabel(view), - variant: "ghost", - onClick: press("switchView"), - }); resetButton.dataset.dockAction = "reset"; saveButton.dataset.dockAction = "save"; confirmButton.dataset.dockAction = "confirm"; - switchButton.dataset.dockAction = "switchView"; const buttons = { reset: resetButton, save: saveButton, confirm: confirmButton, - switchView: switchButton, }; const sync = (): void => { @@ -116,12 +101,10 @@ export function createDesignDock( if (action.title) buttons[name].title = action.title; else buttons[name].removeAttribute("title"); }); - (switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent = - switchViewLabel(current); row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; }; - row.append(resetButton, saveButton, confirmButton, switchButton); + row.append(resetButton, saveButton, confirmButton); root.append(...above, divider, row, busy); sync(); diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 3a1c0b7fc..cf56a56db 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -58,6 +58,7 @@ import { followPlusDraft, plusStationsOf, readPlusDraft, + savedPlusRows, } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; import { @@ -258,6 +259,7 @@ function createPagePipeline( const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j) const followStage = createFollowStage({ savedLine, + savedPlus: () => savedPlusRows(projectId), // 추가 측점 정본 좌표에서 따라감(493-3) saved: () => { const before = beforeDraft ?? facilitiesNow(); return { @@ -287,7 +289,11 @@ function createPagePipeline( follow: (ctx) => { const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null; replay = false; - if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) + // 관 · 구조물 · 추가 측점도 그 걸음 사진(사진첩이 되쓴 가게 값) — 묶음 입력에 넣어야 측점이 따라옴(280-3) + if (kept) { + const { pipes, structures, plusStations } = inputsNow(); + return { alignment: kept, pipes, structures, plusStations }; + } const line = ctx.before.route?.line ?? savedLine() ?? []; // 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410) const frame = (list: unknown, saved: unknown) => diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 304bdb07f..ccd46475f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { releaseSelectAfterPick } from "@ui/ui_template_native_menu"; import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ @@ -101,6 +102,8 @@ function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { true, ), ); + // 새 창 드롭다운도 고른 뒤 초점 뺌 — change 는 메인으로 안 넘김(요구 280-6). + releaseSelectAfterPick(next); // 새 창이 초점을 잃으면 길게 누르기를 끊는다(메인 창 blur 듣기). next.addEventListener("blur", () => window.dispatchEvent(new FocusEvent("blur"))); // 새 창 크기 기준으로 다시 맞춘다. diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 4a7c4fe4d..cef8daf8e 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -11,13 +11,12 @@ * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ -import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { readState, writeState } from "../b_page_state"; -import { currentRoute, navigateTo, remountRoute } from "../router"; +import { currentRoute, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context"; import { createStructuresSection, type StructuresSection } from "../b_structures_section"; @@ -330,7 +329,6 @@ function routeIdOf(shared: DesignPageShared): number | null { /** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */ export function sharedPanel(shared: DesignPageShared): DesignPanel { if (shared.panel) return shared.panel; - const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile"); const panel = createDesignPanel( { projectId: shared.projectId, @@ -345,10 +343,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { // 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다. save: { run: () => saveSharedDesign(shared, false), enabled: false }, confirm: { run: () => saveSharedDesign(shared, true), enabled: false }, - // [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦). - switchView: { - run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION), - }, }, }, shared.view(), diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index 7dbb41b62..651fabd86 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -136,6 +136,14 @@ export function createProfileSections(deps: ProfileSectionsDeps) { migratedRoutes.add(migrateKey); void migrateLegacyStations(projectId, routeId) .then((result) => { + const etc = result.legacy_etc ?? []; + if (etc.length) + showToast( + `옛 측점 이관이 만든 「기타 구조물」 ${etc.length}곳(${etc.map((m) => `${m}m`).join(" · ")}) — ` + + "측점 없는 구조물입니다. 필요 없으면 구조물 목록에서 지우세요.", + "warning", + 8000, + ); if (result.migrated > 0) { showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); return deps diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index f63d5fc4a..1c1e6cc14 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -39,7 +39,7 @@ * * 측점 열쇠(344-3 · `stationKey` · `B05_Profile/logic/B05_Profile_Sections_Stations.ts`): * | 측점 | 열쇠 | 뜻 | - * | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 · 스냅으로 옮긴 측점은 원래 번호 | + * | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 | * | 관 | pipe: | 관 세션 id(없으면 `pipe-{누가거리 2자리}`) | * | 구조물 | structure: | structure_id · 구간형 시작 · 끝 = `:start` · `:end` 덧붙임 | * | 시종점 | end:BP · end:EP | 그 자리 관 · 구조물이 있어도 시종점(서버 bp · ep 와 같음) | @@ -72,7 +72,7 @@ export interface RouteDraft { curveArc: Array; /** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */ stationSnap: boolean; - /** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */ + /** 스냅 간격(m · 10 · null·없음 = 측점 No. · 280-4) — MC 측점 값 자릿수(390-5). */ stationSnapStep?: number | null; /** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */ terrain: string | null; diff --git a/A00_Common/main.ts b/A00_Common/main.ts index e7ea76d3e..647de8f24 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -13,7 +13,11 @@ import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시( import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1) import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; -import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; +import { + blockNativeMenu, + NATIVE_MENU_ROUTE, + releaseSelectAfterPick, +} from "@ui/ui_template_native_menu"; import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe"; import { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; @@ -58,6 +62,8 @@ function bootstrap(): void { // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); + // 드롭다운을 마우스로 고르면 초점 뺌 — 휠 · ↑↓ 가 값을 또 바꾸지 않게(요구 280-6) + releaseSelectAfterPick(window); // B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220) initLoupe(window, () => LOUPE_ROUTE.test(currentRoute())); diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 6360da2e2..d2b7132b0 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -366,9 +366,10 @@ def _raw_payload( **point.as_dict(), "chainage_m": round(pipe.chainage_m, 2), "lonlat": list(to_lonlat(pipe.x, pipe.y)), - # 노선 위 자리(사업지 CRS) — 화면이 따라가기만 한 관을 가림(rebasePipeDraft · 428) - "x": round(float(pipe.x), 3), - "y": round(float(pipe.y), 3), + # 땅 위 자리(사업지 CRS · 정본 좌표 · 없으면 노선 위 자리) — 화면이 따라가기만 한 관을 + # 가림(rebasePipeDraft · 428) · 노선 위 자리를 주면 [저장] 다시 얹기가 정본을 덮음(493-4) + "x": round(float(pipe.x if point.x is None else point.x), 3), + "y": round(float(pipe.y if point.y is None else point.y), 3), "source": pipe.reason, } for pipe, point in zip(detail.pipes, points) diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index 0ba88adc1..2a2093345 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -11,8 +11,11 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend"; import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; import { onPageHistory } from "./B05_Profile_UI_Profile_History"; import type { SavePart } from "../A00_Common/design/design_save_api"; +import type { FollowingStructure } from "../common_util/common_util_route_follow"; const saved = new Map(); +/** 저장분 땅 위 자리(누가거리 · x · y) — 노선 초안 동안 이 좌표에서 따라감(493-3). */ +const savedRows = new Map(); const loaded = new Map>(); async function request(projectId: string, init: RequestInit = {}): Promise { @@ -28,12 +31,20 @@ async function request(projectId: string, init: RequestInit = {}): Promise null); if (!response.ok) throw new Error(payload?.message ?? `요청이 실패했습니다 (${response.status}).`); + if (Array.isArray(payload?.stations)) savedRows.set(projectId, payload.stations); return Array.isArray(payload?.chainages) ? payload.chainages.map(Number) : []; } finally { window.clearTimeout(timer); } } +/** 저장분 추가 측점 땅 위 자리 — 좌표를 못 받았으면 누가거리만(노선 위 자리로 놓음). */ +export const savedPlusRows = (projectId: string): FollowingStructure[] => + savedRows.get(projectId) ?? (saved.get(projectId) ?? []).map((chainage_m) => ({ chainage_m })); + +/** 서버 저장분을 지금 받음(초안 · 받아 둔 값 안 봄) — 추천 [적용] 확인 창 지워질 수(493-2). */ +export const fetchSavedPlusStations = (projectId: string): Promise => request(projectId); + export const readPlusDraft = (projectId: string): number[] | null => useDesignStore(projectId, null).get("plusStations"); @@ -138,6 +149,7 @@ export function plusStationsPart(projectId: string): SavePart { body: draft ? { chainages: draft } : null, after: async (response) => { const given = response?.chainages; + if (Array.isArray(given)) savedRows.delete(projectId); // 고쳐 쓴 저장분 = 노선 위 자리(누가거리로 놓음) const list = Array.isArray(given) ? given.map(Number) : await request(projectId); saved.set(projectId, list); loaded.set(projectId, Promise.resolve()); @@ -146,3 +158,12 @@ export function plusStationsPart(projectId: string): SavePart { }, }; } + +/** 저장분을 다시 받음 — 추천 [적용] 뒤(서버가 추가 측점을 지움) 옛 저장분 · 따라감 기억이 남지 않게(493-5). */ +export async function reloadSavedPlusStations(projectId: string): Promise { + [saved, savedRows, loaded, beforeRoute, followedOnly, warnedOutside].forEach((map) => + map.delete(projectId), + ); + await loadSavedPlusStations(projectId); + notify(projectId); +} diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 2b1d11a84..5c773c27a 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -19,6 +19,7 @@ import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import type { RouteReset } from "./logic/B05_Profile_Route_Reset"; /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; @@ -117,7 +118,7 @@ export interface RoutePlanResponse { edited: boolean; /** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */ station_snap?: boolean; - /** 스냅 간격(m · 0.1 · 1 · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */ + /** 스냅 간격(m · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */ station_snap_step?: number | null; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; @@ -142,6 +143,8 @@ export interface RouteReplanResponse { /** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */ drainage_stale?: boolean; initial_refreshed?: boolean; + /** 노선 모양 바뀜 = 초기화(492) — 지운 사용자 구조물 · 관 · 추가 측점 수 · 옛 서버는 없음. */ + route_reset?: RouteReset; } /** 노선 저장 본문 — `draft` 는 계약 `RouteDraft` 그대로 · `as_initial` = 추천 [적용](초기값 다시 뜸). */ diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index febb3397e..5e2b4f362 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -329,7 +329,13 @@ export async function saveStructures( export async function migrateLegacyStations( projectId: string, routeId: number, -): Promise<{ status: string; migrated: number; revision: number }> { +): Promise<{ + status: string; + migrated: number; + revision: number; + /** 옛 이관이 만든 「기타 구조물」 누가거리(지우지 않음 · 알림만 · 280-3). */ + legacy_etc?: number[]; +}> { return requestJson(`/projects/${projectId}/route/structures/migrate`, { method: "POST", body: JSON.stringify({ route_id: routeId }), diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index ae629a60f..457b7f28b 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -3,8 +3,8 @@ [적용] 체인(`B05_Profile_Route_Save.run_route_save_chain`)과 같은 조각을 메모리에서 부름(파일 안 씀): 후보 노드 → 화면 `flattenServerPlan` 꼴(`draft_nodes`) → `build_planned_polyline`(편집 갈래 · csv - 4자리) → 관 · 구조물 · 추가 측점 따라감(`followed_pipes` · `followed_structures` · - `followed_plus_rows` · 새 세류선 교차 관 포함) → 측점 틀 · 1차 계획선 438(`build_sections`) → + 4자리) → 초기화 시설(492 · `reset_facilities` = 새 세류선 교차 관만 · 사용자 구조물 · 추가 측점 · + 관 버림) → 측점 틀 · 1차 계획선 438(`build_sections`) → 「종단 반영」 켬이면 1 m 균형 종단을 꼭짓점에 얹음(`seat_base_pvi` → `rebuild_alignment_profile`). [적용]은 이 값을 받아 적지 않음 — 서버가 체인에서 같은 조각으로 다시 셈(CLAUDE.md 5). @@ -88,19 +88,12 @@ def candidate_line( return line, [curve.as_dict() for curve in outline.curves] -def followed_facilities( - project_root: Path, - line: list[tuple[float, float]], - old_route_data_path: str | None, - old_xy: list[list[float]], - old_pipes: list, - streams: list, -) -> tuple[list, list, list[float] | None]: - """체인과 같은 따라감(파일 안 씀) → (관 · 구조물 · 추가 측점 누가거리 | 없음 None).""" - from B05_Profile.B05_Profile_Plus_Stations import followed_plus_rows, load_plus_stations +def reset_facilities( + line: list[tuple[float, float]], streams: list +) -> tuple[list, list, list[float]]: + """체인 초기화(492)와 같은 시설(파일 안 씀) → (관 = 새 노선 세류선 교차 기본 관만 · + 구조물 없음 · 추가 측점 없음).""" from B05_Profile.B05_Profile_Route_Save import followed_pipes - from B05_Profile.B05_Profile_Structures_Repository import load_structures - from B05_Profile.B05_Profile_Structures_Router import followed_structures from common_util.common_util_drainage_pipes import ( ensure_pipe_ids, fill_pipe_coordinates, @@ -108,21 +101,13 @@ def followed_facilities( ) points = [{"x": x, "y": y} for x, y in line] - items = load_structures(str(project_root))[1] - if items: - followed_structures(project_root, items, old_route_data_path, points) - rows = load_plus_stations(project_root) - plus = ( - sorted({float(r["chainage_m"]) for r in followed_plus_rows(rows, line)["rows"]}) - if rows - else None - ) - pairs, _crossing, vertices = followed_pipes(old_pipes, old_xy, points, streams) + pairs, _crossing, vertices = followed_pipes([], [], points, streams) pipes = [pipe for _, pipe in pairs] fill_pipe_coordinates(pipes, vertices) ensure_pipe_ids(pipes) # 체인은 관 정본을 쓰고 다시 읽음(지문 같음 = 저장분 그대로) — 같은 왕복 - return parse_pipe_points([pipe.as_dict() for pipe in pipes]), items, plus + # 추가 측점 = 빈 목록(None 이면 측점 틀이 저장 파일을 읽음) + return parse_pipe_points([pipe.as_dict() for pipe in pipes]), [], [] def initial_longitudinal( diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 108484692..4960428ae 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -107,10 +107,10 @@ def load_station_snap(project_root: Path) -> bool: def load_station_snap_step(project_root: Path) -> float | None: """스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘). - 칸 없으면 None(측점 No.). + 10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4). """ step = _read_design_curve_table(project_root).get("station_snap_step") - return float(step) if isinstance(step, int | float) and step > 0 else None + return 10.0 if isinstance(step, int | float) and step == 10 else None def _read_design_curve_table(project_root: Path) -> dict[str, Any]: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index b1ef38200..8cc6afa33 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -6,10 +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` · +⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `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`. +`curveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` · +`test_280_4_stations.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` @@ -295,35 +295,6 @@ def _mc_scale(step_m: float | None) -> int: 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·s+0.5)/s · s = `_mc_scale(step_m)`)로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 - 측점과 같은 값이면 하나로. - - `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]) - middle = (start + end) / 2.0 - inside = [ - index - for index, value in enumerate(values) - if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M - ] - if not inside: - continue - nearest = min(inside, key=lambda index: abs(values[index] - middle)) - values[nearest] = math.floor(middle * scale + 0.5) / scale - return np.unique(np.asarray(values, dtype=np.float64)) - - def curve_stations( stations: np.ndarray | list[float], spans: list[tuple[float, ...]], @@ -331,7 +302,7 @@ def curve_stations( step_m: float | None = None, ) -> list[float]: """곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이 - 없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 · + 없으면 그 값 · 0.005 m 안 측점(일반 · 관 · 구조물)이 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함. `curveStations` 짝.""" scale = _mc_scale(step_m) taken = [float(value) for value in stations] @@ -393,9 +364,8 @@ def _station_layout( 곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길.""" total = float(route_chainage[-1]) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) + # 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함 spans = _design_curve_spans(points, route_chainage, design_curves or []) - if station_snap: - station_chainage = snap_curve_stations(station_chainage, spans, station_snap_step) # 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다. regular_rounded = {round(float(value), 6) for value in station_chainage} # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 @@ -459,9 +429,9 @@ def generate_sections( ) -> dict[str, Any]: """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. - `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) · - `station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.). - MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함. + `station_snap` · `station_snap_step` — MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.) · + 일반측점은 옮기지 않음(요구 280-4). MC 에 측점이 없으면 곡선 측점(kind `curve` · + `curve_stations` · 요구 252-2 · 「No.2+10 (MC)」) 하나를 더함. """ options = options or SectionGenerationOptions() options.validate() diff --git a/B05_Profile/B05_Profile_Plus_Stations.py b/B05_Profile/B05_Profile_Plus_Stations.py index 911c93462..12c25ae8a 100644 --- a/B05_Profile/B05_Profile_Plus_Stations.py +++ b/B05_Profile/B05_Profile_Plus_Stations.py @@ -3,6 +3,8 @@ 사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이 바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(TS `followPlusRows` · Node 입구). 측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝). +화면은 노선 초안 동안 이 좌표(GET `stations`)를 들고 따라감(TS `followPlusGround` · 493-3) — 거울 시험 = +`resources/tester/test_493_3_follow_screen_save.py`. """ from __future__ import annotations diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 1c5757475..75d225105 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -11,20 +11,24 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, initial_refreshed, pipes: {kept, crossing, outside}, structures: {moved, far, outside}, - follow_failed, cross_dropped, - drainage_stale: true, seconds}`. + follow_failed, cross_dropped, drainage_stale: true, seconds}` · 추천 [적용](`as_initial`)만 + `route_reset: {reset: true, dropped: {structures, pipes, plus_stations}}` 더함. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 + - 노선 초기화(`initial` = 추천 [적용] · 요구 289) = 사용자 구조물 · 추가 측점 · 관(옵션 포함) + 버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · 횡단 기본 설계 · 초기값 다시 + 뜸(부르는 쪽). + - 손 고침 [저장](교점 · R · 스냅 · 간격 · 요구 289 · 290) = 보이는 화면 그대로 — 관 정본을 + 지우지 않음 · 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) · 추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) · 횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계). - - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). + - 초기값은 여기서 안 뜸 — 부르는 쪽이 초기화일 때만(자동설계 체인 직후 + 노선 초기화). """ from __future__ import annotations @@ -199,6 +203,7 @@ async def _place_pipes( points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369). + 초기화(492)면 부르는 쪽이 옛 관 · 옛 선을 빈 목록으로 줌 = 새 노선 교차 관만. 따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare` 노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선 @@ -370,6 +375,36 @@ async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id) +async def _follow_facilities( + project_id: UUID, + project_root: Path, + old_route: dict[str, Any], + points: list[dict[str, float]], + follow_failed: list[str], +) -> dict[str, int]: + """구조물 · 추가 측점 땅 위 자리 따라감(초기화 아닌 저장) → 구조물 요약 · 실패 = 옛 누가거리.""" + from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations + from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + + # 구조물 = 옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움 + try: + structures = await asyncio.to_thread( + follow_structures_to_route, project_root, old_route.get("route_data_path"), points + ) + except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) + logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + structures = {"moved": 0, "far": 0} + follow_failed.append("구조물") + try: # 추가 측점(368-2) + await asyncio.to_thread( + follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] + ) + except Exception: # noqa: BLE001 + logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + follow_failed.append("추가 측점") + return structures + + async def run_route_save_chain( project_id: UUID, project_root: Path, @@ -387,16 +422,24 @@ async def run_route_save_chain( `planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름 (없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림). - `initial` = 추천 [적용](`as_initial`) — 옛 계획고 · 편집 · L 안 옮김 · 새 규칙 초기 종단(476). + `initial` = 노선 초기화(추천 [적용]) — 사용자 시설 버림 · 옛 계획고 · + 편집 · L 안 옮김 · 새 규칙 초기 종단(476). """ import aiomysql from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR - from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations, plus_stations_path + from B05_Profile.B05_Profile_Plus_Stations import ( + load_plus_stations, + plus_stations_path, + save_plus_stations, + ) from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route_record - from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path - from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + from B05_Profile.B05_Profile_Structures_Repository import ( + load_structures, + save_structures, + structures_file_path, + ) from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_latest_section_options, @@ -425,7 +468,8 @@ async def run_route_save_chain( old_long = _read_json(project_root / relative_old) if relative_old else {} old_xy = _xy(project_root, old_route.get("route_data_path")) old_pipes = read_pipe_points_file(pipe_points_path(stored_path)) - _, old_structures = load_structures(str(project_root)) + structures_revision, old_structures = load_structures(str(project_root)) + old_plus = load_plus_stations(project_root) stage_dir = project_root / _STAGE_SUBDIR backup = _Backup( project_root, @@ -440,22 +484,16 @@ async def run_route_save_chain( mark_designing(project_root) try: follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459) - # 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움) - try: - structures = await asyncio.to_thread( - follow_structures_to_route, project_root, old_route.get("route_data_path"), points + if initial: # 초기화(492) — 사용자 구조물 · 추가 측점 버림(실패면 `backup` 이 되살림) + if old_structures: + save_structures(str(project_root), [], base_revision=structures_revision) + if old_plus: + save_plus_stations(project_root, [], None) + structures = {"moved": 0, "far": 0, "outside": 0} + else: + structures = await _follow_facilities( + project_id, project_root, old_route, points, follow_failed ) - except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) - logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) - structures = {"moved": 0, "far": 0} - follow_failed.append("구조물") - try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로) - await asyncio.to_thread( - follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] - ) - except Exception: # noqa: BLE001 - logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) - follow_failed.append("추가 측점") request = redesign_solve_request(params, selection, surface_model_id) solved: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solved, JSONResponse): @@ -467,7 +505,10 @@ async def run_route_save_chain( new_xy = _xy(project_root, solved.route_data_path) marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) - pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points) + # 초기화 = 옛 관(옵션 · 사용자 관) 버림 · 새 노선 세류선 교차 관만(492) + pairs, pipes = await _place_pipes( + project_id, stored_path, [] if initial else old_pipes, [] if initial else old_xy, points + ) marks.append(("관 따라감 · 교차점", time.perf_counter())) seed = section_seed(request, old_section_options) @@ -540,4 +581,19 @@ async def run_route_save_chain( "cross_dropped": dropped, "drainage_stale": True, "seconds": round(marks[-1][1] - marks[0][1], 2), + # 노선 초기화(추천 [적용]만 · sub1 확인 창 · 알림) — 버린 사용자 시설 수 + **( + { + "route_reset": { + "reset": True, + "dropped": { + "structures": len(old_structures), + "pipes": len(old_pipes), + "plus_stations": len(old_plus), + }, + } + } + if initial + else {} + ), } diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 7142b8ca3..c33ce63a3 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -13,7 +13,8 @@ 노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이 풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지 -돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만. +돈다. 배수유역은 다시 안 셈(낡음) · 추천 [적용](`as_initial`)이면 초기화(사용자 시설 · 손 입력 +버림 · 초기값 다시 뜸) · 손 고침 [저장]은 보이는 화면 그대로 시설 따라감(요구 289 · 290). 요청 · 응답 · 오류 모양 = 그 모듈 머리. GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) @@ -331,7 +332,7 @@ async def _recompute( marks = [("시작", time.perf_counter())] - # 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안). + # 관 정본 = 체인이 씀 — 추천 [적용]이면 새 교차 관만 · 아니면 좌표로 새 노선을 따라감(346-3). pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음 @@ -544,7 +545,9 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An async with pool.acquire() as connection: # 설계 지형 바꿈(지표면 확정)도 떠 둠 rows = await guard.snapshot_rows(connection, str(project_id), surface=True) files = await asyncio.to_thread( - guard.snapshot_storage, paths[0], guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.snapshot_storage, + paths[0], + guard.STORAGE_SCOPE + guard.PIPE_SCOPE + guard.INITIAL_SCOPE, ) try: result = await _save_route(project_id, body) @@ -620,7 +623,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any summary["violations"], radius_m, ) - # 균형 종단 새로 풂 = 추천 [적용](`as_initial`)만 — 손으로 고친 노선은 계획고 옮김(344-2). + # 추천 [적용](`as_initial`) = 초기화(사용자 시설 · 손 입력 버림 · 균형 종단 새로 풂) — 손으로 + # 고친 노선은 보이는 화면 그대로 시설 따라감 · 계획고 옮김(344-2 · 요구 289 · 290). result = await _recompute( project_id, project_root, diff --git a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py index a18f11767..78407b6e5 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py @@ -41,11 +41,9 @@ async def initial_profile_of( from B05_Profile import B05_Profile_Engine_InitialProfile as ip from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_Sections import _MODELS_SUBDIR - from B05_Profile.B05_Profile_Plus_Stations import plus_stations_path from B05_Profile.B05_Profile_Repository import get_latest_route - from B05_Profile.B05_Profile_Route_Save import _xy, section_seed + from B05_Profile.B05_Profile_Route_Save import section_seed from B05_Profile.B05_Profile_Router_RouteInitial import balanced_profile_of, project_standard - from B05_Profile.B05_Profile_Structures_Repository import structures_file_path from B06_Section.B06_Section_Repository import ( get_latest_section_options, get_route_generation_source, @@ -54,7 +52,6 @@ async def initial_profile_of( _regeneration_grade_options, _regeneration_options, ) - from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file from common_util.common_util_surface_sampler import build_surface_sampler from common_util.common_util_workflow_state import get_workflow_state @@ -101,14 +98,8 @@ async def initial_profile_of( "surface": surface, "models": ip.stat_marks(list(models.glob(f"*_{surface['source_filter']}*.npz"))), "streams": ip.stat_marks([_sheet_dir(stored_path) / STREAM_FILE]), - "files": ip.file_marks( - [ - pipe_points_path(stored_path), - Path(structures_file_path(str(project_root))), - plus_stations_path(project_root), - project_root / route_path, - ] - ), + # 초기화(492) = 저장 관 · 구조물 · 추가 측점을 안 씀 — 노선 파일만 + "files": ip.file_marks([project_root / route_path]), "options": asdict(options), "grade": grade_options.as_dict() if grade_options else None, "crs": crs, @@ -123,15 +114,7 @@ async def initial_profile_of( return stored prepared = await _prepare(project_id) streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] - facilities = await asyncio.to_thread( - ip.followed_facilities, - project_root, - line, - route_path or None, - _xy(project_root, route_path), - read_pipe_points_file(pipe_points_path(stored_path)), - streams, - ) + facilities = await asyncio.to_thread(ip.reset_facilities, line, streams) target = None if request.with_profile: # [적용] `balanced_target` 과 같은 셈(노선 정점 · 새 곡선표) points = [{"x": x, "y": y} for x, y in line] diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 7ab3241d1..aaa7a3c75 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -433,4 +433,4 @@ class RouteReplanRequest(BaseModel): 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"`.""" + """스냅 간격(m · 10 · None = 측점 No. · 390-5 · 280-4) — 곡선표 `"station_snap_step"`.""" diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index b4703ddc2..97c433a55 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -65,7 +65,9 @@ def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bo label = str(entry.get("structure", "")).strip() at = float(entry.get("chainage_m", 0.0)) return ( - entry.get("origin") == "pipe" + # 이름 빈 측점 = 추가 측점 정본(`plus_stations.json`) 소관 — 「기타」로 안 옮김(280-3) + not label + or entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME or label in {item.name for item in structure_type_map().values()} diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index f639d071b..020a16d9d 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -49,7 +49,7 @@ from common_util.common_util_route_follow import ( route_outside, ) from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_structure_station import plan_stations +from common_util.common_util_structure_station import SAME_STATION_M, plan_stations from config.config_db import get_db_pool from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible @@ -266,9 +266,10 @@ class StructureMigrateRequest(BaseModel): async def _saved_irregular_stations( project_id: UUID, root: str, route_id: int | None -) -> list[dict[str, Any]]: - """저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안 - (옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음).""" +) -> tuple[list[dict[str, Any]], list[float]]: + """저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천 · 서 있는 구조물 측점 + 누가거리(비정규 · 이름 단 격자 · 낡음 판정 280-3). 화면 상세는 초안(옮긴 관 자리)이 섞일 수 + 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음).""" from B06_Section.B06_Section_Repository import get_longitudinal_section from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path @@ -283,21 +284,37 @@ async def _saved_irregular_stations( else None ) if not row or not row.get("longitudinal_file_path"): - return [] + return [], [] try: path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"])) stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or [] except (OSError, ValueError, AttributeError): - return [] - return [ + return [], [] + stations = [station for station in stations if station.get("chainage_m") is not None] + legacy = [ {"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""} for station in stations - if station.get("kind") == "irregular" and station.get("chainage_m") is not None + if station.get("kind") == "irregular" ] + standing = [ + float(station["chainage_m"]) + for station in stations + if station.get("kind") == "irregular" or station.get("structure") + ] + return legacy, standing @router.post("/{project_id}/route/structures/migrate", response_model=None) async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse: + """구 비정규 측점 이관 — [저장]과 같은 프로젝트 잠금 안(280-3 · 다른 탭 저장 도중 낡은 종단으로 + 옮기지 않음 · 몸통 `_migrate_structures`).""" + from common_util import common_util_save_guard as guard + + async with guard.project_lock(str(project_id)): + return await _migrate_structures(project_id, payload) + + +async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse: """구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는 (타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다 (2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의).""" @@ -320,15 +337,38 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) plus_chainages(root), ) ] - legacy = await _saved_irregular_stations(project_id, root, payload.route_id) - plan = migrate_irregular_stations(legacy, planted) + legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id) + # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). - def key_of(item: StructureInstance) -> str: return f"{item.type_id}@{round(item.anchor_m(), 3)}" + # 옛 이관이 만든 「기타 구조물」(이름 없는 측점 · 메모 없음) — 지우지 않고 알림만(280-3) + legacy_etc = [ + round(item.anchor_m(), 3) + for item in existing + if item.type_id == "etc" + and key_of(item) in migrated_before + and str(item.options.get("name") or "기타 구조물") == "기타 구조물" + and not item.memo + ] + # 종단 정본이 지금 심는 자리를 다 못 세움 = 낡음(저장 도중 끊김 · 다른 탭 [저장] 도중) — + # 낡은 측점을 「기타」로 옮기지 않음(280-3) + if any(all(abs(at - s) >= SAME_STATION_M for s in standing) for at in planted): + return JSONResponse( + content={ + "status": "success", + "migrated": 0, + "revision": revision, + "pipe_facilities": 0, + "skipped": "stale_longitudinal", + "legacy_etc": legacy_etc, + } + ) + plan = migrate_irregular_stations(legacy, planted) + occupied = {key_of(item) for item in existing} fresh = [ item @@ -360,6 +400,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) "migrated": moved_pipes, "revision": revision, "pipe_facilities": moved_pipes, + "legacy_etc": legacy_etc, } ) new_revision = save_structures( @@ -381,6 +422,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) "migrated": len(fresh) + moved_pipes, "revision": new_revision, "pipe_facilities": moved_pipes, + "legacy_etc": legacy_etc, } ) except LookupError: diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index ee0e52e5a..b78246e7c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -50,3 +50,54 @@ justify-content: flex-end; gap: var(--spacing-8, 8px); } + +/* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */ +.b05-crosshint { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 4; + width: 0; + pointer-events: none; +} + +.b05-crosshint[hidden], +.b05-crosshint__dot[hidden] { + display: none; +} + +.b05-crosshint__line { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-left: 1px dashed var(--color-accent); +} + +.b05-crosshint__dot { + position: absolute; + top: var(--crosshint-y); + left: 0; + width: 10px; + height: 10px; + border: 2px solid var(--color-accent); + border-radius: 50%; + background: var(--color-surface); + transform: translate(-50%, -50%); + box-sizing: border-box; +} + +.b05-crosshint__label { + position: absolute; + top: var(--crosshint-y); + left: 10px; + transform: translateY(-50%); + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface-raised); + color: var(--color-text-body); + font-size: var(--text-caption); + white-space: nowrap; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index 045bf2238..b89a16345 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -7,7 +7,7 @@ * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 - * 측점일 때만 · [닫기]. + * 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1). * ========================================================================== */ import "./B05_Profile_UI_Profile_CrossModal.css"; @@ -30,9 +30,13 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { + crossPickAt, + groundElevationAt, + type CrossPickHit, + type GroundSample, +} from "./logic/B05_Profile_Profile_CrossPick"; -/** 원지반선을 눌렀다고 볼 위아래 여유(px). */ -const GROUND_SLACK_PX = 8; /** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */ const SAME_M = 0.5; @@ -43,42 +47,87 @@ export interface CrossModalOpen { intervalM: number; } -/** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */ -export function groundPickAt( +/** 그래프 위 포인터 → 임시 횡단 자리(정수 m · 그 자리 원지반선 y px) · 아니면 null. + * 누름(작은 창) · 가까이 표시가 같이 씀(490-1) — 판정은 `crossPickAt` 한 벌. */ +export function crossPickFromEvent( chart: HTMLElement, event: MouseEvent, chainageAtPx: (px: number) => number, - samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>, -): number | null { + samples: readonly GroundSample[], +): { chainageM: number; groundY: number | null } | null { + const target = event.target as Element; + const marker = target.closest(".b06-chart__station"); + const plusRaw = marker?.querySelector(".b06-chart__station-line--plus") + ? Number(marker.getAttribute("data-chainage")) + : null; + const hit: CrossPickHit = + plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null; const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); - if (!svg) return null; - const read = (name: string): number => Number(svg.getAttribute(name)); + const read = (name: string): number => Number(svg?.getAttribute(name)); const view: ChartWindow = { center: read(Y_WINDOW_ATTRS.center), pxPerM: read(Y_WINDOW_ATTRS.pxPerM), zero: read(Y_WINDOW_ATTRS.zero), ...parseWindowTransform( - svg.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null, + svg?.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null, ), }; - if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) - return null; const rect = chart.getBoundingClientRect(); - const chainage = chainageAtPx(event.clientX - rect.left); - const valid = samples.filter( - (s): s is { chainage_m: number; elevation_m: number } => - Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN), - ); - const after = valid.findIndex((s) => s.chainage_m >= chainage); - if (!Number.isFinite(chainage) || after <= 0) return null; - const [a, b] = [valid[after - 1], valid[after]]; - const t = (chainage - a.chainage_m) / (b.chainage_m - a.chainage_m || 1); - const ground = a.elevation_m + (b.elevation_m - a.elevation_m) * t; - const top = svg.getBoundingClientRect().top - rect.top; - const groundY = chartYAtElevation(view, ground) + top; - return Math.abs(event.clientY - rect.top - groundY) <= GROUND_SLACK_PX - ? Math.round(chainage) - : null; + const top = svg ? svg.getBoundingClientRect().top - rect.top : 0; + const viewOk = + !!svg && [view.center, view.pxPerM, view.zero].every(Number.isFinite) && view.pxPerM > 0; + const groundY = (elevation: number): number => + viewOk ? chartYAtElevation(view, elevation) + top : NaN; + // 포인터 이동은 소수 px · 누름은 정수 px — 내려 맞춰 같은 자리 = 같은 m(0.5 m 경계에서 1 m 어긋남 막음). + const chainageM = crossPickAt({ + hit, + chainageM: chainageAtPx(Math.floor(event.clientX) - rect.left), + pointerY: Math.floor(event.clientY) - rect.top, + samples, + groundY, + }); + if (chainageM === null || !Number.isFinite(chainageM)) return null; + const ground = groundElevationAt(samples, chainageM); + const y = ground === null ? NaN : groundY(ground); + return { chainageM, groundY: Number.isFinite(y) ? y : null }; +} + +/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1). + * 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */ +export function mountCrossHint( + chart: HTMLElement, + scroller: HTMLElement, + options: { + pick: (event: MouseEvent) => { chainageM: number; groundY: number | null } | null; + x: (chainageM: number) => number; + intervalM: number; + blocked: () => boolean; + }, +): void { + const hint = document.createElement("div"); + hint.className = "b05-crosshint"; + hint.hidden = true; + hint.innerHTML = ``; + const dot = hint.querySelector(".b05-crosshint__dot")!; + const label = hint.querySelector(".b05-crosshint__label")!; + chart.append(hint); + const hide = (): void => { + hint.hidden = true; + chart.style.cursor = ""; + }; + chart.addEventListener("pointermove", (event) => { + const at = event.buttons || options.blocked() ? null : options.pick(event); + if (!at) return hide(); + hint.hidden = false; + hint.style.left = `${options.x(at.chainageM)}px`; + dot.hidden = at.groundY === null; + // 원 · 글 높이 = 원지반선(없으면 위쪽) — 글이 위 ▲ 단추를 가리지 않게 원 옆에 둠. + hint.style.setProperty("--crosshint-y", `${at.groundY ?? 12}px`); + label.textContent = `${stationLabel(at.chainageM, options.intervalM)} · 눌러 임시 횡단`; + chart.style.cursor = "pointer"; + scroller.addEventListener("scroll", hide, { once: true, passive: true }); + }); + chart.addEventListener("pointerleave", hide); } interface CrossModal { diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 1e8fca7c4..5d8ece274 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -53,7 +53,8 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { - groundPickAt, + crossPickFromEvent, + mountCrossHint, openCrossModal, removePlusStation, } from "./B05_Profile_UI_Profile_CrossModal"; @@ -328,19 +329,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { } // 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3) // 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) - const target = event.target as Element; - const marker = target.closest(".b06-chart__station"); - const plusAt = marker?.querySelector(".b06-chart__station-line--plus") - ? marker.getAttribute("data-chainage") - : null; - if ((marker && !plusAt) || target.closest("button")) return; const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset); - const at = plusAt - ? Math.round(Number(plusAt)) - : groundPickAt(chartWrap, event, toChainageAt, graphData.samples); + const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples); if (at === null) return; openCrossModal(body.parentElement ?? body, { - chainageM: at, + chainageM: at.chainageM, stations: graphLongitudinal.stations.map((station) => station.chainage_m), intervalM: stationIntervalM ?? 20, }); @@ -495,6 +488,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { }), ); } + // 임시 횡단이 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(490-1 · 누름과 같은 판정). + mountCrossHint(chartWrap, body, { + pick: (event) => crossPickFromEvent(chartWrap, event, toChainage, graphData.samples), + x, + intervalM: stationIntervalM ?? 20, + blocked: ctx.toolActive, + }); // 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기). // 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6). canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a10dba7c8..520474528 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -436,8 +436,7 @@ export async function mountRouteEditor( markEdited(); const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : ""; if (stop && before) return restoreSnapshot(before, stop, false); - const from = fit.fitStart(before, snapshotNow()); - if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from); + if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits()); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); @@ -457,7 +456,7 @@ export async function mountRouteEditor( /** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */ function fitLimits(): fit.FitLimits { - return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() }; + return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap(), stationM: stationIntervalM }; } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ @@ -542,8 +541,8 @@ export async function mountRouteEditor( }, snapStepM: snap, fitDrag: (index) => { - const base = stationSnap ? history?.current?.()?.planned : undefined; - if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits()); + const base = stationSnap ? history?.current?.() : undefined; // 누르기 전 노선 — 280-4 유지 + if (base) planned = fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base); if (base) markEdited(); return base !== undefined; }, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 94fbb43b4..b43a99946 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -30,12 +30,14 @@ import { import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import type { SavePart } from "../A00_Common/design/design_save_api"; import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; +import { reloadSavedPlusStations } from "./B05_Profile_Api_PlusStations"; import { replanRoute, type RouteReplanResponse, type RouteSaveBody, } from "./B05_Profile_Api_Replan"; import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep"; +import { routeResetNotice } from "./logic/B05_Profile_Route_Reset"; type Vertex = [number, number]; type RouteSettings = SnapSetting & { terrain: string | null }; @@ -207,7 +209,7 @@ export async function saveRecommendedRoute( const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; showLoadingOverlay(); try { - await saveRouteDraft(projectId, { + const saved = await saveRouteDraft(projectId, { // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장 draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }), initial, @@ -215,8 +217,10 @@ export async function saveRecommendedRoute( as_initial: true, }); clearDrafts(projectId); + await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5) sources.get(projectId)?.saved(); - showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success"); + const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0); + showToast(`추천 노선을 저장했습니다 — 새 초기값입니다.${reset ? ` ${reset}` : ""}`, "success"); await rebuildAfterRouteSave(projectId); return true; } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index ad721cc0f..a59ecb239 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -110,12 +110,10 @@ export function createRouteEditChrome( + title="켜면 고친 곡선의 가운데(MC)를 고른 간격 배수에 맞춥니다 — 일반측점(20 m)은 그대로 · MC 가 일반측점 위가 아니면 「No.N+10 (MC)」 특별측점을 더함 · 손대지 않은 곡선은 그 자리 유지 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅