diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 4c697c6e6..3b0edc52f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -107,6 +107,8 @@ export const STATE_REGISTRY = { "input-prefs": { bucket: "pref", scope: "global" }, /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ "b05-3d-projection": { bucket: "pref", scope: "global" }, + /** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */ + "drainage-mode": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts new file mode 100644 index 000000000..345ba4168 --- /dev/null +++ b/A00_Common/design/design_leave.ts @@ -0,0 +1,123 @@ +/* ============================================================================= + * A00_Common/design/design_leave.ts + * B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때 + * 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]). + * + * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) · + * 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. + * 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄: + * [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음. + * B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음. + * 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`. + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; +import { t as L } from "@ui/ui_template_locale"; +import { + clearState, + namesInBucket, + readByKey, + routeStateKeys, + stateKey, + type StateName, +} from "../b_page_state"; +import { currentRoute, setLeaveGuard } from "../router"; + +/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */ +const NOT_EDITS = new Set([ + "selection", + "pipe-ids", + "structure-pick", + "structure-type-cache", + "std-cross", +]); +const OTHER_PAGE = /^b0[89]-/; + +export function editDraftNames(): StateName[] { + return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name)); +} + +const filled = (raw: string | null): raw is string => + raw !== null && raw !== "" && raw !== "{}" && raw !== "[]"; + +/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */ +export function editSignature(projectId: string): string { + const parts: string[] = []; + for (const name of editDraftNames()) { + const own = stateKey(name, projectId); + const keys = own ? [own] : routeStateKeys(name, projectId); + for (const key of keys.sort()) { + const raw = readByKey(key); + if (filled(raw)) parts.push(`${key}=${raw}`); + } + } + return parts.join("\n"); +} + +/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */ +function digest(text: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `${text.length}:${hash >>> 0}`; +} + +// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음. +const cleanMarks = new Map(); + +/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */ +export function markDesignClean(projectId: string): void { + if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId))); +} + +/** 저장 안 한 조작이 있나. */ +export function designDirty(projectId: string): boolean { + if (!projectId) return false; + const signature = editSignature(projectId); + if (!signature) return false; + return cleanMarks.get(projectId) !== digest(signature); +} + +/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */ +export function discardDesignEdits(projectId: string): void { + editDraftNames().forEach((name) => clearState(name, projectId)); +} + +const SAME_PAGE = new Set([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]); + +/** + * 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂. + * `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`). + */ +export function guardDesignLeave( + projectId: string, + root: HTMLElement, + save: () => Promise, +): void { + const unload = (event: BeforeUnloadEvent): void => { + if (!root.isConnected) return window.removeEventListener("beforeunload", unload); + if (!designDirty(projectId)) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", unload); + const guard = async (): Promise => { + if (!root.isConnected) return true; + if (SAME_PAGE.has(currentRoute())) { + // 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺. + window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0); + return true; + } + if (!designDirty(projectId)) return true; + const got = await leaveOrStay(L("B05_Design_Leave"), { + save, + stillDirty: () => designDirty(projectId), + discard: () => discardDesignEdits(projectId), + }); + return got !== "stay"; + }; + setLeaveGuard(guard); +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2b2a93ec6..2d436080b 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -28,7 +28,8 @@ import { } from "../b_workflow_nav"; import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; -import type { DesignPageShared } from "./design_shared"; +import { saveSharedDesign, type DesignPageShared } from "./design_shared"; +import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import "./design_page.css"; @@ -138,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; + guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 3374528c1..7985fd101 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -26,6 +26,7 @@ import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; +import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -313,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea else await profileSave?.cross?.(isConfirm); }, done: async (isConfirm) => { + markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) if (isConfirm) { showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 75eb2d58b..b03e06de9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -53,7 +53,6 @@ import { structureAnchorM, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; -import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, type PageActionContext, @@ -135,7 +134,7 @@ export async function mountProfileView( { // 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다** // (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다. - onCrossDesignsUpdated: () => panel.setCorridorPending(true), + onCrossDesignsUpdated: () => sections.setCorridorPending(true), // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의 // 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫). onPipesChanged: (pipes) => { @@ -188,6 +187,7 @@ export async function mountProfileView( roadWidths: DEFAULT_ROAD_WIDTHS, detail: null, corridorVisible: true, + corridorPending: false, routeReady: false, restoring: true, }; @@ -404,10 +404,6 @@ export async function mountProfileView( } profilePick.paint(); }, - // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. - onHide: () => { - if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id); - }, }); /** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를 diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 96f999fe7..435f3b941 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -7,7 +7,7 @@ import { showToast } from "@ui/ui_template_elements"; import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch"; -import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; +import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor"; import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View"; @@ -48,27 +48,36 @@ export function createProfile3d(deps: Profile3dDeps) { onResetView: () => viewer.setView("top"), onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. - onCorridorRefresh: async () => { - const detail = state.detail; - const latest = state.latest; - if (!detail || !latest?.route?.id) return; - const routePoints = latest.route_points ?? []; - if (routePoints.length <= 1) return; - await refreshCorridor( - viewer, - projectId, - latest.route.id, - detail, - routePoints, - deps.profilePanel().alignmentSamples() ?? undefined, - ); - // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). - deps.sections().renderStationLines(detail); - // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. - void saveCorridorIfDirty(projectId, latest.route.id); - }, + onCorridorRefresh: () => refreshOnce(), }; + /** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */ + let refreshing: Promise | null = null; + function refreshOnce(): Promise { + refreshing ??= refreshCorridorNow().finally(() => (refreshing = null)); + return refreshing; + } + + /** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */ + async function refreshCorridorNow(): Promise { + const detail = state.detail; + const latest = state.latest; + if (!detail || !latest?.route?.id) return; + const routePoints = latest.route_points ?? []; + if (routePoints.length <= 1) return; + await refreshCorridor( + viewer, + projectId, + latest.route.id, + detail, + routePoints, + deps.profilePanel().alignmentSamples() ?? undefined, + ); + // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). + deps.sections().renderStationLines(detail); + deps.sections().setCorridorPending(false); + } + async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { @@ -122,6 +131,10 @@ export function createProfile3d(deps: Profile3dDeps) { viewer, loadTerrain, visible: () => deps.host.visible(), + // 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다. + onOpen: () => { + if (state.corridorPending) void refreshOnce(); + }, }); return { viewCallbacks, applyContours, modal }; diff --git a/A00_Common/design/design_view_profile_3d_modal.ts b/A00_Common/design/design_view_profile_3d_modal.ts index 69af1a74d..7791d9dd0 100644 --- a/A00_Common/design/design_view_profile_3d_modal.ts +++ b/A00_Common/design/design_view_profile_3d_modal.ts @@ -18,6 +18,8 @@ export interface Viewer3dModalDeps { loadTerrain: () => Promise; /** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */ visible: () => boolean; + /** 열 때마다(지형이 선 뒤) — 낡은 3D 길 모형 다시 짓기(345-4). */ + onOpen?: () => void; } export function createViewer3dModal(deps: Viewer3dModalDeps) { @@ -54,11 +56,13 @@ export function createViewer3dModal(deps: Viewer3dModalDeps) { }, }); viewer.setActive(true); - if (terrain) return; - const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); - progress.set(null); - viewer.root.append(progress.root); - terrain = deps.loadTerrain().finally(() => progress.remove()); + if (!terrain) { + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + terrain = deps.loadTerrain().finally(() => progress.remove()); + } + void terrain.then(() => modal && deps.onOpen?.()); // 그 사이 닫았으면 안 지음 } /** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */ diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index bd0e8118b..dc9702ea6 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -29,6 +29,8 @@ export interface ProfileViewState { detail: SectionDetailResponse | null; /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ corridorVisible: boolean; + /** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */ + corridorPending: boolean; routeReady: boolean; restoring: boolean; } @@ -92,7 +94,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { detail, routePoints, profilePanel.alignmentSamples() ?? undefined, - ).then((fresh) => panel.setCorridorPending(!fresh)); + ).then((fresh) => setCorridorPending(!fresh)); } } @@ -152,6 +154,12 @@ export function createProfileSections(deps: ProfileSectionsDeps) { } } + /** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */ + function setCorridorPending(pending: boolean): void { + state.corridorPending = pending; + deps.panel().setCorridorPending(pending); + } + function renderLatest(next: RouteLatestResponse): void { state.latest = next; state.routeReady = Boolean(next.route && next.route_points.length > 1); @@ -175,5 +183,6 @@ export function createProfileSections(deps: ProfileSectionsDeps) { renderSections, restoreSections, renderLatest, + setCorridorPending, }; } diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index a2f589bea..a85f53710 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -392,6 +392,53 @@ async def _ensure_initial_polyline( logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id) +def redesign_solve_request( + params: dict[str, Any], selection: dict[str, Any], surface_model_id: int +) -> Any: + """stage 2 params(사용자 저장분) + 새 확정 지표면 → as_planned 풀기 요청(노선 저장도 씀).""" + from B05_Profile.B05_Profile_Schema import RouteSolveRequest + + points = params.get("points") or {} + options = params.get("options") or {} + return RouteSolveRequest( + filter_key=str(selection.get("source_filter") or params.get("filter_key")), + method=str(selection.get("method") or params.get("method") or "dtm"), + smooth=bool(selection.get("smooth", params.get("smooth", False))), + surface_model_id=surface_model_id, + # 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사· + # 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측: + # 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음). + # PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다. + algorithm="as_planned", + bp=points["bp"], + ep=points["ep"], + cp=points.get("cp") or [], + ap=points.get("ap") or [], + fp=points.get("fp") or [], + station_interval_m=params.get("station_interval_m"), + cross_half_width_m=params.get("cross_half_width_m"), + cross_sample_interval_m=params.get("cross_sample_interval_m"), + long_sample_interval_m=params.get("long_sample_interval_m"), + **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, + paved=bool(options.get("paved", False)), + terrain_type=str(options.get("terrain_type") or "special"), + main_direction=str(options.get("main_direction") or "auto"), + min_curve_radius_m=options.get("min_curve_radius_m"), + max_uphill_grade=options.get("max_uphill_grade"), + max_downhill_grade=options.get("max_downhill_grade"), + min_uphill_grade=options.get("min_uphill_grade"), + min_downhill_grade=options.get("min_downhill_grade"), + weights=options.get("weights"), + allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)), + max_grade_pct=params.get("max_grade_pct"), + min_vertical_radius_m=params.get("min_vertical_radius_m"), + min_tangent_length_m=params.get("min_tangent_length_m"), + balance_segment_length_m=params.get("balance_segment_length_m"), + start_elevation_offset_m=params.get("start_elevation_offset_m"), + end_elevation_offset_m=params.get("end_elevation_offset_m"), + ) + + async def run_redesign_chain( project_id: UUID, surface_model_id: int, @@ -421,7 +468,6 @@ async def run_redesign_chain( confirm_latest_route, solve_route_record, ) - from B05_Profile.B05_Profile_Schema import RouteSolveRequest from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_latest_section_options, @@ -478,7 +524,6 @@ async def run_redesign_chain( ) params = (stage2 or {}).get("params") or {} points = params.get("points") or {} - options = params.get("options") or {} if not points.get("bp") or not points.get("ep"): logger.warning( "재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id @@ -488,43 +533,7 @@ async def run_redesign_chain( marks.append(("준비·사용자입력 회수", time.perf_counter())) # 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분. - request = RouteSolveRequest( - filter_key=str(selection.get("source_filter") or params.get("filter_key")), - method=str(selection.get("method") or params.get("method") or "dtm"), - smooth=bool(selection.get("smooth", params.get("smooth", False))), - surface_model_id=surface_model_id, - # 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사· - # 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측: - # 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음). - # PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다. - algorithm="as_planned", - bp=points["bp"], - ep=points["ep"], - cp=points.get("cp") or [], - ap=points.get("ap") or [], - fp=points.get("fp") or [], - station_interval_m=params.get("station_interval_m"), - cross_half_width_m=params.get("cross_half_width_m"), - cross_sample_interval_m=params.get("cross_sample_interval_m"), - long_sample_interval_m=params.get("long_sample_interval_m"), - **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, - paved=bool(options.get("paved", False)), - terrain_type=str(options.get("terrain_type") or "special"), - main_direction=str(options.get("main_direction") or "auto"), - min_curve_radius_m=options.get("min_curve_radius_m"), - max_uphill_grade=options.get("max_uphill_grade"), - max_downhill_grade=options.get("max_downhill_grade"), - min_uphill_grade=options.get("min_uphill_grade"), - min_downhill_grade=options.get("min_downhill_grade"), - weights=options.get("weights"), - allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)), - max_grade_pct=params.get("max_grade_pct"), - min_vertical_radius_m=params.get("min_vertical_radius_m"), - min_tangent_length_m=params.get("min_tangent_length_m"), - balance_segment_length_m=params.get("balance_segment_length_m"), - start_elevation_offset_m=params.get("start_elevation_offset_m"), - end_elevation_offset_m=params.get("end_elevation_offset_m"), - ) + request = redesign_solve_request(params, selection, surface_model_id) # 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일. solve_result: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solve_result, JSONResponse): diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 86d1abe35..3cd0632df 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -591,6 +591,8 @@ export interface DetailBasinResponse { pipe_signature?: string; /** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */ stale?: string | null; + /** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */ + pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>; } let lastNote = { text: "", at: 0 }; @@ -640,10 +642,13 @@ export async function resetDetailPipePoints(projectId: string): Promise { + // [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림. const saved = await requestJson( - `/projects/${projectId}/drainage/pipe-points`, + `/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`, { method: "PUT", body: JSON.stringify({ points }) }, + recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); noteSnapshotFailed(saved.snapshot_failed); return saved; diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index 16595b035..b4813838a 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -12,6 +12,8 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; +import type { StageContext } from "../A00_Common/design/design_pipeline_types"; +import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage"; /** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다. * `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */ @@ -24,6 +26,84 @@ const TIMEOUT_MS = 20000; /** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */ const KEPT_PLANS = 8; +/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */ +const kept = new Map(); + +/** + * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 + * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). + * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1). + */ +function keyOf(result: unknown): string { + const points = (result as { points?: unknown[] })?.points; + if (!Array.isArray(points) || !points.length) return "empty"; + return JSON.stringify(points); +} + +const keptKey = (projectId: string, route: number, key: string): string => + `${projectId}:${route}|${key}`; + +function keep(at: string, plan: HaulPlanPayload): void { + kept.delete(at); + kept.set(at, plan); + if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string); +} + +/** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */ +async function postHaulPlan( + projectId: string, + route: number, + result: unknown, + key: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return undefined; + const controller = new AbortController(); + const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); + const relay = () => controller.abort(); + signal?.addEventListener("abort", relay); + try { + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ result }), + signal: controller.signal, + }, + ); + if (!response.ok) return undefined; + const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; + const plan = payload.haul_plan ?? null; + keep(keptKey(projectId, route, key), plan); + return plan; + } catch { + return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. + } finally { + window.clearTimeout(abort); + signal?.removeEventListener("abort", relay); + } +} + +/** + * 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버 + * Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림). + * 못 받으면 앞 배분(`ctx.prev`) 그대로. + */ +export function createHaulStage(projectId: string, routeId: () => number | undefined) { + return async (ctx: StageContext<"haul">): Promise => { + const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput; + const route = routeId(); + if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null; + const key = keyOf(result); + const at = keptKey(projectId, route, key); + if (kept.has(at)) return kept.get(at) ?? null; + const plan = await postHaulPlan(projectId, route, result, key, ctx.signal); + return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan; + }; +} + export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ schedule: (result: unknown) => void; @@ -50,19 +130,6 @@ export function createHaulPlanPrefetch( let sentKey: string | null = null; /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ let applying = false; - /** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */ - const kept = new Map(); - - /** - * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 - * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). - * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1). - */ - function keyOf(result: unknown): string { - const points = (result as { points?: unknown[] })?.points; - if (!Array.isArray(points) || !points.length) return "empty"; - return JSON.stringify(points); - } /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */ function forget(seq: number): void { @@ -72,41 +139,19 @@ export function createHaulPlanPrefetch( async function send(result: unknown, seq: number, key: string): Promise { const route = routeId(); if (!route) return forget(seq); - const controller = new AbortController(); - const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); + const got = await postHaulPlan(projectId, route, result, key); + if (got === undefined) return forget(seq); + // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. + if (seq !== sequence) return; + plan = got; + // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 + // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 + // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). + applying = true; try { - const response = await fetch( - `${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`, - { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ result }), - signal: controller.signal, - }, - ); - if (!response.ok) return forget(seq); - const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; - // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. - if (seq !== sequence) return; - plan = payload.haul_plan ?? null; - kept.delete(key); - kept.set(key, plan); - if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string); - // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 - // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 - // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). - applying = true; - try { - onReady(); - } finally { - applying = false; - } - } catch { - // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. - forget(seq); + onReady(); } finally { - window.clearTimeout(abort); + applying = false; } } @@ -121,7 +166,8 @@ export function createHaulPlanPrefetch( const seq = sequence; window.clearTimeout(timer); // 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다. - const known = kept.get(key); + const route = routeId(); + const known = route ? kept.get(keptKey(projectId, route, key)) : undefined; if (known !== undefined) return void (plan = known); timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS); }, diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py new file mode 100644 index 000000000..d1e344d79 --- /dev/null +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -0,0 +1,496 @@ +"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 → +종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌. + +API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`): + 요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` · + `station_snap`) + + `draft` — 계약 `RouteDraft`(`A00_Common/design/design_pipeline_types.ts`) 그대로 + `{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc, + stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것. + `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). + 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, + initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped, + drainage_stale: true, seconds}`. + 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · + 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 + 옛것으로 되살린 뒤). + +규칙: + - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함 + (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). + - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 + (`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단 + (`apply_profile_target`). + - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import shutil +import tempfile +import time +from dataclasses import asdict +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import numpy as np +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B05_Profile.B05_Profile_Route_Save_Parts import ( + add_crossing_pipes, + carry_cross_inputs, + concave_chainages, + fill_concave_pipes, + follow_pipes, + station_keys, + vertices_of, +) +from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + + +class RouteDraftInput(BaseModel): + """노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase).""" + + rev: int = 0 + nodes: list[tuple[float, float]] = Field(default_factory=list) + curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름 + curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815 + 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 + terrain: Literal["las", "sheet"] | None = None + + +class RouteSaveRequest(RouteReplanRequest): + """노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`).""" + + draft: RouteDraftInput | None = None + as_initial: bool = False + + def resolved(self) -> RouteReplanRequest: + """초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError.""" + if self.draft is None: + return self + draft = self.draft + count = len(draft.nodes) + for name in ("curveOn", "curveRadius"): + values = getattr(draft, name) + if values and len(values) != count: + raise ValueError( + f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다." + ) + vertices = [ + RouteVertexInput( + x=x, + y=y, + curve=draft.curveOn[index] if draft.curveOn else True, + radius_m=draft.curveRadius[index] if draft.curveRadius else None, + ) + for index, (x, y) in enumerate(draft.nodes) + ] + return self.model_copy( + update={ + "vertices": vertices, + "station_snap": draft.stationSnap, + "terrain": draft.terrain, + } + ) + + +class _Backup: + """체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단).""" + + def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None: + self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root)) + self.files = {path: path.read_bytes() if path.is_file() else None for path in paths} + self.folders: dict[Path, Path | None] = {} + for index, folder in enumerate(folders): + copy = Path(self.held) / f"dir{index}" + self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None + + def restore(self) -> None: + for path, saved in self.files.items(): + if saved is None: + path.unlink(missing_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(saved) + for folder, copy in self.folders.items(): + shutil.rmtree(folder, ignore_errors=True) + if copy is not None: + shutil.copytree(copy, folder) + + def drop(self) -> None: + shutil.rmtree(self.held, ignore_errors=True) + + +class _Fail(RuntimeError): + """체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음).""" + + +def _reason(response: JSONResponse) -> str: + try: + return bytes(response.body).decode("utf-8", "replace")[:300] + except Exception: # noqa: BLE001 — 사유는 로그 · 응답용 + return f"status={response.status_code}" + + +def _read_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]: + from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline + + if not route_data_path: + return [] + try: + return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)] + except (OSError, ValueError): + return [] + + +def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple: + """새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀).""" + from shapely.geometry import LineString + + from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M + from common_util.common_util_surface_sampler import build_surface_sampler + + try: + sampler = build_surface_sampler( + project_root / "B04_PreProcess" / "models", + str(selection.get("source_filter")), + str(selection.get("method") or "dtm"), + bool(selection.get("smooth")), + ) + except (FileNotFoundError, KeyError, OSError, ValueError): + logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀") + return np.zeros(0), np.zeros(0) + line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M) + xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64) + z, valid = sampler.sample_xy(xy) + return chainage[valid], np.asarray(z, dtype=np.float64)[valid] + + +async def _place_pipes( + project_id: UUID, + project_root: Path, + stored_path: str, + selection: dict[str, Any], + old_pipes: list, + old_xy: list[list[float]], + points: list[dict[str, float]], +) -> tuple[list, dict[str, int]]: + """관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문).""" + from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes + from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare + from common_util.common_util_drainage_pipes import route_signature, save_pipe_points + + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만 + vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), [] + else: + vertices, streams = prepared["vertices"], prepared["streams"] + pairs = follow_pipes(old_pipes, vertices) + total = float(vertices[-1].chainage_m) + crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total) + chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices) + concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy) + await asyncio.to_thread( + save_pipe_points, + stored_path, + route_signature(vertices), + [pipe for _, pipe in pairs], + vertices, + ) + return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave} + + +async def _carry_profile( + project_root: Path, + relative: str, + route_id: int, + old_alignment: dict[str, Any], + old_xy: list[list[float]], + new_xy: list[list[float]], +) -> None: + """바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴.""" + from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span + from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary + from B05_Profile.B05_Profile_Router import _apply_alignment_edits + from common_util.common_util_json import atomic_write_json + + if not old_alignment.get("base_pvi"): + return # 옛 선형 없음 — 새 지반 첫 계획선 그대로 + path = project_root / relative + longitudinal = _read_json(path) + alignment = longitudinal.get("profile_alignment") or {} + span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy]) + if span is None: # 같은 노선 — 옛 계획선 통째 + base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {} + else: + base, edits = carry_profile_edits( + old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"] + ) + alignment["base_pvi"] = base + await asyncio.to_thread(atomic_write_json, path, longitudinal) + _alignment, summary, _dropped = await asyncio.to_thread( + _apply_alignment_edits, project_root, relative, edits + ) + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + await update_longitudinal_grade_summary( + connection, route_id=route_id, grade_summary=summary + ) + await connection.commit() + + +async def _carry_cross( + project_id: UUID, + project_root: Path, + route_id: int, + relative: str, + old_long: dict[str, Any], + old_designs: list[dict[str, Any]], + old_pipes: list, + old_structures: list, + pairs: list, + interval_m: float, + standard: dict[str, Any] | None, +) -> int: + """측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌.""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs + from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages + from B06_Section.B06_Section_Router_Design import ( + CARRY_INPUT_KEYS, + STRUCTURE_ADJUST_KEYS, + compute_default_designs, + ) + + new_long = _read_json(project_root / relative) + _, new_structures = load_structures(str(project_root)) + old_keys = station_keys( + old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m + ) + new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m) + designs = { + round(float(row["chainage_m"]), 3): row["design"] + for row in old_designs + if isinstance(row.get("design"), dict) + } + inputs, adjusts, dropped = carry_cross_inputs( + old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS + ) + chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, []) + entries = await asyncio.to_thread( + compute_default_designs, project_root, relative, chainages, standard, inputs + ) + entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries] + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + await merge_cross_section_designs( + connection, route_id=route_id, entries=entries, replace=True, project_id=project_id + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return dropped + + +async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None: + """새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선.""" + from common_util.common_util_workflow_state import start_stage + + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM routes WHERE project_id = %s AND id > %s", + (str(project_id), old_route_id), + ) + await start_stage(cursor, str(project_id), 2, params) + await connection.commit() + except Exception: + await connection.rollback() + logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id) + + +async def run_route_save_chain( + project_id: UUID, + project_root: Path, + stored_path: str, + surface_model_id: int, + selection: dict[str, Any], + old_route: dict[str, Any], + points: list[dict[str, float]], + previous_params: dict[str, Any], + profile_target: tuple[list[float], list[float]] | None = None, +) -> dict[str, Any]: + """노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤).""" + 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_Router import ( + _section_options, + 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 B06_Section.B06_Section_Repository import ( + get_cross_section_designs, + get_latest_section_options, + get_longitudinal_section, + ) + from B06_Section.B06_Section_Router import regenerate_route_sections + from B06_Section.B06_Section_Router_Confirm import confirm_sections + from B06_Section.B06_Section_Router_Design import stored_standard_cross_section + from B06_Section.B06_Section_Schema import SectionConfirmRequest + from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file + from common_util.common_util_initial_snapshot import clear_designing, mark_designing + from common_util.common_util_workflow_state import get_workflow_state + + marks = [("시작", time.perf_counter())] + old_route_id = int(old_route["id"]) + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + old_long_row = await get_longitudinal_section(connection, project_id, old_route_id) + old_designs = await get_cross_section_designs(connection, old_route_id) + old_section_options = await get_latest_section_options(connection, project_id) + stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None) + params = (stage2 or {}).get("params") or {} + relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "") + 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)) + stage_dir = project_root / _STAGE_SUBDIR + backup = _Backup( + project_root, + [ + project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"), + pipe_points_path(stored_path), + Path(structures_file_path(str(project_root))), + ], + [stage_dir / "longitudinal", stage_dir / "cross_sections"], + ) + mark_designing(project_root) + try: + # 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움) + 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} + 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): + raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}") + new_route_id = int(solved.route_id) + confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False) + if isinstance(confirmed, JSONResponse): + raise _Fail(f"노선 확정 실패({confirmed.status_code})") + new_xy = _xy(project_root, solved.route_data_path) + marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) + + pairs, pipes = await _place_pipes( + project_id, project_root, stored_path, selection, old_pipes, old_xy, points + ) + marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter())) + + seed = asdict(_section_options(request, old_section_options)) + seed.pop("extra_stations", None) + made = await regenerate_route_sections( + project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed + ) + if isinstance(made, JSONResponse): + raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}") + async with pool.acquire() as connection: + new_long_row = await get_longitudinal_section(connection, project_id, new_route_id) + relative = str((new_long_row or {}).get("longitudinal_file_path") or "") + marks.append(("종횡단 한 번", time.perf_counter())) + + if profile_target is not None: + from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target + + await apply_profile_target(project_id, new_route_id, profile_target) + else: + await _carry_profile( + project_root, + relative, + new_route_id, + old_long.get("profile_alignment") or {}, + old_xy, + new_xy, + ) + marks.append(("계획고 옮김", time.perf_counter())) + + standard = stored_standard_cross_section(old_long_row) + dropped = await _carry_cross( + project_id, + project_root, + new_route_id, + relative, + old_long, + old_designs, + old_pipes, + old_structures, + pairs, + float(seed["station_interval_m"]), + standard, + ) + marks.append(("횡단 이월(측점 열쇠)", time.perf_counter())) + + done = await confirm_sections( + project_id, + new_route_id, + SectionConfirmRequest(standard_cross_section=standard) if standard else None, + mark_stage_complete=False, + ) + if isinstance(done, JSONResponse): + raise _Fail(f"종횡단 확정 실패({done.status_code})") + marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter())) + except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림 + if not isinstance(exc, _Fail): + logger.exception("노선 저장 체인 실패: project_id=%s", project_id) + await asyncio.to_thread(backup.restore) + await _rollback(project_id, old_route_id, previous_params) + return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"} + finally: + backup.drop() + clear_designing(project_root) + _log_steps("재확정 체인(노선 저장)", marks) + return { + "route_id": new_route_id, + "total_length_m": float(solved.total_length_m or 0.0), + "pipes": pipes, + "structures": structures, + "cross_dropped": dropped, + "drainage_stale": True, + "seconds": round(marks[-1][1] - marks[0][1], 2), + } diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py new file mode 100644 index 000000000..f3ec307f5 --- /dev/null +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -0,0 +1,224 @@ +"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 · +횡단 이월. + +DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은 +`B05_Profile_Route_Save.py`. + + - 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음 + (사용자 관 편집 지킴). + - 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음). + - 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음. + - 측점 열쇠(계약 `design_pipeline_types.ts` 머리) = 일반 `grid:k`(누가거리 ÷ 측점 간격 반올림) · + 관 `pipe:` · 구조물 `structure:`(구간 시작 · 끝은 `:start` · `:end`) · 시종점 + `end:BP` · `end:EP`. 관 id = 저장 파일 차례(노선 저장 한 번 안에서 옛 · 새가 같음). + - 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면 + `STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 = + 「노선 밖 고침」. +""" + +from __future__ import annotations + +import copy +import math +from typing import Any, Sequence + +import numpy as np +from shapely.geometry import LineString, Point + +from common_util.common_util_drainage_pipes import ( + PIPE_SOURCE_SPACING, + PIPE_SOURCE_STREAM, + PipePoint, + project_pipe_points, +) +from common_util.common_util_route_geometry import RouteVertex +from common_util.common_util_structure_station import plan_stations, snap_structure_station +from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M + +# 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상). +MOVED_AWAY_M = 0.5 +# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게. +# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것. +CONCAVE_STEP_M = 2.0 +CONCAVE_WINDOW = 5 +_SAME_M = 0.01 + + +def vertices_of(xy: Sequence[Sequence[float]]) -> list[RouteVertex]: + """평면 점열 → 누가거리 붙은 정점(z 0).""" + out: list[RouteVertex] = [] + walked = 0.0 + for index, point in enumerate(xy): + x, y = float(point[0]), float(point[1]) + if index: + walked += math.hypot(x - out[-1].x, y - out[-1].y) + out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked)) + return out + + +def follow_pipes( + old_pipes: list[PipePoint], vertices: list[RouteVertex] +) -> list[tuple[int, PipePoint]]: + """관이 새 노선 좌표를 따라감 → [(관 id = 옛 차례, 새 관)] 누가거리 순 · 옛 목록 그대로.""" + pairs = [(index, copy.deepcopy(pipe)) for index, pipe in enumerate(old_pipes)] + for _, pipe in pairs: + project_pipe_points([pipe], vertices) + pairs.sort(key=lambda pair: pair[1].chainage_m) + return pairs + + +def _moved_away(old_line: LineString | None, x: float, y: float) -> bool: + return old_line is None or old_line.distance(Point(x, y)) > MOVED_AWAY_M + + +def add_crossing_pipes( + pairs: list[tuple[int, PipePoint]], + crossings: Sequence[Any], + old_xy: Sequence[Sequence[float]] | None, + total_m: float, +) -> int: + """옛 노선에 없던 세류선 교차점에 관이 없으면 기본 관을 더함 · 더한 수.""" + old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None + added = 0 + for crossing in crossings: + if not _moved_away(old_line, crossing.x, crossing.y): + continue # 옛 교차점 — 사용자가 지운 관을 되살리지 않음 + at = snap_structure_station(float(crossing.chainage_m), [total_m]) + if any(abs(pipe.chainage_m - at) < DRAINAGE_PIPE_MIN_SPACING_M for _, pipe in pairs): + continue + pairs.append( + (-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_STREAM, x=crossing.x, y=crossing.y)) + ) + added += 1 + pairs.sort(key=lambda pair: pair[1].chainage_m) + return added + + +def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]: + """지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)].""" + if ground.size < 3: + return [] + kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW + pad = CONCAVE_WINDOW // 2 + smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid") + return [ + (float(chainage[i]), float(smooth[i])) + for i in range(1, smooth.size - 1) + if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1] + ] + + +def fill_concave_pipes( + pairs: list[tuple[int, PipePoint]], + concave: list[tuple[float, float]], + vertices: list[RouteVertex], + old_xy: Sequence[Sequence[float]] | None, +) -> int: + """관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관.""" + line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None + total = float(line.length) + added = 0 + while True: + edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total] + best: tuple[float, float] | None = None + for start, end in zip(edges, edges[1:]): + if end - start <= DRAINAGE_PIPE_MAX_SPACING_M: + continue + for at, z in concave: + if not ( + start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M + ): + continue + point = line.interpolate(at) + if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]): + best = (at, z) + if best is None: + return added + at = snap_structure_station(best[0], [total]) + point = line.interpolate(at) + pairs.append( + (-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y)) + ) + pairs.sort(key=lambda pair: pair[1].chainage_m) + concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M] + added += 1 + + +def _facility_keys( + pipes: Sequence[tuple[int, PipePoint]], structures: Sequence[Any] +) -> dict[float, str]: + """측점 자리(0.01 m) → 관 · 구조물 열쇠. 같은 자리면 관이 먼저(`plan_stations` 이름 규칙).""" + keys: dict[float, str] = {} + + def put(marks: list[dict[str, object]], head: str) -> None: + for mark in marks: + role = str(mark.get("role") or "anchor") + keys.setdefault( + round(float(mark["chainage_m"]), 2), head if role == "anchor" else f"{head}:{role}" + ) + + for pipe_id, pipe in pipes: + if pipe_id >= 0: + put(plan_stations([pipe], []), f"pipe:{pipe_id}") + for item in structures: + if getattr(item, "structure_id", None): + put(plan_stations([], [item]), f"structure:{item.structure_id}") + return keys + + +def station_keys( + stations: Sequence[dict[str, Any]], + pipes: Sequence[tuple[int, PipePoint]], + structures: Sequence[Any], + interval_m: float, +) -> dict[str, float]: + """측점 열쇠 → 누가거리. 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만).""" + facility = _facility_keys(pipes, structures) + values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] + out: dict[str, float] = {} + for index, chainage in enumerate(values): + key = facility.get(round(chainage, 2)) + if key is None and index == 0: + key = "end:BP" + elif key is None and index == len(values) - 1: + key = "end:EP" + elif key is None and interval_m > 0: + k = round(chainage / interval_m) + key = f"grid:{k}" if abs(chainage - k * interval_m) < _SAME_M else None + if key is not None: + out.setdefault(key, chainage) + return out + + +def carry_cross_inputs( + old_keys: dict[str, float], + new_keys: dict[str, float], + old_designs: dict[float, dict[str, Any]], + input_keys: Sequence[str], + adjust_keys: Sequence[str], +) -> tuple[dict[float, dict[str, Any]], dict[float, dict[str, Any]], int]: + """같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수). + + `old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에 + 같은 열쇠가 없는 것(「노선 밖 고침」). + """ + inputs: dict[float, dict[str, Any]] = {} + adjusts: dict[float, dict[str, Any]] = {} + dropped = 0 + for key, old_at in old_keys.items(): + design = old_designs.get(round(old_at, 3)) + if not isinstance(design, dict): + continue + new_at = new_keys.get(key) + if new_at is None: + dropped += 1 + continue + carried = {name: design[name] for name in input_keys if design.get(name) is not None} + if carried: + inputs[round(new_at, 3)] = carried + if key.startswith(("pipe:", "structure:")): + adjust = {name: design[name] for name in adjust_keys if design.get(name) is not None} + if adjust: + adjusts[round(new_at, 3)] = adjust + return inputs, adjusts, dropped diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 2dc46d594..428c49c03 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -14,17 +14,13 @@ 노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을 수정본으로 복사」와 결과가 같다. -[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다 -(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관 -정착 계획선 재산출 → 종횡단 재생성 → 옛 측점 설계를 누가거리로 이월 → B05·B06 확정까지 한 -줄로 돈다. 여기서 하는 일은 그 앞에 **노선을 갈아 끼우는 것**뿐이다. - -시설 처리(2026-09-06 사용자 확정) — 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로 -저장분을 버리고 **새로 계산한 값**을 쓴다. 그 밖의 구조물은 **옛 측점값을 그대로** 이어받는다 -(`structures.json`은 프로젝트 정본이라 손대지 않으면 그대로 남는다). +노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이 +풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지 +돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만. +요청 · 응답 · 오류 모양 = 그 모듈 머리. GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) - POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 + POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인) POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) """ @@ -45,6 +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_Sections import load_station_snap +from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 RouteInitialCriteria, RouteInitialRequest, @@ -340,22 +337,19 @@ async def _recompute( from B03_FileInput.B03_FileInput_Service_Chain import ( _log_steps, _planned_route_points_in_project_crs, - run_redesign_chain, ) from B04_PreProcess.B04_PreProcess_Service import ( find_surface_model_for_selection, switch_design_terrain, ) from B05_Profile.B05_Profile_Repository import get_latest_route - from common_util.common_util_drainage_pipes import clear_pipe_points + from B05_Profile.B05_Profile_Route_Save import run_route_save_chain from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state, start_stage marks = [("시작", time.perf_counter())] - # 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다. - await asyncio.to_thread(clear_pipe_points, stored_path) - + # 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안). pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음 @@ -408,7 +402,8 @@ async def _recompute( stage2 = next( (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None ) - params = dict((stage2 or {}).get("params") or {}) + previous_params = dict((stage2 or {}).get("params") or {}) # 실패 때 되살림 + params = dict(previous_params) params["points"] = { "bp": points[0], "ep": points[-1], @@ -418,23 +413,28 @@ async def _recompute( await start_stage(cursor, str(project_id), 2, params) await connection.commit() - marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter())) - - # 구조물은 땅 위 자리(좌표)를 따라감(344-2) — 옛 노선 경로 파일은 체인 전이라 아직 옛 것 - from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route - - try: - old_path = (latest or {}).get("route_data_path") - await asyncio.to_thread(follow_structures_to_route, project_root, old_path, points) - except Exception: # 따라감 실패 = 옛 누가거리 그대로(지금까지와 같음) - logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + marks.append(("재계산 준비(노선 읽기·stage 기록)", time.perf_counter())) target = None if balanced is not None: from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target target = await balanced_target(project_id, project_root, points, balanced.criteria) - failure = await run_redesign_chain(project_id, int(surface_model_id), selection, target) + if not latest: + return {"error": "저장된 노선이 없어 노선을 저장할 수 없습니다(자동설계 먼저)."} + # 노선 저장 체인(346-3) — 배수유역 뗌 · 관 · 구조물 따라감 · 종횡단 한 번 · 계획고 옮김 + saved = await run_route_save_chain( + project_id, + project_root, + stored_path, + int(surface_model_id), + selection, + latest, + points, + previous_params, + target, + ) + failure = saved.get("error") marks.append(("재확정 체인", time.perf_counter())) _log_steps("노선 [확인] 재계산", marks) if failure and switched: # 노선과 함께 설계 지형도 되돌림 @@ -448,14 +448,7 @@ async def _recompute( # 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다. return {"error": failure} - pool = get_db_pool() - async with pool.acquire() as connection: - made = await get_latest_route(connection, project_id) - return { - "route_id": int(made["id"]) if made else None, - "total_length_m": float(made.get("total_length_m") or 0.0) if made else None, - "vertex_count": len(points), - } + return {**saved, "vertex_count": len(points)} @router.get("/{project_id}/route/plan", response_model=None) @@ -530,10 +523,16 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: @router.post("/{project_id}/route/replan", response_model=None) -async def replan_route( - project_id: UUID, request: RouteReplanRequest -) -> dict[str, Any] | JSONResponse: - """고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인]).""" +async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse: + """노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리). + + 배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다. + """ + as_initial = bool(getattr(body, "as_initial", False)) # 옛 요청 꼴(파이썬 호출)도 받음 + try: + request = body.resolved() if isinstance(body, RouteSaveRequest) else body + except ValueError as error: + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) if len(request.vertices) < 2: return JSONResponse( status_code=400, @@ -616,15 +615,18 @@ async def replan_route( if choice else await asyncio.to_thread(read_initial_choice, project_root) ) - # 확정 노선 = 새 초기값(PLAN 74-4) — [저장] 없이 이 결과를 [초기화]가 돌아갈 자리로 뜬다. - from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot + # 초기값 = 자동설계 체인 직후 + 추천 노선 [적용] 때만(346-3 · 사용자 확정 10-16) — 손으로 고친 + # 노선 저장은 [초기화]가 돌아갈 자리를 안 바꿈. + if as_initial: + from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot - await refresh_initial_snapshot(project_id, project_root, result.get("route_id")) + await refresh_initial_snapshot(project_id, project_root, result.get("route_id")) return { "status": "success", "project_id": str(project_id), **result, "current_initial": current, + "initial_refreshed": as_initial, } diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 53091ec56..2da4ffe04 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -337,7 +337,7 @@ async def balanced_profile_of( async def refresh_initial_snapshot( project_id: UUID, project_root: Path, route_id: int | None ) -> None: - """노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적.""" + """추천 노선 [적용](as_initial) 결과로 초기값을 다시 뜬다(346-3) — 실패는 비치명적.""" from common_util.common_util_initial_snapshot import ( discard_initial_snapshot, save_initial_snapshot, diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 7bf2963c5..46a4d4c73 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -6,8 +6,9 @@ * 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 · * B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음). * 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시. - * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로 - * 지음 — 메모리에만 · 올리지 않음. + * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가 + * 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 · + * 올리지 않음. * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). * ========================================================================== */ @@ -21,7 +22,8 @@ import { import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; import { corridorHash, ENVELOPE_VERSION, @@ -169,7 +171,7 @@ export async function ensureCorridor( // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 // 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③). - const build = buildCorridor( + const build = await buildCorridorOffThread( detail.cross_sections, routePoints, designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, @@ -184,9 +186,6 @@ export async function ensureCorridor( return build; } -/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */ -export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise {} - /** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ function withSections( build: CorridorBuildResult | null, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker.ts new file mode 100644 index 000000000..6c605be99 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Worker.ts + * 3D 길 모형 **웹 워커**(345-4) — 저장 안 한 초안의 미리보기를 주 스레드 밖에서 짓는다. + * + * 짓는 셈은 `buildCorridor` 그대로(서버 Node 묶음 `B05_Profile_Corridor_Node` 와 같은 순수 계산) — + * 새로 짜지 않는다. 결과의 리본 좌표(Float64Array)는 복사 없이 넘긴다(Transferable). + * 부르는 쪽은 `_Corridor_Worker_Client`. 이 파일 맨 위에 DOM · window 를 두지 않는다. + * ========================================================================== */ + +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; + +export interface CorridorWorkerRequest { + id: number; + crossSections: CrossSection[]; + routePoints: RoutePoint[]; + designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>; +} + +export interface CorridorWorkerResponse { + id: number; + build: CorridorBuildResult | null; + error?: string; +} + +/** 넘길 버퍼 — 리본 좌표(가장 큼). 나머지는 구조화 복제. */ +export function corridorTransfers(build: CorridorBuildResult | null): ArrayBuffer[] { + return build ? build.ribbons.map((ribbon) => ribbon.positions.buffer as ArrayBuffer) : []; +} + +/** 요청 하나 — 워커 안에서도 시험에서도 같은 함수. */ +export function handleCorridorRequest(request: CorridorWorkerRequest): CorridorWorkerResponse { + try { + const build = buildCorridor(request.crossSections, request.routePoints, request.designSamples); + return { id: request.id, build }; + } catch (error) { + return { + id: request.id, + build: null, + error: error instanceof Error ? error.message : "짓기 실패", + }; + } +} + +interface WorkerScope { + onmessage: ((event: { data: CorridorWorkerRequest }) => void) | null; + postMessage: (message: CorridorWorkerResponse, transfer: ArrayBuffer[]) => void; +} + +// 워커로 돌 때만 듣는다(모듈을 시험 · 주 스레드가 들여와도 아무 일 없음). +if (typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope !== "undefined") { + const scope = globalThis as unknown as WorkerScope; + scope.onmessage = (event) => { + const response = handleCorridorRequest(event.data); + scope.postMessage(response, corridorTransfers(response.build)); + }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts new file mode 100644 index 000000000..8d2f43114 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Worker_Client.ts + * 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드 + * 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다. + * 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다. + * ========================================================================== */ + +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { + CorridorWorkerRequest, + CorridorWorkerResponse, +} from "./B05_Profile_UI_Corridor_Worker"; + +type Samples = CorridorWorkerRequest["designSamples"]; + +let worker: Worker | null = null; +let seq = 0; +const waits = new Map void>(); + +function settleAll(): void { + waits.forEach((resolve) => resolve(null)); + waits.clear(); +} + +function getWorker(): Worker | null { + if (worker) return worker; + if (typeof Worker === "undefined") return null; + try { + worker = new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url), { + type: "module", + }); + } catch { + return null; + } + worker.onmessage = (event: MessageEvent) => { + const resolve = waits.get(event.data.id); + waits.delete(event.data.id); + resolve?.(event.data); + }; + // 워커가 깨지면 버리고 기다리던 것은 주 스레드로 — 다음 부름은 새 워커. + worker.onerror = () => { + worker?.terminate(); + worker = null; + settleAll(); + }; + return worker; +} + +/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */ +export async function buildCorridorOffThread( + crossSections: CrossSection[], + routePoints: RoutePoint[], + designSamples?: Samples, +): Promise { + const onMain = (): CorridorBuildResult | null => + buildCorridor(crossSections, routePoints, designSamples); + const target = getWorker(); + if (!target) return onMain(); + const id = ++seq; + const response = await new Promise((resolve) => { + waits.set(id, resolve); + try { + target.postMessage({ id, crossSections, routePoints, designSamples }); + } catch { + waits.delete(id); // 복제 못 하는 값 — 주 스레드로 + resolve(null); + } + }); + if (!response || response.error) return onMain(); + return response.build; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts new file mode 100644 index 000000000..06cf8df7e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Live.ts + * 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석 + * 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`. + * + * - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 — + * 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음). + * - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트]. + * - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s. + * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. + * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { openModal } from "@ui/ui_template_modal"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import type { StageFn } from "../A00_Common/design/design_pipeline_types"; +import { + computeDetailBasins, + type DetailBasinResponse, + type DetailPipeInput, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + acceptLive, + draftMakesStale, + liveRequestBody, + parseDrainageMode, + QUIET_MS, + type DrainageMode, +} from "./logic/B05_Profile_Drainage_Live"; +import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; +import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest"; + +const MODE_KEY = "drainage-mode"; + +export interface DrainageLiveDeps { + /** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */ + root: HTMLElement; + /** 제안 목록을 이 앞에 둠. */ + basinList: HTMLElement; + pipeRequest: () => DetailPipeInput[]; + runner: DetailRunner; + /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ + rememberPipes: () => void; +} + +export interface DrainageLive { + /** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */ + stage: StageFn<"drainage">; + /** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */ + refresh(): void; + /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */ + markFresh(): void; + readonly stale: boolean; + suggest: DrainageSuggest; +} + +/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */ +async function fetchLive( + projectId: string, + body: ReturnType, + signal: AbortSignal, +): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +export function readDrainageMode(): DrainageMode { + return parseDrainageMode(readStateRaw(MODE_KEY)); +} + +export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { + const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; + const header = deps.root.querySelector(".b05-drainage__header") as HTMLElement; + const suggest = createDrainageSuggest(deps.pipeRequest, (points) => { + const projectId = deps.runner.projectId(); + if (!projectId) return; + void deps.runner + .run(() => computeDetailBasins(projectId, points)) + .then((ok) => ok && deps.rememberPipes()); + }); + deps.basinList.before(suggest.root); + let mode = readDrainageMode(); + let route: unknown = null; + let stale = false; + let generation = 0; + let timer = 0; + let inflight: AbortController | null = null; + + const veil = document.createElement("div"); + veil.className = "b05-drainage__veil"; + veil.hidden = true; + const update = document.createElement("button"); + update.type = "button"; + update.className = "b05-drainage__analyze b05-drainage__update"; + update.textContent = "업데이트"; + update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다."; + veil.append(update); + const busy = createProgressCircle({ size: 22, label: "계산 중" }); + busy.root.classList.add("b05-drainage__busy"); + busy.root.hidden = true; + viewport.append(veil, busy.root); + + const gear = document.createElement("button"); + gear.type = "button"; + gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear"; + gear.textContent = "⚙"; + gear.title = "배수유역 계산 설정 — 수동(기본) / 자동"; + header.append(gear); + + const paint = (): void => { + deps.root.classList.toggle("is-stale", stale); + veil.hidden = !stale || mode === "auto" || inflight !== null; + busy.root.hidden = inflight === null; + }; + + function cancel(): void { + window.clearTimeout(timer); + timer = 0; + inflight?.abort(); + inflight = null; + } + + function refresh(): void { + const projectId = deps.runner.projectId(); + if (!projectId) return; + cancel(); + const sent = generation; + const controller = new AbortController(); + inflight = controller; + paint(); + const body = liveRequestBody(sent, route, deps.pipeRequest()); + void deps.runner + .run(async () => { + const response = await fetchLive(projectId, body, controller.signal); + // 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌. + if (!acceptLive(response, sent, generation)) + throw new DOMException("superseded", "AbortError"); + return response; + }, true) + .then((applied) => { + if (inflight !== controller) return; + inflight = null; + if (applied) stale = false; + paint(); + }); + } + + /** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */ + function invalidate(): void { + generation += 1; + cancel(); + stale = true; + if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); + paint(); + } + + function openSettings(): void { + openModal({ + title: "배수유역 계산", + closeLabel: "닫기", + mount(body, close) { + const choices: Array<[DrainageMode, string, string]> = [ + ["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"], + ["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"], + ]; + choices.forEach(([value, label, hint]) => { + const row = document.createElement("label"); + row.className = "b05-drainage__mode"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = "b05-drainage-mode"; + input.checked = mode === value; + input.addEventListener("change", () => { + setMode(value); + close(); + }); + const text = document.createElement("span"); + text.textContent = `${label} — ${hint}`; + row.append(input, text); + body.append(row); + }); + }, + }); + } + + function setMode(next: DrainageMode): void { + mode = next; + writeStateRaw(MODE_KEY, next); + window.clearTimeout(timer); + timer = 0; + if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); + paint(); + } + + update.addEventListener("click", refresh); + gear.addEventListener("click", openSettings); + + return { + stage(ctx) { + route = ctx.inputs.route; + if (draftMakesStale(route) || stale) invalidate(); + return null; + }, + refresh, + suggest, + markFresh() { + cancel(); + stale = false; + paint(); + }, + get stale() { + return stale; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 6c6b6be19..c7a482e8b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -38,6 +38,8 @@ import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Dra import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; +import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run"; +import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; @@ -47,7 +49,6 @@ import { DRAINAGE_LAYERS, fetchDrainageLayers, createProgressReporter, - analyzeFailed, fitCanvasToViewport, fitViewToRoute, observeViewportSize, @@ -180,7 +181,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra /** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */ let frameView: Window = window; let loadSequence = 0; - let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 /** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */ const fordWaits = () => fordWaitsOf(projectId); let canvasWidth = 0; @@ -276,6 +276,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, intervalSpans, + candidates: live.suggest.candidates(), }); updateImageTransform(); } @@ -338,6 +339,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra * — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */ function apply(response: DetailBasinResponse): void { basins = response.basins; + live.suggest.update(response); mainBoundary = response.main_polygon_lonlat ?? []; upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; @@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(); } - /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ - async function run(request: () => Promise): Promise { - if (!projectId) return; - analyzeButton.disabled = true; - status.hidden = false; - status.textContent = L("B05_Drainage_Status_Analyzing"); - showProgress(null, L("B05_Drainage_Status_Analyzing")); - const seq = ++requestSeq; - try { - const response = await request(); - if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림 - apply(response); - status.hidden = basins.length > 0; - if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin"); - scheduleDraw(); - } catch (error) { - if (seq !== requestSeq) return; - status.hidden = false; - status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed")); - } finally { - if (seq === requestSeq) { - analyzeButton.disabled = false; - showProgress(null, null); - } - } - } + /** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */ + const runner = createDetailRunner({ + projectId: () => projectId, + analyzeButton, + status, + showProgress, + apply, + basinCount: () => basins.length, + scheduleDraw, + label: L, + }); + const { run } = runner; /** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */ const pipeRequest = () => @@ -418,7 +406,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이 * 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */ - const loadSaved = (): Promise => + const loadSaved = (): Promise => run(() => { const pending = readPendingPipes(projectId as string); return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감 @@ -428,12 +416,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 * 옛 관이 살아난다(2026-08-02 사용자 보고). */ - const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); + const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest()))); + // 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔). + const live = createDrainageLive({ root, basinList, pipeRequest, runner, rememberPipes }); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); autoButton.addEventListener("click", () => void resetPipes()); @@ -668,10 +658,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(notify); scheduleDraw(); }, - async savePipes() { + async savePipes(confirm = false) { if (!projectId) return 0; // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). - const response = await saveDetailPipePoints(projectId, pipeRequest()); + const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm); + if (confirm && !response.stale) live.markFresh(); apply(response); scheduleDraw(); return response.pipe_count; @@ -695,5 +686,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra widthResizer.dispose(); }, rehome: () => resizeObserver.rehome(), + pipelineStage: live.stage, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 365d8a1ef..cf31e5cb2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -13,6 +13,7 @@ import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; +import type { StageFn } from "../A00_Common/design/design_pipeline_types"; export interface DrainagePanel { root: HTMLElement; @@ -40,7 +41,10 @@ export interface DrainagePanel { ) => void; removePipe: (chainageM: number) => void; /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ - savePipes: () => Promise; + /** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */ + savePipes: (confirm?: boolean) => Promise; + /** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */ + pipelineStage: StageFn<"drainage">; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 34d56b1f3..341a74acb 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -61,8 +61,8 @@ export interface PipeEditor { * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). * `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */ /** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */ -export function batched(work: () => Promise): () => Promise { - let queued: Promise | null = null; +export function batched(work: () => Promise): () => Promise { + let queued: Promise | null = null; return () => (queued ??= Promise.resolve().then(() => { queued = null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index e5c920046..c50ea8138 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -70,6 +70,8 @@ export interface DrainageScene { stationIntervalM: number; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ intervalSpans: ReadonlyArray; + /** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */ + candidates: ReadonlyArray; } export function drawDrainageScene( @@ -168,6 +170,20 @@ export function drawDrainageScene( scene.maxHotspotArea, ); } + // 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음). + scene.candidates.forEach((chainage) => { + const sample = scene.strengthSamples[Math.round(chainage)]; + if (!sample) return; + const [x, y] = projector.toScreen(sample.x, sample.y); + context.save(); + context.setLineDash([3, 3]); + context.lineWidth = 2; + context.strokeStyle = "#1e88e5"; + context.beginPath(); + context.arc(x, y, 9, 0, Math.PI * 2); + context.stroke(); + context.restore(); + }); // 배관(관 매설) 마커 — 계획선 위. scene.pipeEditor.draw(context, view, scene.pipeColor); // 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Run.ts b/B05_Profile/B05_Profile_UI_Drainage_Run.ts new file mode 100644 index 000000000..be63c32ef --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Run.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Run.ts + * 세부유역 요청 한 번 감싸기 — 버튼 잠금 · 진행 문구 · 오류 표기 · 최신 응답만 채택(차례 번호). + * + * 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄이라 나눔(347-2). 「지금 노선으로 셈」 답도 + * 같은 차례 번호를 타서, 늦게 온 세부유역 답이 새 답을 덮지 않음 — `quiet` 이면 버튼 · 가운데 진행 + * 원 · 상태 글을 안 건드림(칸 구석 「계산 중」은 부르는 쪽 · 조작을 막지 않음). + * ========================================================================== */ + +import type { DetailBasinResponse } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { analyzeFailed } from "./B05_Profile_UI_Drainage_Parts"; + +type StatusKey = + | "B05_Drainage_Status_Analyzing" + | "B05_Drainage_Status_NoBasin" + | "B05_Drainage_Status_AnalyzeFailed"; + +export interface DetailRunnerDeps { + projectId: () => string | null; + analyzeButton: HTMLButtonElement; + status: HTMLElement; + showProgress: (ratio: number | null, label: string | null) => void; + apply: (response: DetailBasinResponse) => void; + basinCount: () => number; + scheduleDraw: () => void; + label: (key: StatusKey) => string; +} + +export interface DetailRunner { + projectId: () => string | null; + /** 요청 하나 — 답을 화면에 올렸으면 true(뒤에 보낸 요청이 있으면 false). */ + run(request: () => Promise, quiet?: boolean): Promise; +} + +export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner { + const { analyzeButton, status, showProgress, label } = deps; + let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 + return { + projectId: deps.projectId, + async run(request, quiet = false) { + if (!deps.projectId()) return false; + if (!quiet) { + analyzeButton.disabled = true; + status.hidden = false; + status.textContent = label("B05_Drainage_Status_Analyzing"); + showProgress(null, label("B05_Drainage_Status_Analyzing")); + } + const seq = ++requestSeq; + try { + const response = await request(); + if (seq !== requestSeq) return false; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림 + deps.apply(response); + status.hidden = deps.basinCount() > 0; + if (!deps.basinCount()) + status.textContent = response.stale ?? label("B05_Drainage_Status_NoBasin"); + deps.scheduleDraw(); + return true; + } catch (error) { + if (seq !== requestSeq) return false; + if (error instanceof DOMException && error.name === "AbortError") return false; + status.hidden = false; + status.textContent = analyzeFailed(error, label("B05_Drainage_Status_AnalyzeFailed")); + return false; + } finally { + if (seq === requestSeq) { + analyzeButton.disabled = false; + showProgress(null, null); + } + } + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts new file mode 100644 index 000000000..c100f29d3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts @@ -0,0 +1,87 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Suggest.ts + * 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로. + * 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음. + * 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판). + * ========================================================================== */ + +import type { + DetailBasinResponse, + DetailPipeInput, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + applySuggestions, + collectSuggestions, + type Suggestion, +} from "./logic/B05_Profile_Drainage_Suggest"; + +const FACILITY_NAME: Record = { + pipe: "배관", + box_culvert: "BOX암거", + ford_bridge: "세월교", +}; + +const facilityName = (id: string): string => FACILITY_NAME[id] ?? id; + +/** 제안 한 줄 글. */ +export function suggestionText(item: Suggestion): string { + const at = `측점 ${Math.round(item.chainageM)} m`; + if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`; + if (item.kind === "facility") + return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`; + return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`; +} + +export interface DrainageSuggest { + root: HTMLElement; + update(response: DetailBasinResponse): void; + /** 관 자리 후보 누가거리 — 배수유역도 점선 원. */ + candidates(): number[]; +} + +export function createDrainageSuggest( + pipeRequest: () => DetailPipeInput[], + applyPoints: (points: DetailPipeInput[]) => void, +): DrainageSuggest { + const root = document.createElement("div"); + root.className = "b05-drainage__suggest"; + root.hidden = true; + const head = document.createElement("div"); + head.className = "b05-drainage__suggest-head"; + const title = document.createElement("span"); + const apply = document.createElement("button"); + apply.type = "button"; + apply.className = "b05-drainage__analyze"; + apply.textContent = "적용"; + apply.title = + "제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다."; + head.append(title, apply); + const list = document.createElement("ul"); + list.className = "b05-drainage__suggest-list"; + root.append(head, list); + let suggestions: Suggestion[] = []; + + apply.addEventListener("click", () => { + if (suggestions.length) applyPoints(applySuggestions(pipeRequest(), suggestions)); + }); + + return { + root, + update(response) { + suggestions = response.stale + ? [] + : collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []); + root.hidden = suggestions.length === 0; + title.textContent = `제안 ${suggestions.length}건`; + list.replaceChildren( + ...suggestions.map((item) => { + const row = document.createElement("li"); + row.textContent = suggestionText(item); + return row; + }), + ); + }, + candidates: () => + suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 5d6fc7ef8..d4b27c095 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -13,7 +13,6 @@ import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; -import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; @@ -87,9 +86,6 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다. invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); - // 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리. - const routeId = ctx.latest()?.route?.id; - if (routeId) void saveCorridorIfDirty(projectId, routeId); }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index a1b0f9cea..709e616e8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -11,8 +11,7 @@ * 그리기·라벨·집기가 출처를 안 가리게 한다. * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { createNormalizer, type GeoJsonCollection, @@ -71,8 +70,7 @@ export async function loadRouteEditContours( // 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다. const data = await fetchCachedJson( projectId, - `${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` + - `/contour?interval=${interval}&smooth=${options.smooth}`, + contourAssetUrl(projectId, options.surfaceModelId, interval, options.smooth), ); const all = data.contours ?? []; const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6; diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 17e1ea15f..67a1699d5 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -266,3 +266,70 @@ .b05-drainage[hidden] { display: none; } + +/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */ +.b05-drainage.is-stale .b05-drainage__canvas, +.b05-drainage.is-stale .b05-drainage__image, +.b05-drainage.is-stale .b05-drainage__summary, +.b05-drainage.is-stale .b05-drainage__basins, +.b05-drainage.is-stale .b05-drainage__suggest { + filter: blur(2px); + opacity: 0.6; +} + +.b05-drainage__veil { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.b05-drainage__veil[hidden] { + display: none; +} + +.b05-drainage__veil .b05-drainage__update { + pointer-events: auto; +} + +.b05-drainage__busy { + position: absolute; + right: var(--spacing-8); + bottom: var(--spacing-8); +} + +.b05-drainage__busy[hidden] { + display: none; +} + +.b05-drainage__mode { + display: flex; + gap: var(--spacing-8); + align-items: center; + padding: var(--spacing-8) 0; + color: var(--color-text-body); +} + +/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */ +.b05-drainage__suggest { + flex: 0 0 auto; + border-top: 1px solid var(--color-border); + padding: var(--spacing-8); + font-size: var(--text-caption); + color: var(--color-text-body); +} + +.b05-drainage__suggest-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.b05-drainage__suggest-list { + max-height: 6em; + margin: var(--spacing-8) 0 0; + padding-left: 1.2em; + overflow-y: auto; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 78f276413..619242cc5 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -4,7 +4,7 @@ import { createTerrainCompass } from "@ui/ui_template_compass"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourAssetUrl, fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { bindCursorPivotControls, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera"; import { createRouteMarkers, @@ -325,7 +325,7 @@ export function createRouteViewer(): RouteViewer { contours: Array<{ level: number; coordinates: [number, number, number][] }>; }>( current.projectId, - `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + contourAssetUrl(current.projectId, current.modelId, interval, current.smooth), ); clearContours(); // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다. diff --git a/B05_Profile/logic/B05_Profile_Drainage_Live.ts b/B05_Profile/logic/B05_Profile_Drainage_Live.ts new file mode 100644 index 000000000..16ab31a16 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Drainage_Live.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Drainage_Live.ts + * 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`. + * + * - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음). + * - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤. + * - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동. + * - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림). + * ========================================================================== */ + +export type DrainageMode = "manual" | "auto"; + +/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */ +export const QUIET_MS = 3000; + +/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */ +export const parseDrainageMode = (raw: string | null | undefined): DrainageMode => + raw === "auto" ? "auto" : "manual"; + +/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */ +export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined; + +/** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */ +export function liveRequestBody

( + generation: number, + route: unknown, + points: P[], +): { generation: number; route: unknown; points: P[] } { + return { generation, route: route ?? null, points }; +} + +/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */ +export function acceptLive( + response: { status?: string; generation?: number }, + sentGeneration: number, + currentGeneration: number, +): boolean { + return ( + response.status !== "superseded" && + sentGeneration === currentGeneration && + (response.generation === undefined || response.generation === sentGeneration) + ); +} diff --git a/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts new file mode 100644 index 000000000..891d4e695 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Drainage_Suggest.ts + * 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음. + * 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`). + * + * - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」). + * - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때. + * - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳). + * - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐. + * ========================================================================== */ + +/** 관 자리가 같은 유역으로 볼 거리(m) — 화면 `PIPE_MATCH_M` 과 같은 값. */ +const SAME_PIPE_M = 0.5; + +interface PipeLike { + chainage_m: number; + source?: string; + facility?: string; + options?: Record; +} + +interface BasinLike { + chainage_m: number; + recommended_facility?: string; + recommended_diameter_mm?: number | null; +} + +export type Suggestion = + | { kind: "diameter"; chainageM: number; from: number | null; to: number } + | { kind: "facility"; chainageM: number; from: string; to: string } + | { kind: "position"; chainageM: number }; + +const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_PIPE_M; + +/** 응답(유역 · 관 자리 후보) + 지금 관 목록 → 제안 목록(관 자리 차례). */ +export function collectSuggestions( + pipes: ReadonlyArray, + basins: ReadonlyArray, + candidates: ReadonlyArray<{ chainage_m: number }>, +): Suggestion[] { + const found: Suggestion[] = []; + basins.forEach((basin) => { + const pipe = pipes.find((item) => near(item.chainage_m, basin.chainage_m)); + if (!pipe) return; + const facility = pipe.facility ?? "pipe"; + const want = basin.recommended_facility ?? "pipe"; + if (want !== "pipe" && facility !== want) { + found.push({ kind: "facility", chainageM: pipe.chainage_m, from: facility, to: want }); + return; + } + const to = basin.recommended_diameter_mm; + const raw = pipe.options?.pipe_diameter_mm; + const from = raw === undefined || raw === "" ? null : Number(raw); + if (want === "pipe" && facility === "pipe" && to != null && from !== to) + found.push({ kind: "diameter", chainageM: pipe.chainage_m, from, to }); + }); + candidates + .filter((item) => !pipes.some((pipe) => near(pipe.chainage_m, item.chainage_m))) + .forEach((item) => found.push({ kind: "position", chainageM: item.chainage_m })); + return found.sort((a, b) => a.chainageM - b.chainageM); +} + +/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */ +export function applySuggestions

( + pipes: ReadonlyArray

, + suggestions: ReadonlyArray, +): P[] { + const next = pipes.map( + (pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P, + ); + suggestions.forEach((item) => { + if (item.kind === "position") { + if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM))) + next.push({ chainage_m: item.chainageM, source: "spacing" } as P); + return; + } + const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM)); + if (!pipe) return; + if (item.kind === "facility") pipe.facility = item.to; + else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to }; + }); + return next.sort((a, b) => a.chainage_m - b.chainage_m); +} diff --git a/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts b/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts new file mode 100644 index 000000000..883b083f0 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * B05_Profile_MassHaul_Stage.ts + * 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) · + * profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산). + * + * 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음). + * 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null. + * ========================================================================== */ + +import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul"; +import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types"; +import type { StageContext } from "../../A00_Common/design/design_pipeline_types"; + +export interface MassHaulStageContext { + conversion: EarthworkConversion; + naturalSpoilMinSlope?: number; +} + +export interface MassHaulStageOutput { + series: MassHaulSeries[]; + /** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */ + haulInput: MassHaulSeries["result"] | null; +} + +/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */ +export function createMassHaulStage(context: () => MassHaulStageContext | null) { + return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => { + const cross = ctx.out.cross; + const settings = context(); + if (!cross || !settings) return null; + const sections: MassHaulSection[] = Object.values(cross.byKey) + .sort((a, b) => a.chainageM - b.chainageM) + .map((entry) => ({ + station_id: entry.key, + chainage_m: entry.chainageM, + design: entry.detail as MassHaulSection["design"], + })); + const samples = ctx.out.profile?.samples ?? []; + const longitudinal = { + length_m: + samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0, + design_profiles: samples.length ? [{ samples }] : [], + }; + const series = computeMassHaulSeries( + longitudinal, + sections, + settings.conversion, + settings.naturalSpoilMinSlope, + ); + const picked = series.find((entry) => entry.basis === "cross") ?? series[0]; + return { series, haulInput: picked?.result ?? null }; + }; +} diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index d351a1f6b..451456bf7 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -8,9 +8,11 @@ * 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다. * * 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다. + * 세션 열쇠 = 측점 열쇠(344-3 · `B06_Section_Station_Keys`) · 부르는 쪽에는 누가거리로 내줌. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +import { chainageKey } from "@util/common_util_station_key"; +import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys"; /** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ export interface CrossDesignChoice { @@ -25,14 +27,9 @@ export interface CrossDesignChoice { ditch_choice?: boolean | null; } -type ChoiceMap = Record; - -/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */ -const keyOf = (chainageM: number): string => chainageM.toFixed(2); - -function readAll(projectId: string, routeId: number): ChoiceMap { - return readState("crossdesign", projectId, routeId) ?? {}; -} +/** 누가거리(0.01 m 글) → 선택값 — 지금 측점에 있는 것만. */ +const readAll = (projectId: string, routeId: number) => + readKeyed("crossdesign", projectId, routeId); /** 이 측점의 선택값(없으면 null). */ export function readCrossDesignChoice( @@ -40,7 +37,7 @@ export function readCrossDesignChoice( routeId: number, chainageM: number, ): CrossDesignChoice | null { - return readAll(projectId, routeId)[keyOf(chainageM)] ?? null; + return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null; } /** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */ @@ -50,9 +47,8 @@ export function writeCrossDesignChoice( chainageM: number, choice: CrossDesignChoice, ): void { - const all = readAll(projectId, routeId); - all[keyOf(chainageM)] = choice; - writeState("crossdesign", all, projectId, routeId); + const at = chainageKey(chainageM); + writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]); } /** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ @@ -62,7 +58,7 @@ export function crossDesignChoices( ): Map { const choices = new Map(); if (!projectId || routeId === null) return choices; - for (const [key, value] of Object.entries(readAll(projectId, routeId))) { + for (const [key, value] of readAll(projectId, routeId)) { const chainage = Number(key); if (Number.isFinite(chainage) && value) choices.set(chainage, value); } @@ -80,12 +76,11 @@ type SideState = "shoulderext" | "bermdepth"; function readSides(name: SideState, projectId: string | null, routeId: number | null) { const out: ShoulderExtends = new Map(); if (!projectId || routeId === null) return out; - const raw = readState>>>( + for (const [key, value] of readKeyed>>( name, projectId, routeId, - ); - for (const [key, value] of Object.entries(raw ?? {})) { + )) { const chainage = Number(key); if (Number.isFinite(chainage) && value) out.set(chainage, value); } @@ -100,11 +95,10 @@ function writeSide( side: ShoulderSide, valueM: number, ): void { - const all = - readState>>>(name, projectId, routeId) ?? - {}; - all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; - writeState(name, all, projectId, routeId); + const at = chainageKey(chainageM); + const own = readKeyed>>(name, projectId, routeId).get(at); + const next = { ...(own ?? {}), [side]: Math.max(valueM, 0) }; + writeKeyed(name, projectId, routeId, new Map([[at, next]]), [at]); } /** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index c4b1da7a5..07233dac9 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,12 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { + bermSpecAt, + readBermSpans, + readCutSlopeSession, + readRockBoundarySession, +} from "./B06_Section_UI_Page_Persist"; import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { @@ -48,7 +53,7 @@ import { } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; -import { readState } from "../A00_Common/b_page_state"; +import { useStationKeys } from "./B06_Section_Station_Keys"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; @@ -211,6 +216,7 @@ export async function refreshStaleDesigns( * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const updated = refreshLocally(input) ?? (await refreshFromServer(input)); @@ -301,8 +307,7 @@ function bermDepthReader(projectId: string, routeId: number) { */ function readCutSlopeRatios(projectId: string, routeId: number): Map { const out = new Map(); - const raw = readState>("cutslope", projectId, routeId); - if (!raw) return out; + const raw = readCutSlopeSession(projectId, routeId); for (const [chainage, ratio] of Object.entries(raw)) { const at = Number(chainage); if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); @@ -516,8 +521,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { berms: bermPayload(projectId, routeId, detail), // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). - cutSlopeRatios: - readState>("cutslope", projectId, routeId) ?? undefined, + cutSlopeRatios: readCutSlopeSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Cross_Stage.ts b/B06_Section/B06_Section_Cross_Stage.ts new file mode 100644 index 000000000..cde2f0969 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Stage.ts @@ -0,0 +1,136 @@ +/* ============================================================================= + * B06_Section_Cross_Stage.ts + * 계산 묶음 `cross` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — profile · ground · input.cross → + * 측점 열쇠마다 횡단 설계(`CrossOutput.byKey`). + * + * · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진). + * · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로. + * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있으면 셈 없이 재사용. + * · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음. + * input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김). + * ========================================================================== */ + +import { computeCrossDesign } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { + CrossOutput, + CrossStationResult, + PipelineStation, + StageContext, +} from "../A00_Common/design/design_pipeline_types"; +import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; + +/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */ +export type CrossStationInput = Partial< + Omit +>; + +/** `input.cross` — 표준단면 + 측점 열쇠 → 그 측점 입력. */ +export interface CrossStageInput { + standard: StandardCrossSectionSpec; + byKey: Record; +} + +interface Sample { + offset_m: number; + elevation_m: number | null; + valid: boolean; +} + +/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */ +export function groundSamples(station: PipelineStation): Sample[] { + const ground = station.ground; + if (!ground) return []; + const point = (offset: number, z: number | null): Sample => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null && Number.isFinite(z), + }); + const right = ground.right.map((s) => point(-s.offsetM, s.z)); + const left = ground.left.map((s) => point(s.offsetM, s.z)); + const out = [...right, ...left]; + if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9)) + out.push(point(0, ground.centerZ)); + return out.sort((a, b) => a.offset_m - b.offset_m); +} + +/** 측점 하나의 엔진 입력. */ +function optionsFor( + station: PipelineStation, + input: CrossStationInput | undefined, + standard: StandardCrossSectionSpec, +): CrossDesignOptions { + return { + groundType: "ripping_rock", + sectionMode: "left_cut", + ditchType: "standard", + ...input, + standard, + planRadiusM: station.planRadiusM, + curveOuterSide: station.curveOuterSide, + curveWideningM: station.curveWideningM, + }; +} + +const inRange = (station: PipelineStation, ctx: StageContext<"cross">): boolean => { + const range = ctx.range; + if (!range) return true; + if (range.keys.includes(station.key)) return true; + return station.chainageM >= range.fromM - 1e-6 && station.chainageM <= range.toM + 1e-6; +}; + +/** + * `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수. + */ +export function createCrossStage() { + const memo = new Map(); + let last = { computed: 0, reused: 0 }; + + function stage(ctx: StageContext<"cross">): CrossOutput { + const profile = ctx.out.profile; + const ground = ctx.out.ground; + const input = ctx.inputs.cross as CrossStageInput | null; + if (!profile || !ground || !input?.standard) return ctx.prev ?? { byKey: {} }; + const standardKey = JSON.stringify(input.standard); + const byKey: Record = {}; + last = { computed: 0, reused: 0 }; + for (const station of ground.stations) { + const kept = ctx.prev?.byKey[station.key]; + if (kept && !inRange(station, ctx)) { + byKey[station.key] = { ...kept, chainageM: station.chainageM }; + last.reused += 1; + continue; + } + const samples = groundSamples(station); + const elevation = planElevationAt(profile, station.chainageM); + const options = optionsFor(station, input.byKey[station.key], input.standard); + const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`; + const hit = memo.get(station.key); + if (hit?.fingerprint === fingerprint) { + byKey[station.key] = { ...hit.result, chainageM: station.chainageM }; + last.reused += 1; + continue; + } + let design; + try { + design = computeCrossDesign(samples, elevation, options); + } catch { + continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도) + } + const result: CrossStationResult = { + key: station.key, + chainageM: station.chainageM, + cutAreaM2: design.cut_area_m2, + fillAreaM2: design.fill_area_m2, + detail: design, + }; + memo.set(station.key, { fingerprint, result }); + byKey[station.key] = result; + last.computed += 1; + } + for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점 + return { byKey }; + } + + return { stage, counts: () => last }; +} diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 3a23ceddf..e535d552f 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -186,6 +186,18 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None: ) +def _prune_cross_files(project_root: Path, longitudinal_file_path: str) -> None: + """측점을 고친 뒤 옛 cross_*.json 을 지움 — GET 은 안 지우므로 쓰는 쪽(저장 · 확정)이 맡음.""" + from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files + + path = project_root / longitudinal_file_path + try: + stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or [] + except (OSError, ValueError, AttributeError): + return # 측점을 못 읽으면 안 지움(오삭제 막음) + prune_stale_cross_files(path.parent.parent / "cross_sections", stations) + + async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None: """3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝. @@ -261,6 +273,10 @@ async def save_sections( missing = await get_cross_sections_missing_design_chainages(connection, route_id) project_root = Path(resolve_stored_project_path(stored_path)) + # 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫) + await asyncio.to_thread( + _prune_cross_files, project_root, str(existing["longitudinal_file_path"]) + ) rowless = missing + await asyncio.to_thread( _rowless_station_chainages, project_root, @@ -341,6 +357,10 @@ async def confirm_sections( known = await get_cross_section_chainages(connection, route_id) marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter())) project_root = Path(resolve_stored_project_path(stored_path)) + # 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫) + await asyncio.to_thread( + _prune_cross_files, project_root, str(existing["longitudinal_file_path"]) + ) # 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면 # 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24). missing = missing + await asyncio.to_thread( diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 633c9d7a9..64d6f1927 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -30,6 +30,7 @@ import { structureStationMarks, type PlannedStation, } from "../common_util/common_util_structure_station"; +import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys"; import { previewStructureSections, type CrossSection, @@ -188,6 +189,7 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); + useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3) const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 @@ -246,4 +248,5 @@ export async function withDraftStations( ...[...live, ...fresh].sort(byChainage), ); parked.set(detail, stale); + useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3) } diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts new file mode 100644 index 000000000..3962048f3 --- /dev/null +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * B06_Section_Station_Keys.ts + * 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게. + * + * 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · + * id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로 + * 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로). + * + * · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션 + * 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서. + * · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음. + * · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀. + * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 · + * 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이 + * 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { showToast } from "@ui/ui_template_elements"; +import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key"; +import { inferStationInterval } from "@util/common_util_svg"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; + +/** 측점 열쇠로 담는 세션 칸. */ +export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; +export const KEYED_STATES: readonly KeyedState[] = [ + "crossdesign", + "shoulderext", + "bermdepth", + "rockb", + "cutslope", +]; + +/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ +const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; +/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */ +const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; + +/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ +const NEAR_M = 0.01; +const HINT = "@at"; + +type Keyed = { key: string; chainageM: number }; +type Raw = Record & { [HINT]?: Record }; + +const lists = new Map(); +/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ +const owners = new Map>(); +let current: { projectId: string; routeId: number } | null = null; + +const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; + +/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ +export function useStationOwners( + projectId: string, + planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>, +): void { + owners.set( + projectId, + planned.flatMap((station) => + station.owners.slice(0, 1).map((id) => ({ + id, + kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), + at: station.chainage_m, + })), + ), + ); +} + +/** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */ +export function useStructureIds( + projectId: string, + structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>, +): void { + const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe"); + for (const item of structures) { + if (item.structure_id && typeof item.chainage_m === "number") + known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m }); + } + owners.set(projectId, known); +} + +const looksLikePipe = (section: CrossSection): boolean => + Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); + +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */ +export function stationKeysOf( + projectId: string | null, + sections: ReadonlyArray, + intervalM: number | null, +): Keyed[] { + const known = projectId ? (owners.get(projectId) ?? []) : []; + const seen = new Set(); + return sections.map((section) => { + const at = section.chainage_m; + let key: string; + if (section.kind === "irregular") { + const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); + if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind); + else if (looksLikePipe(section)) + key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); + else + key = section.grid + ? stationKey("regular", at, intervalM) + : stationKey("irregular", at, intervalM); + } else key = stationKey(section.kind, at, intervalM); + if (seen.has(key)) key = `at:${chainageKey(at)}`; + seen.add(key); + return { key, chainageM: at }; + }); +} + +/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ +export function useStationKeys( + projectId: string, + routeId: number, + detail: Pick, +): void { + const interval = + ( + detail.longitudinal?.profile_alignment as + { policy?: { station_interval_m?: number } } | undefined + )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections); + lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval)); + current = { projectId, routeId }; +} + +/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */ +export const currentStationScope = () => current; + +function nearest(list: Keyed[], chainageM: number): Keyed | undefined { + let best: Keyed | undefined; + for (const entry of list) { + const gap = Math.abs(entry.chainageM - chainageM); + if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; + } + return best; +} + +/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ +export function keyAt(projectId: string, routeId: number, chainageM: number): string { + const list = lists.get(scopeOf(projectId, routeId)); + return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; +} + +/** + * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 + * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). + */ +export function chainageOfKey( + projectId: string, + routeId: number, + key: string, + hint?: number, +): number | null { + const list = lists.get(scopeOf(projectId, routeId)); + const hit = list?.find((entry) => entry.key === key); + if (hit) return Number(chainageKey(hit.chainageM)); + const at = looseChainage(key) ?? hint; + if (at === undefined) return null; + if (!list) return Number(chainageKey(at)); + const near = nearest(list, at); + return near ? Number(chainageKey(near.chainageM)) : null; +} + +function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { + const raw = readState(name, projectId, routeId); + return raw && typeof raw === "object" ? raw : {}; +} + +function entries(raw: Raw): Array<[string, V, number | undefined]> { + const hints = raw[HINT] ?? {}; + return Object.entries(raw) + .filter(([key]) => key !== HINT) + .map(([key, value]) => [key, value as V, hints[key]]); +} + +/** + * 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음). + * 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때). + */ +export function readKeyed(name: KeyedState, projectId: string, routeId: number): Map { + const raw = readRaw(name, projectId, routeId); + const out = new Map(); + let moved = false; + for (const [key, value, hint] of entries(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at === null) continue; + out.set(chainageKey(at), value); + if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:")) + moved = true; + } + if (moved) writeKeyed(name, projectId, routeId, out); + return out; +} + +/** + * 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠. + * `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로. + */ +export function writeKeyed( + name: KeyedState, + projectId: string, + routeId: number, + byChainage: ReadonlyMap, + only?: ReadonlyArray, +): void { + const raw = readRaw(name, projectId, routeId); + const next: Raw = { [HINT]: {} }; + const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at)))); + for (const [key, value, hint] of entries(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; + next[key] = value; + if (hint !== undefined) next[HINT]![key] = hint; + } + for (const [at, value] of byChainage) { + if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue; + const key = keyAt(projectId, routeId, Number(at)); + next[key] = value; + next[HINT]![key] = Number(chainageKey(Number(at))); + } + writeState(name, next, projectId, routeId); +} + +/** + * 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김. + * 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구 + * 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). + */ +export function carryStationDrafts( + projectId: string, + fromRouteId: number, + toRouteId: number, +): number { + const outside = new Set(); + for (const name of KEYED_STATES) { + const raw = readRaw(name, projectId, fromRouteId); + const next: Raw = { [HINT]: {} }; + for (const [key, value] of entries(raw)) { + // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 + const fromAt = + looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); + const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); + const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined); + const carried = carriedValue(name, value); + if (toAt === null || stable.startsWith("at:")) { + if (carried !== null) outside.add(stable); + continue; + } + if (carried === null) continue; + const toKey = keyAt(projectId, toRouteId, toAt); + next[toKey] = carried; + next[HINT]![toKey] = toAt; + } + writeState(name, next, projectId, toRouteId); + } + if (outside.size) + showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info"); + return outside.size; +} + +/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ +function carriedValue(name: KeyedState, value: unknown): unknown { + if (CARRY_WHOLE.includes(name)) return value; + if (name !== "crossdesign" || !value || typeof value !== "object") return null; + const source = value as Record; + const picked = Object.fromEntries( + CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), + ); + return Object.keys(picked).length ? picked : null; +} diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 804307026..fa04f3326 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -27,6 +27,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; +import { useStructureIds } from "./B06_Section_Station_Keys"; export interface DesignSyncContext { projectId: string | null; @@ -194,6 +195,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { function syncBermSpans(structures: ReadonlyArray): void { const currentRouteId = ctx.routeId(); if (!projectId || currentRouteId === null) return; + useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3) const next = bermSpansFromStructures(structures); if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; writeBermSpans(projectId, currentRouteId, next); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3e870bbbc..8c0c1b2e1 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -49,6 +49,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { catalogRow } from "../common_util/common_util_structure_catalog"; +import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -65,8 +66,35 @@ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { - const stored = readState>("rockb", projectId, routeId); - return stored && typeof stored === "object" ? stored : {}; + // 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로 + return Object.fromEntries(readKeyed("rockb", projectId, routeId)); +} + +/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */ +export function readCutSlopeSession(projectId: string, routeId: number): Record { + return Object.fromEntries(readKeyed("cutslope", projectId, routeId)); +} + +/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ +function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { + const [projectId, route] = storageKey.split(":").slice(-2); + const routeId = Number(route); + return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null; +} + +/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */ +function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> { + const scope = keyedScope(storageKey); + if (scope) return [...readKeyed(name, scope.projectId, scope.routeId)]; + const raw = readByKey(storageKey); + return raw ? Object.entries(JSON.parse(raw) as Record) : []; +} + +/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */ +function persistKeyed(name: KeyedState, storageKey: string, values: Map): void { + const scope = keyedScope(storageKey); + if (scope) writeKeyed(name, scope.projectId, scope.routeId, values); + else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values))); } /** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ @@ -215,7 +243,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); + persistKeyed("rockb", storageKey, offsets); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -228,10 +256,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, offset]) => { + loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => { if (Number.isFinite(offset)) offsets.set(chainage, offset); }); } catch { @@ -318,7 +343,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + persistKeyed("cutslope", storageKey, ratios); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -331,10 +356,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, ratio]) => { + loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => { // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); }); diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py new file mode 100644 index 000000000..de9c02d68 --- /dev/null +++ b/common_util/common_util_station_key.py @@ -0,0 +1,52 @@ +"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + +일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · +시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. + +⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. +한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. +""" + +from __future__ import annotations + +import math +import re + +_LOOSE = re.compile(r"^-?\d+(\.\d+)?$") + + +def _half_up(value: float) -> int: + """JS ``Math.round`` 와 같은 반올림(0.5 는 위로).""" + return math.floor(value + 0.5) + + +def chainage_key(chainage_m: float) -> str: + """누가거리 0.01 m — 옛 초안 열쇠(``toFixed(2)``)와 같은 자릿수.""" + return f"{_half_up(chainage_m * 100) / 100:.2f}" + + +def station_key( + kind: str, + chainage_m: float, + interval_m: float | None, + station_id: str | None = None, + id_kind: str = "structure", +) -> str: + """측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:.""" + if kind == "bp": + return "end:BP" + if kind == "ep": + return "end:EP" + if kind in ("pipe", "structure"): + return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if kind == "irregular": + return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if interval_m and interval_m > 0: + return f"grid:{_half_up(chainage_m / interval_m)}" + return f"at:{chainage_key(chainage_m)}" + + +def loose_chainage(key: str) -> float | None: + """옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None.""" + text = key[3:] if key.startswith("at:") else key + return float(text) if _LOOSE.match(text) else None diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts new file mode 100644 index 000000000..db398ac99 --- /dev/null +++ b/common_util/common_util_station_key.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * common_util_station_key.ts + * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · + * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * + * ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도 + * 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`. + * ========================================================================== */ + +/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) 그대로 받음. */ +export type StationKeyKind = "bp" | "ep" | "regular" | "irregular" | "pipe" | "structure"; + +/** 누가거리 0.01 m — 옛 초안 열쇠(`toFixed(2)`)와 같은 자릿수. */ +export const chainageKey = (chainageM: number): string => + (Math.round(chainageM * 100) / 100).toFixed(2); + +/** + * 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:`. + * 간격이 없거나 0 이하면 일반 측점도 `at:`. + */ +export function stationKey( + kind: StationKeyKind, + chainageM: number, + intervalM: number | null, + id: string | null = null, + idKind: "pipe" | "structure" = "structure", +): string { + if (kind === "bp") return "end:BP"; + if (kind === "ep") return "end:EP"; + if (kind === "pipe" || kind === "structure") + return id ? `${kind}:${id}` : `at:${chainageKey(chainageM)}`; + if (kind === "irregular") return id ? `${idKind}:${id}` : `at:${chainageKey(chainageM)}`; + if (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`; + return `at:${chainageKey(chainageM)}`; +} + +/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */ +export function looseChainage(key: string): number | null { + const text = key.startsWith("at:") ? key.slice(3) : key; + if (!/^-?\d+(\.\d+)?$/.test(text)) return null; + return Number(text); +} diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 08d4d6794..400e71d70 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -309,3 +309,4 @@ 232. (원문 · 10-16) 1. 결국 초기화 데이터는 전처리 과정에서 나온 결과물일테고 만약 추천 노선으로 변경된다면 그게 초기값이 될것같음. 거기에는 예정노선을 따라가는 기능도 있으니. 2. 배관과 구조물을 제안하면 좋지만 이건 배수유역 계산해야 되어 몰래 업데이트 하거나 수동업데이트 하려고 했음. 사용자 조작 편의성을 위해 화면 프리즈 안되고 0.3초 안에 종단 횡단 평면(노선) 이 사로 연동되어 바뀐다면 배관제안이나 구조물 제안도 좋음. 이때 조심해야할 내용은 양방향 연동이리는 점임. 필요하면 변경지점을 지정하는 로직을 넣어 국부적으로 연산하는 기능을 구현해도 좋음. 3. 상세 신규계획서를 작성하고 너가 브레인으로써 잘 수행해줄것. 나는 한동안 신경 못쓰니 너가 제어해서 최종 브라우져 검증까지해서 아티팩트 보고 할것. +233. (원문 · 10-16) 캡쳐한 그림을 보고 제안 b05의 메인은 지금처럼 계획노선 편집 화면을 보여주는 건 맞음. 단 3d 처럼 상세 전체영역을 사용하는게 맞으며 하단 종단 패널은 오버레이 방식이 맞음. 이때 하단에 종단 그래프가 있으니 기존에 대략적으로 보여주던 원반선과 계획 종단선 그래프는 필요없음(종단그래프 영역 아님 조심할것.) diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 7f3b15bce..759e05c8f 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { } }, }, + // 측점 열쇠 세우기(344-3) — 이 시험 몫 아님 + "./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_projectId, chainages) => { asked.push(chainages); diff --git a/resources/tester/helper_344_3_entry.ts b/resources/tester/helper_344_3_entry.ts new file mode 100644 index 000000000..b45c2a570 --- /dev/null +++ b/resources/tester/helper_344_3_entry.ts @@ -0,0 +1,26 @@ +/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. + * 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다. + */ +export { + carryStationDrafts, + chainageOfKey, + keyAt, + readKeyed, + useStationKeys, + useStructureIds, +} from "../../B06_Section/B06_Section_Station_Keys"; +export { + crossDesignChoices, + readShoulderExtends, + writeCrossDesignChoice, + writeShoulderExtend, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { + createRockBoundaryStore, + readRockBoundarySession, +} from "../../B06_Section/B06_Section_UI_Page_Persist"; +export { createCrossStage, groundSamples } from "../../B06_Section/B06_Section_Cross_Stage"; +export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage"; +export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; +export { buildAlignment, emptyEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +export { readState, stateKey, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_344_3_station_keys.mjs b/resources/tester/helper_344_3_station_keys.mjs new file mode 100644 index 000000000..542aabc22 --- /dev/null +++ b/resources/tester/helper_344_3_station_keys.mjs @@ -0,0 +1,240 @@ +/* 344-3 · 346-2 2파 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_344_3_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_344_3_station_keys.mjs <묶음.mjs> <입력.json> + * 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 }. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); + +const [bundlePath, inputPath] = process.argv.slice(2); +const m = await import(pathToFileURL(bundlePath).href); +const input = JSON.parse(readFileSync(inputPath, "utf8")); +const P = "p344"; +const R = 7; +const R2 = 8; +const raw = (name, route) => m.readState(name, P, route) ?? {}; +const keysOf = (name, route) => + Object.keys(raw(name, route)) + .filter((k) => k !== "@at") + .sort(); +const section = (chainage_m, kind, extra = {}) => ({ + station_id: `s${chainage_m}`, + chainage_m, + kind, + label: String(chainage_m), + samples: [], + ...extra, +}); +const detailOf = (sections) => ({ + longitudinal: { + stations: sections, + profile_alignment: { policy: { station_interval_m: 20 } }, + }, + cross_sections: sections, +}); +const choice = (ground) => ({ + ground_type: ground, + section_mode: "right_cut", + ditch_side: "right", + ditch_type: "l_type", + paved: true, + two_stage_slope: true, + ditch_choice: false, +}); +const out = {}; + +/* 1. 옛 초안(누가거리 열쇠) → 측점 열쇠로 옮김 · 지금 측점에 없는 것은 남겨 둠 */ +const oldRoute = detailOf([ + section(0, "bp"), + section(20, "regular"), + section(40, "regular"), + section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }), + section(52.5, "irregular", { structure: "대피로" }), + section(60, "regular"), + section(70, "ep"), +]); +m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 52.5 }]); +m.useStationKeys(P, R, oldRoute); +m.writeState( + "crossdesign", + { + "20.00": choice("soil"), + 45.05: choice("blasting_rock"), + "52.50": choice("soil"), + "60.00": choice("soil"), + "99.00": choice("soil"), + }, + P, + R, +); +out.legacyRead = [...m.crossDesignChoices(P, R).keys()].sort((a, b) => a - b); +out.legacyKeys = keysOf("crossdesign", R); + +/* 2. 쓰기 = 측점 열쇠 · 누가거리로 다시 읽음 · 암 경계 저장소도 같은 열쇠 */ +m.writeCrossDesignChoice(P, R, 40, choice("ripping_rock")); +m.writeShoulderExtend(P, R, 40, "left", 0.5); +out.writeKey = keysOf("crossdesign", R).includes("grid:2"); +out.shoulderKeys = keysOf("shoulderext", R); +const rock = m.createRockBoundaryStore({ + sessionKey: () => m.stateKey("rockb", P, R), + detail: () => oldRoute, + refreshCard: () => undefined, + recompute: () => undefined, +}); +rock.control.adjust(20, -0.3); +out.rockKeys = keysOf("rockb", R); +rock.load(); +out.rockLoaded = Object.fromEntries(rock.offsets); +out.rockSession = m.readRockBoundarySession(P, R); + +/* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */ +m.useStructureIds(P, []); +m.useStationKeys(P, R, oldRoute); +out.hintKeyNow = m.keyAt(P, R, 52.5); +out.hintRead = m.crossDesignChoices(P, R).has(52.5); + +/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */ +m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김 +m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]); +m.useStationKeys( + P, + R2, + detailOf([ + section(0, "bp"), + section(20, "regular"), + section(40, "regular"), + section(47, "irregular", { structure: "파형강관 D800", culvert: {} }), + section(55, "irregular", { structure: "대피로" }), + section(58, "ep"), + ]), +); +// 옛 노선 목록(st-1 은 52.5 · 관 장부는 옛 자리 기준으로 이미 열쇠가 섬) +out.outside = m.carryStationDrafts(P, R, R2); +out.carriedKeys = keysOf("crossdesign", R2); +out.carriedChoice = raw("crossdesign", R2)["grid:1"]; +out.carriedRead = [...m.crossDesignChoices(P, R2).keys()].sort((a, b) => a - b); +out.carriedRock = m.readRockBoundarySession(P, R2); +out.carriedShoulder = keysOf("shoulderext", R2); + +/* 5. cross 단계 — 가짜 입력 · 같은 입력 0 · 측점 하나 고침 = 1 · 범위 밖 재사용 */ +const grid = [0, 20, 40, 60, 80, 100]; +const groundAt = (c) => 100 + 0.05 * c - 3 * Math.sin(c / 17); +const base = { + policy: input.policy, + onlyExplicitCurves: false, + basePvi: grid.map((c, i) => ({ chainage_m: c, elevation_m: groundAt(c) + (i % 2 ? 0.8 : -0.6) })), + chainage: Array.from({ length: 101 }, (_, i) => i), + ground: Array.from({ length: 101 }, (_, i) => groundAt(i)), + stations: grid.map((c) => ({ station_id: `s${c}`, chainage_m: c })), +}; +const profile = m.buildAlignment(base, m.emptyEdits()); +const side = (c, sign) => + Array.from({ length: 24 }, (_, i) => ({ + offsetM: (i + 1) * 0.5, + z: groundAt(c) + sign * 0.3 * (i + 1) * 0.5, + })); +const ground = { + stations: grid.map((c, i) => ({ + key: i === 0 ? "end:BP" : i === grid.length - 1 ? "end:EP" : `grid:${i}`, + chainageM: c, + x: c, + y: 0, + azimuthDeg: 90, + left: [0, 1], + kind: i === 0 || i === grid.length - 1 ? "end" : "grid", + id: null, + ground: { centerZ: groundAt(c), left: side(c, 1), right: side(c, -1) }, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: null, + })), + longitudinal: { chainageM: [], z: [] }, +}; +const samples = m.groundSamples(ground.stations[1]); +out.samplesSorted = samples.every((s, i) => i === 0 || s.offset_m > samples[i - 1].offset_m); +out.samplesCount = samples.length; +const { stage, counts } = m.createCrossStage(); +const ctxOf = (cross, range, prev, prof = profile) => ({ + generation: 1, + range, + signal: new AbortController().signal, + inputs: { route: null, alignment: null, cross, pipes: null, structures: null }, + out: { profile: prof, ground }, + before: {}, + prev, +}); +const crossInput = { standard: input.standard, byKey: { "grid:2": { groundType: "soil" } } }; +const first = stage(ctxOf(crossInput, null, null)); +out.crossFirst = { ...counts(), keys: Object.keys(first.byKey).length }; +out.crossHasArea = Object.values(first.byKey).every((r) => Number.isFinite(r.cutAreaM2)); +stage(ctxOf(crossInput, null, first)); +out.crossSame = counts(); +const touched = { ...crossInput, byKey: { ...crossInput.byKey, "grid:3": { groundType: "soil" } } }; +const third = stage( + ctxOf(touched, { sources: ["cross"], fromM: 60, toM: 60, keys: ["grid:3"] }, first), +); +out.crossOne = counts(); +out.crossOneChanged = third.byKey["grid:3"].detail.ground_type; +const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curve_radii: {} }); +stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited)); +out.crossGrade = counts(); + +/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */ +const conversion = { + soil: { loose: 1.25, compacted: 0.9 }, + ripping_rock: { loose: 1.5, compacted: 1.2 }, + blasting_rock: { loose: 1.65, compacted: 1.35 }, +}; +const massStage = m.createMassHaulStage(() => ({ conversion })); +const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } }); +out.massSeries = mass.series.map((s) => s.basis); +out.massHaulInput = Array.isArray(mass.haulInput?.points); + +/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */ +const posts = []; +globalThis.fetch = async (_url, init) => { + if (init.signal?.aborted) throw new Error("aborted"); + posts.push(1); + return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) }; +}; +const haul = m.createHaulStage(P, () => R); +const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({ + ...ctxOf(crossInput, null, prev), + signal, + out: { masshaul }, +}); +const plan1 = await haul(haulCtx(mass, null)); +const plan2 = await haul(haulCtx(mass, plan1)); +const other = massStage({ + ...ctxOf(crossInput, null, null), + out: { profile: edited, cross: third }, +}); +const aborted = new AbortController(); +aborted.abort(); +const plan3 = await haul(haulCtx(other, plan1, aborted.signal)); +out.haul = { posts: posts.length, plan1, plan2, plan3 }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_345_2_3d_modal.cjs b/resources/tester/helper_345_2_3d_modal.cjs index 83466b52a..2bda4f7c1 100644 --- a/resources/tester/helper_345_2_3d_modal.cjs +++ b/resources/tester/helper_345_2_3d_modal.cjs @@ -111,6 +111,7 @@ const frames = (n) => { let terrainLoads = 0; let resolveTerrain; +let opens = 0; // 345-4 — 열 때(지형 뒤) 낡은 3D 길 모형 다시 짓기 부름 const view3d = createViewer3dModal({ viewer, loadTerrain: () => { @@ -118,6 +119,7 @@ const view3d = createViewer3dModal({ return new Promise((resolve) => (resolveTerrain = resolve)); }, visible: () => true, + onOpen: () => (opens += 1), }); (async () => { @@ -131,12 +133,15 @@ const view3d = createViewer3dModal({ const first = modals[0]; out.wide = first.opts.wide === true; out.inModal = viewer.root.parent === first.body; + await new Promise((r) => setImmediate(r)); + out.opensBeforeTerrain = opens; out.spinnerWhileLoading = viewer.root.children.length === 1; frames(3); out.openDraws = viewer.draws; resolveTerrain(); await new Promise((r) => setImmediate(r)); out.spinnerGone = viewer.root.children.length === 0; + out.opensAfterTerrain = opens; // 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙) out.beforeCloseOk = (await first.opts.beforeClose()) === true; @@ -145,6 +150,8 @@ const view3d = createViewer3dModal({ out.afterCloseDraws = viewer.draws; out.backInPark = viewer.root.parent === view3d.park; out.disposedOnClose = viewer.disposed; + await new Promise((r) => setImmediate(r)); + out.opensWhileClosed = opens; view3d.open(); view3d.open(); // 열린 동안 또 눌러도 하나 @@ -152,10 +159,27 @@ const view3d = createViewer3dModal({ out.terrainLoads = terrainLoads; frames(2); out.reopenDraws = viewer.draws; + await new Promise((r) => setImmediate(r)); + out.opensAfterReopen = opens; view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림 out.closedOnDispose = modals[1].open === false; out.disposed = viewer.disposed; out.runningAfterDispose = viewer.running; + + // 지형 받는 사이 닫으면 — 다시 짓기 안 부름(닫힌 동안 짓기 0) + let lateOpens = 0; + let lateResolve; + const late = createViewer3dModal({ + viewer: { root: new FakeNode("div"), setActive() {}, dispose() {} }, + loadTerrain: () => new Promise((resolve) => (lateResolve = resolve)), + visible: () => true, + onOpen: () => (lateOpens += 1), + }); + late.open(); + late.close(); + lateResolve(); + await new Promise((r) => setImmediate(r)); + out.closedBeforeTerrainOpens = lateOpens; console.log(JSON.stringify(out)); })(); diff --git a/resources/tester/helper_345_4_corridor_worker.cjs b/resources/tester/helper_345_4_corridor_worker.cjs new file mode 100644 index 000000000..fa6edd906 --- /dev/null +++ b/resources/tester/helper_345_4_corridor_worker.cjs @@ -0,0 +1,132 @@ +/* 345-4 3D 길 모형 웹 워커 헬퍼 — 워커 몸(`handleCorridorRequest`) · 부르기(`buildCorridorOffThread`)를 + * 트랜스파일해 같은 입력으로 주 스레드 `buildCorridor` 와 견줌. 워커 경계는 structuredClone(+transfer)로 흉내 — + * 브라우저 postMessage 와 같은 복제 규칙. 측점 꼴은 helper_115_8_corridor_loft.cjs 와 같다. 결과는 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +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.replace(/import\.meta\.url/g, '"file:///x.js"'); + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith("@ui/") || request.startsWith("@config/")) + return new Proxy({}, { get: () => () => undefined }); + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { handleCorridorRequest, corridorTransfers } = loadTs(B05("B05_Profile_UI_Corridor_Worker")); +const { buildCorridorOffThread } = loadTs(B05("B05_Profile_UI_Corridor_Worker_Client")); + +const HALF = 3; +const ROAD_Z = 100; +function station(chainage, cx, cy, lx, ly, groundZ, ratio) { + const w = Math.abs(groundZ - ROAD_Z) * ratio; + const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut"; + const pts = [ + [-40, groundZ], + [-(HALF + w), groundZ], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [HALF + w, groundZ], + [40, groundZ], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: cx, + center_y: cy, + center_z: groundZ, + frame: { left_xy: [lx, ly] }, + samples, + design: { + section_mode: mode, + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +const sections = [ + station(0, 0, 0, 0, 1, 96, 1.5), + station(20, 20, 0, 0, 1, 104, 1.0), + station(40, 40, 0, 0, 1, 96, 1.5), +]; +const route = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; +const designSamples = [ + { chainage_m: 0, elevation_m: 100, ground_elevation_m: 96 }, + { chainage_m: 20, elevation_m: 100, ground_elevation_m: 104 }, + { chainage_m: 40, elevation_m: 100, ground_elevation_m: 96 }, +]; + +/** 비교용 평탄화 — 타입 배열은 숫자 배열로. */ +const plain = (value) => + JSON.parse(JSON.stringify(value, (_k, v) => (ArrayBuffer.isView(v) ? Array.from(v) : v))); + +(async () => { + const out = {}; + const direct = buildCorridor(sections, route, designSamples); + // 워커 경계 흉내 — 요청 · 응답 둘 다 구조화 복제 · 응답은 리본 버퍼를 넘김(transfer) + const request = structuredClone({ + id: 7, + crossSections: sections, + routePoints: route, + designSamples, + }); + const response = handleCorridorRequest(request); + const transfer = corridorTransfers(response.build); + const received = structuredClone(response, { transfer }); + out.ribbonCount = direct.ribbons.length; + out.idKept = received.id === 7; + out.sameAsMain = JSON.stringify(plain(received.build)) === JSON.stringify(plain(direct)); + out.float64 = received.build.ribbons.every((r) => r.positions instanceof Float64Array); + out.transferCount = transfer.length; + out.detached = response.build.ribbons.every((r) => r.positions.byteLength === 0); // 복사 없이 넘김 + // 측점 모자람 → null(워커도 같음) + out.tooFew = handleCorridorRequest({ + id: 1, + crossSections: [sections[0]], + routePoints: route, + }).build; + // 워커 없는 곳(시험 · 옛 브라우저) — 부르기가 주 스레드 같은 함수로 + const fallback = await buildCorridorOffThread(sections, route, designSamples); + out.fallbackSame = JSON.stringify(plain(fallback)) === JSON.stringify(plain(direct)); + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_346_4_design_leave.cjs b/resources/tester/helper_346_4_design_leave.cjs new file mode 100644 index 000000000..ebc38c799 --- /dev/null +++ b/resources/tester/helper_346_4_design_leave.cjs @@ -0,0 +1,167 @@ +/* 346-4 떠남 확인 단위검증 헬퍼 — `A00_Common/design/design_leave.ts` 를 그 자리에서 트랜스파일 · + * `b_page_state` 는 진짜 · 라우터 · 물음 창 · 문구는 흉내. 결과 JSON 을 stdout 으로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + clear: () => map.clear(), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const session = memoryStorage(); +const win = new EventTarget(); +win.sessionStorage = session; +win.localStorage = memoryStorage(); +win.setTimeout = setTimeout; +globalThis.window = win; +globalThis.sessionStorage = session; + +let route = "/b05-profile"; +let guard = null; +let guardSets = 0; +let choice = "cancel"; +const asked = []; +const STUBS = { + "@config/config_frontend": { + ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" }, + }, + "@ui/ui_template_locale": { t: (key) => key }, + "@ui/ui_template_leave_dialog": { + leaveOrStay: async (message, hooks) => { + asked.push(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") { + await hooks.discard(); + return "discarded"; + } + await hooks.save(); + return hooks.stillDirty() ? "stay" : "saved"; + }, + }, + "../router": { + currentRoute: () => route, + setLeaveGuard: (fn) => { + guard = fn; + guardSets += 1; + }, + }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); +const P = "p1"; +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); +/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */ +class BeforeUnload extends Event { + returnValue = true; +} +const unloadBlocked = () => { + const event = new BeforeUnload("beforeunload", { cancelable: true }); + win.dispatchEvent(event); + return event.defaultPrevented; +}; + +(async () => { + const out = {}; + const root = { isConnected: true }; + let saveMarks = true; + let saves = 0; + leave.guardDesignLeave(P, root, async () => { + saves += 1; + if (saveMarks) leave.markDesignClean(P); + }); + + out.emptyDirty = leave.designDirty(P); + // 고침이 아닌 칸 · 다른 페이지 칸 · 표준 횡단면만 — 안 물음 + session.setItem(`aislo:draft:selection:${P}`, '{"chainageM":20}'); + session.setItem(`aislo:draft:std-cross:${P}`, '{"soil":{}}'); + session.setItem(`aislo:draft:pipe-ids:${P}`, '{"next":3}'); + session.setItem(`aislo:draft:b08-materials:${P}`, '{"units":1}'); + session.setItem(`aislo:draft:crossdesign:${P}:7`, "{}"); // 빈 칸 + out.nonEditDirty = leave.designDirty(P); + out.nonEditUnload = unloadBlocked(); + route = "/b08-quantity"; + out.nonEditLeave = await guard(); + out.askedNonEdit = asked.length; + + // 횡단 초안(노선 7) — 물음 + session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"20.000":{"ground_type":"soil"}}'); + out.editDirty = leave.designDirty(P); + out.editUnload = unloadBlocked(); + + // 같은 페이지(B05 → B06) — 안 묻고 한 틱 뒤 다시 걺 + route = "/b06-section"; + const setsBefore = guardSets; + out.samePage = await guard(); + await tick(); + out.reArmed = guardSets === setsBefore + 1; + out.askedSamePage = asked.length; + + // 다른 페이지 — 취소 = 머묾 + route = "/b08-quantity"; + choice = "cancel"; + out.cancelLeave = await guard(); + + // 저장 실패(깨끗한 표 없음) = 머묾 · 저장 성공 = 넘어감 · 뒤로 안 물음 + choice = "save"; + saveMarks = false; + out.saveFailLeave = await guard(); + saveMarks = true; + out.saveLeave = await guard(); + out.saves = saves; + out.afterSaveDirty = leave.designDirty(P); + out.afterSaveUnload = unloadBlocked(); + const askedAfterSave = asked.length; + out.afterSaveLeave = await guard(); + out.askedAfterSave = asked.length - askedAfterSave; + + // 저장 뒤 다시 고침 — 다시 물음 · 버리고 넘어감 = 고침 칸만 지움 + session.setItem(`aislo:draft:alignment-draft:${P}:7`, '{"station_offsets":{"20.000":0.3}}'); + session.setItem(`aislo:draft:route-draft:${P}`, '{"rev":2}'); + out.reEditDirty = leave.designDirty(P); + choice = "discard"; + out.discardLeave = await guard(); + out.left = { + crossdesign: session.getItem(`aislo:draft:crossdesign:${P}:7`), + alignment: session.getItem(`aislo:draft:alignment-draft:${P}:7`), + routeDraft: session.getItem(`aislo:draft:route-draft:${P}`), + selection: session.getItem(`aislo:draft:selection:${P}`), + stdCross: session.getItem(`aislo:draft:std-cross:${P}`), + b08: session.getItem(`aislo:draft:b08-materials:${P}`), + }; + out.afterDiscardDirty = leave.designDirty(P); + + // 페이지가 화면에서 빠짐 — 안 물음 + session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"40.000":{}}'); + root.isConnected = false; + const askedGone = asked.length; + out.goneLeave = await guard(); + out.goneUnload = unloadBlocked(); + out.askedGone = asked.length - askedGone; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_347_2_drainage_live.cjs b/resources/tester/helper_347_2_drainage_live.cjs new file mode 100644 index 000000000..940201632 --- /dev/null +++ b/resources/tester/helper_347_2_drainage_live.cjs @@ -0,0 +1,57 @@ +/* 347-2 · 347-3 배수유역 자동/수동 · 제안 헬퍼 — logic 두 파일을 그 자리에서 트랜스파일해 + * Node 로 돌리고 값을 JSON 으로 낸다(틀 = helper_281_1). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +} + +const live = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Live.ts")); +const sug = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Suggest.ts")); + +const pipes = [ + { chainage_m: 100, source: "stream", options: { pipe_diameter_mm: 1000 } }, + { chainage_m: 300, source: "user", facility: "pipe" }, + { chainage_m: 500, source: "stream", facility: "box_culvert" }, +]; +const basins = [ + { chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }, + { chainage_m: 300, recommended_facility: "ford_bridge", recommended_diameter_mm: null }, + { chainage_m: 500, recommended_facility: "box_culvert", recommended_diameter_mm: null }, +]; +const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }]; +const before = JSON.stringify(pipes); +const suggestions = sug.collectSuggestions(pipes, basins, candidates); +const applied = sug.applySuggestions(pipes, suggestions); + +process.stdout.write( + JSON.stringify({ + quietMs: live.QUIET_MS, + modes: [ + live.parseDrainageMode(null), + live.parseDrainageMode("auto"), + live.parseDrainageMode("x"), + ], + stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })], + accept: [ + live.acceptLive({ status: "success", generation: 3 }, 3, 3), + live.acceptLive({ status: "success", generation: 3 }, 3, 4), + live.acceptLive({ status: "superseded" }, 3, 3), + live.acceptLive({ status: "success", generation: 2 }, 3, 3), + ], + body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]), + suggestions, + applied, + untouched: JSON.stringify(pipes) === before, + }), +); diff --git a/resources/tester/run_346_3_route_save_timing.py b/resources/tester/run_346_3_route_save_timing.py new file mode 100644 index 000000000..67e7081cf --- /dev/null +++ b/resources/tester/run_346_3_route_save_timing.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""346-3 노선 저장 실경로 시간 — 검증 프로젝트의 지금 노드 · 곡선 그대로 저장 한 번(수동 실행). + +같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값은 안 건드림(`as_initial` 끔). +쓰는 법: `./venv/Scripts/python.exe resources/tester/run_346_3_route_save_timing.py ` +""" + +import asyncio +import json +import logging +import sys +import time +from pathlib import Path +from uuid import UUID + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from config.config_db import close_db_pool, init_db_pool # noqa: E402 + + +async def main(project_id: UUID) -> None: + from B05_Profile import B05_Profile_Router_Replan as replan + from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest + + logging.basicConfig(level=logging.INFO, format="%(message)s") + await init_db_pool() + try: + plan = await replan.read_route_plan(project_id) + nodes = plan["nodes"] + radii: dict[int, float] = {} + for curve in plan["curves"]: + if curve.get("node_first") == curve.get("node_last"): + radii[int(curve["node_first"])] = float(curve["radius_m"]) + body = RouteSaveRequest( + vertices=[ + { + "x": node["x"], + "y": node["y"], + "curve": index in radii, + "radius_m": radii.get(index), + } + for index, node in enumerate(nodes) + ], + station_snap=bool(plan.get("station_snap")), + ) + started = time.perf_counter() + result = await replan.replan_route(project_id, body) + seconds = time.perf_counter() - started + body_text = getattr(result, "body", None) + print(json.dumps(json.loads(body_text) if body_text else result, ensure_ascii=False)) + print(f"[시간] 노선 저장 전체 {seconds:.1f}s") + finally: + await close_db_pool() + + +asyncio.run(main(UUID(sys.argv[1]))) diff --git a/resources/tester/test_133_2_design_terrain.py b/resources/tester/test_133_2_design_terrain.py index 6abb6a75c..92fddd064 100644 --- a/resources/tester/test_133_2_design_terrain.py +++ b/resources/tester/test_133_2_design_terrain.py @@ -18,7 +18,7 @@ import numpy as np import B03_FileInput.B03_FileInput_Service_Chain as chain import B04_PreProcess.B04_PreProcess_Service as service import B05_Profile.B05_Profile_Repository as route_repo -import common_util.common_util_drainage_pipes as drainage_pipes +import B05_Profile.B05_Profile_Route_Save as route_save import common_util.common_util_workflow_state as workflow_state from B05_Profile import B05_Profile_Router_Replan as replan from B06_Section import B06_Section_Router_Stations as stations @@ -212,16 +212,15 @@ def _patch_recompute(monkeypatch, tmp_path: Path, db: _Db, failure: str | None): async def start(*_args): return None - async def run_chain(_project_id, model_id, selection, _target=None): + async def run_chain(_project_id, _root, _stored, model_id, selection, *_rest): seen["model_id"], seen["selection"] = model_id, dict(selection) - return failure + return {"error": failure} if failure else {"route_id": 238} points = [{"x": 0.0, "y": 0.0}, {"x": 5.0, "y": 5.0}] monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool()) - monkeypatch.setattr(drainage_pipes, "clear_pipe_points", lambda _s: None) monkeypatch.setattr(route_repo, "get_latest_route", latest) monkeypatch.setattr(chain, "_planned_route_points_in_project_crs", lambda *_a: points) - monkeypatch.setattr(chain, "run_redesign_chain", run_chain) + monkeypatch.setattr(route_save, "run_route_save_chain", run_chain) monkeypatch.setattr(workflow_state, "get_workflow_state", state) monkeypatch.setattr(workflow_state, "start_stage", start) return seen diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py new file mode 100644 index 000000000..66e3af00d --- /dev/null +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠. + +일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe: · 구조물 structure: · +시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리. +""" + +import json +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from common_util.common_util_station_key import ( # noqa: E402 + chainage_key, + loose_chainage, + station_key, +) + +TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts" +TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript" + +CASES = [ + ("bp", 0.0, 20.0, None, "structure"), + ("ep", 812.345, 20.0, None, "structure"), + ("regular", 40.0, 20.0, None, "structure"), + ("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로) + ("regular", 69.996, 20.0, None, "structure"), + ("regular", 40.0, None, None, "structure"), + ("irregular", 85.054, 20.0, "pipe-s3", "pipe"), + ("irregular", 85.054, 20.0, "st-7", "structure"), + ("irregular", 85.054, 20.0, None, "structure"), + ("irregular", 1.005, 20.0, None, "structure"), + ("pipe", 12.344, 20.0, "pipe-s1", "structure"), + ("structure", 12.345, 20.0, None, "structure"), +] +LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] + +SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const src = fs.readFileSync(process.argv[2], "utf8"); +const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, opts).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const m = box.exports; +const input = JSON.parse(process.argv[3]); +process.stdout.write(JSON.stringify({ + keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])), + chainage: input.cases.map((c) => m.chainageKey(c[1])), + loose: input.loose.map((k) => m.looseChainage(k)), +})); +""" + + +def test_파이썬과_TS_열쇠_같음(): + proc = subprocess.run( + [ + "node", + "-e", + SCRIPT, + str(TYPESCRIPT), + str(TS), + json.dumps({"cases": CASES, "loose": LOOSE}), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["keys"] == [station_key(*case) for case in CASES] + assert out["chainage"] == [chainage_key(case[1]) for case in CASES] + assert out["loose"] == [loose_chainage(key) for key in LOOSE] + + +def test_열쇠_규칙(): + assert station_key("bp", 0.0, 20.0) == "end:BP" + assert station_key("ep", 812.3, 20.0) == "end:EP" + assert station_key("regular", 50.0, 20.0) == "grid:3" + assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3" + assert station_key("irregular", 85.054, 20.0) == "at:85.05" + assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None diff --git a/resources/tester/test_344_3_station_keys.py b/resources/tester/test_344_3_station_keys.py new file mode 100644 index 000000000..a00ddd633 --- /dev/null +++ b/resources/tester/test_344_3_station_keys.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계. + +못박는 것 + - 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김. + - 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음. + - 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음. + - 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 · + 단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」. + - cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용. + - masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로. + +Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을 +부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + +FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json" + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b344") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_344_3_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_344_3_entry.js", bundle) + policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"] + source = work / "input.json" + source.write_text( + json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str), + encoding="utf-8", + ) + proc = subprocess.run( + ["node", str(HERE / "helper_344_3_station_keys.mjs"), str(bundle), str(source)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_옛_초안은_읽을_때_측점_열쇠로_옮김(out): + assert out["legacyRead"] == [20, 45.05, 52.5, 60] + assert out["legacyKeys"] == ["99.00", "grid:1", "grid:3", "pipe:pipe-s1", "structure:st-1"] + + +def test_쓰기는_측점_열쇠_읽기는_누가거리(out): + assert out["writeKey"] is True + assert out["shoulderKeys"] == ["grid:2"] + assert out["rockKeys"] == ["grid:1"] + assert out["rockLoaded"] == {"20.00": -0.8} + assert out["rockSession"] == {"20.00": -0.8} + + +def test_구조물_id_모를_때도_쓴_자리로_읽음(out): + assert out["hintKeyNow"] == "at:52.50" + assert out["hintRead"] is True + + +def test_노선_바뀜_측점_열쇠로_넘김(out): + assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안) + assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"] + assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로 + assert out["carriedChoice"] == { + "ground_type": "soil", + "ditch_type": "l_type", + "ditch_choice": False, + "two_stage_slope": True, + } + assert out["carriedRock"] == {"20.00": -0.8} + assert out["carriedShoulder"] == [] + + +def test_cross_단계_국부_연산(out): + assert out["samplesSorted"] is True and out["samplesCount"] == 49 + assert out["crossFirst"] == {"computed": 6, "reused": 0, "keys": 6} + assert out["crossHasArea"] is True + assert out["crossSame"] == {"computed": 0, "reused": 6} + assert out["crossOne"] == {"computed": 1, "reused": 5} + assert out["crossOneChanged"] == "soil" + assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3 + + +def test_masshaul_haul_단계(out): + assert out["massSeries"] == ["cross", "longitudinal"] + assert out["massHaulInput"] is True + assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}} diff --git a/resources/tester/test_345_2_3d_modal.py b/resources/tester/test_345_2_3d_modal.py index 751a94981..457e5fe0d 100644 --- a/resources/tester/test_345_2_3d_modal.py +++ b/resources/tester/test_345_2_3d_modal.py @@ -48,6 +48,15 @@ def test_처음_열_때_지형_한_번_로딩_원(out): assert out["reopenDraws"] == 5 +def test_열_때만_다시_짓기_닫힌_동안_0(out): + """345-4 — 낡은 3D 길 모형은 모달을 열 때(지형이 선 뒤)만 다시 짓기를 부름 · 닫혀 있으면 안 부름.""" + assert out["opensBeforeTerrain"] == 0 + assert out["opensAfterTerrain"] == 1 + assert out["opensWhileClosed"] == 1 + assert out["opensAfterReopen"] == 2 # 다시 열 때 한 번(열린 동안 또 눌러도 하나) + assert out["closedBeforeTerrainOpens"] == 0 + + def test_떠날_때만_버림(out): assert out["beforeCloseOk"] is True assert out["closedOnDispose"] is True diff --git a/resources/tester/test_345_4_contour_one_url.py b/resources/tester/test_345_4_contour_one_url.py new file mode 100644 index 000000000..b7db895b6 --- /dev/null +++ b/resources/tester/test_345_4_contour_one_url.py @@ -0,0 +1,35 @@ +"""345-4 정리 — B05 등고선 주소 한 벌(`contourAssetUrl` · 미리 받기와 같은 파일) · 빈 saveCorridorIfDirty 지움.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_B05_등고선_주소는_한_벌(): + for rel in ( + "B05_Profile/B05_Profile_UI_Viewer.ts", + "B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts", + ): + source = _read(rel) + assert "contourAssetUrl(" in source, rel + assert "/contour?interval" not in source, rel + # 미리 받기(b_asset_cache)도 같은 함수 — 주소가 같아야 보관함 한 칸을 같이 씀 + assert "contourAssetUrl(projectId, confirmed.model_id, interval, smooth)" in _read( + "A00_Common/b_asset_cache.ts" + ) + + +def test_빈_코리도_올림_함수_지움(): + for rel in ( + "B05_Profile/B05_Profile_UI_Corridor.ts", + "B05_Profile/B05_Profile_UI_Page_Actions.ts", + "A00_Common/design/design_view_profile.ts", + "A00_Common/design/design_view_profile_3d.ts", + ): + assert "saveCorridorIfDirty" not in _read(rel), rel diff --git a/resources/tester/test_345_4_corridor_worker.py b/resources/tester/test_345_4_corridor_worker.py new file mode 100644 index 000000000..4a378e427 --- /dev/null +++ b/resources/tester/test_345_4_corridor_worker.py @@ -0,0 +1,72 @@ +"""345-4 3D 길 모형 웹 워커 — 워커 결과 = 주 스레드 결과(같은 입력) · 리본 좌표는 복사 없이(Transferable) · +서버 저장본이 먼저(해시 맞으면 받기만) · 저장 안 한 초안일 때만 워커 미리보기 · 모달을 열 때만 다시 짓기. + +셈은 Node 헬퍼 `helper_345_4_corridor_worker.cjs`(워커 경계 = structuredClone + transfer) · 배선은 글로 봄. +모달 닫힌 동안 짓기 0 은 `test_345_2_3d_modal.py`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_345_4_corridor_worker.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(run.stdout) + + +def test_워커_결과는_주_스레드와_같음(out): + assert out["ribbonCount"] > 0 + assert out["sameAsMain"] is True and out["idKept"] is True + assert out["float64"] is True # 모델 좌표 해상도 그대로 + assert out["tooFew"] is None + assert out["fallbackSame"] is True # 워커 없는 곳은 주 스레드 같은 함수 + + +def test_리본_좌표는_복사_없이_넘김(out): + assert out["transferCount"] == out["ribbonCount"] + assert out["detached"] is True + + +def test_저장본_먼저_초안만_워커(): + corridor = _read("B05_Profile/B05_Profile_UI_Corridor.ts") + body = corridor[corridor.index("export async function ensureCorridor(") :] + body = body[: body.index("\n}\n")] + assert body.index("fetchStored(") < body.index("buildCorridorOffThread(") + assert "if (!rebuild) return null;" in body # 진입 = 저장본만(짓지 않음) + assert "buildCorridor(" not in body.replace("buildCorridorOffThread(", "") + client = _read("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") + assert 'new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url)' in client + worker = _read("B05_Profile/B05_Profile_UI_Corridor_Worker.ts") + for banned in ("document.", "window.", "sessionStorage", 'from "three"'): + assert banned not in worker + + +def test_모달_열_때_낡았으면_다시_짓기(): + view3d = _read("A00_Common/design/design_view_profile_3d.ts") + assert "if (state.corridorPending) void refreshOnce();" in view3d + assert "onCorridorRefresh: () => refreshOnce()," in view3d + assert "deps.sections().setCorridorPending(false);" in view3d + sections = _read("A00_Common/design/design_view_profile_sections.ts") + assert ".then((fresh) => setCorridorPending(!fresh));" in sections + assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로 + view = _read("A00_Common/design/design_view_profile.ts") + assert "onCrossDesignsUpdated: () => sections.setCorridorPending(true)," in view diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py new file mode 100644 index 000000000..3a0618cb5 --- /dev/null +++ b/resources/tester/test_346_3_route_save.py @@ -0,0 +1,561 @@ +# -*- coding: utf-8 -*- +"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 · +초기값 규칙 · +배수유역 낡음 · 실패 때 옛 노선 되살림. + +순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API +(`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from fastapi.responses import JSONResponse # noqa: E402 + +import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 +import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 +import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 +import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 +import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402 +import B05_Profile.B05_Profile_Router_RouteInitial as route_initial # noqa: E402 +import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402 +import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 +import B06_Section.B06_Section_Router as b06_router # noqa: E402 +import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 +import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402 +import common_util.common_util_initial_snapshot as snapshot # noqa: E402 +import common_util.common_util_workflow_state as workflow # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 + add_crossing_pipes, + carry_cross_inputs, + concave_chainages, + fill_concave_pipes, + follow_pipes, + station_keys, + vertices_of, +) +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PIPE_SOURCE_SPACING, + PIPE_SOURCE_STREAM, + PipePoint, + analysis_route_signature, + route_signature, +) + +OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)] +# 새 노선 = 100 ~ 300 m 를 북으로 휘게(앞 · 뒤는 옛 노선 위) +NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)] + + +# ── 관 따라감 · 교차점 · 오목 보충 ───────────────────────────────────────────── + + +def test_관은_지우지_않고_좌표를_따라감(): + old = [ + PipePoint(chainage_m=50.0, x=50.0, y=0.0, options={"pipe_kind": "흄관"}), + PipePoint(chainage_m=350.0, x=350.0, y=0.0, start_m=345.0, end_m=355.0), + ] + pairs = follow_pipes(old, vertices_of(NEW)) + assert [pipe_id for pipe_id, _ in pairs] == [0, 1] + assert pairs[0][1].options == {"pipe_kind": "흄관"} # 사용자 관 속성 그대로 + shift = pairs[1][1].chainage_m - 350.0 # 새 노선이 길어진 만큼 뒤 관이 밀림 + assert shift > 30 and pairs[1][1].start_m == pytest.approx(345.0 + shift) + assert old[1].chainage_m == 350.0 # 옛 목록은 안 고침 + + +def test_새_교차점에만_기본_관(): + pairs = follow_pipes([PipePoint(chainage_m=50.0, x=50.0, y=0.0)], vertices_of(NEW)) + crossings = [ + SimpleNamespace(chainage_m=20.0, x=20.0, y=0.0), # 옛 노선 위 — 사용자가 지운 관 안 되살림 + SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0), # 새 자리 · 관 없음 → 더함 + SimpleNamespace(chainage_m=52.0, x=52.0, y=0.0), # 옛 자리 + ] + added = add_crossing_pipes(pairs, crossings, OLD, 456.0) + assert added == 1 + new = [pipe for pipe_id, pipe in pairs if pipe_id < 0] + assert new[0].chainage_m == 150.0 and new[0].source == PIPE_SOURCE_STREAM + # 같은 자리 다시 = 이미 관이 있음 + assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0 + + +def test_오목_지점_보충은_새_자리의_오목만(): + vertices = vertices_of(NEW) + total = vertices[-1].chainage_m + chainage = np.arange(0.0, total, 2.0) + ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리) + ground[chainage < 60] = ( + 100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05 + ) # 30 m 골(옛 자리) + concave = concave_chainages(chainage, ground) + assert any(abs(at - 220.0) < 3 for at, _ in concave) + pairs: list = [] + added = fill_concave_pipes(pairs, concave, vertices, OLD) + places = [pipe.chainage_m for _, pipe in pairs] + assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs) + assert any(abs(at - 220.0) <= 3 for at in places) + assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠 + + +def test_간격이_좁으면_보충_없음(): + vertices = vertices_of(NEW) + pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)] + concave = [(220.0, 99.0)] + assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0 + + +# ── 측점 열쇠 · 횡단 이월 ────────────────────────────────────────────────────── + + +def _structure(sid, type_id, anchor, start=None, end=None): + return SimpleNamespace( + structure_id=sid, + type_id=type_id, + chainage_m=anchor, + start_m=start, + end_m=end, + options={}, + ) + + +def test_측점_열쇠_규칙(): + stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)] + pipes = [(3, PipePoint(chainage_m=50.0))] + structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)] + keys = station_keys(stations, pipes, structures, 20.0) + assert keys == { + "end:BP": 0.0, + "grid:1": 20.0, + "grid:2": 40.0, + "pipe:3": 50.0, + "structure:w1:start": 60.0, + "structure:w1": 77.0, + "structure:w1:end": 80.0, + "end:EP": 95.5, + } + + +def test_이월은_열쇠로_누가거리가_밀려도(): + old_keys = { + "end:BP": 0.0, + "grid:1": 20.0, + "pipe:0": 50.0, + "structure:w1": 77.0, + "grid:5": 100.0, + } + new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0} + designs = { + 0.0: {"ground_type": "soil"}, + 20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0}, + 50.0: {"ditch_type": "u", "inlet_structure": "basin", "basin_adjust": {"d": 1}}, + 77.0: {"rock_boundary_offset_m": 0.8, "revet_adjust": {"h": 2}}, + 100.0: {"ground_type": "soil"}, + } + inputs, adjusts, dropped = carry_cross_inputs( + old_keys, + new_keys, + designs, + ("ground_type", "ditch_type", "berm", "rock_boundary_offset_m"), + ("inlet_structure", "basin_adjust", "revet_adjust"), + ) + assert inputs == { + 0.0: {"ground_type": "soil"}, + 20.0: {"ground_type": "rock", "berm": {"on": True}}, # 결과 칸(면적)은 안 넘김 + 57.0: {"ditch_type": "u"}, + 84.0: {"rock_boundary_offset_m": 0.8}, + } + # 구조물 조정은 관 · 구조물 열쇠만 따라감 + assert adjusts == { + 57.0: {"inlet_structure": "basin", "basin_adjust": {"d": 1}}, + 84.0: {"revet_adjust": {"h": 2}}, + } + assert dropped == 1 # grid:5 — 새 노선에 없음(노선 밖 고침) + + +# ── 노선 초안 요청 ──────────────────────────────────────────────────────────── + + +def test_초안_모양을_옛_칸으로(): + body = save.RouteSaveRequest( + draft={ + "rev": 3, + "nodes": [[0, 0], [10, 5], [20, 0]], + "curveOn": [True, False, True], + "curveRadius": [None, 30.0, None], + "curveLock": [None, "radius", None], + "curveArc": [None, None, None], + "stationSnap": True, + "terrain": "sheet", + } + ) + request = body.resolved() + assert [(v.x, v.y, v.curve, v.radius_m) for v in request.vertices] == [ + (0, 0, True, None), + (10, 5, False, 30.0), + (20, 0, True, None), + ] + assert request.station_snap is True and request.terrain == "sheet" + with pytest.raises(ValueError): + save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}).resolved() + + +# ── 체인 차례 · 되살림 · 배수유역 낡음 ───────────────────────────────────────── + + +class _Cursor: + def __init__(self, log): + self.log = log + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, sql, params=None): + self.log.append(" ".join(sql.split())) + + +class _Connection: + def __init__(self, log): + self.log = log + + def cursor(self, *args): + return _Cursor(self.log) + + async def begin(self): + return None + + async def commit(self): + return None + + async def rollback(self): + return None + + +class _Pool: + def __init__(self, log): + self.log = log + + def acquire(self): + connection = _Connection(self.log) + + class _Acquire: + async def __aenter__(self): + return connection + + async def __aexit__(self, *exc): + return False + + return _Acquire() + + +def _async(value, order=None, name=None): + async def fake(*args, **kwargs): + if order is not None: + order.append(name) + return value + + return fake + + +@pytest.fixture +def chain_world(monkeypatch, tmp_path): + order: list[str] = [] + sql: list[str] = [] + root = tmp_path + route_file = root / "B05_Profile" / "route" / "route_main.geojson" + route_file.parent.mkdir(parents=True) + route_file.write_text( + json.dumps({"geometry": {"coordinates": [[*p, 0] for p in OLD]}}), "utf-8" + ) + long_dir = root / "B06_Section" / "longitudinal" + long_dir.mkdir(parents=True) + (long_dir / "longitudinal.json").write_text(json.dumps({"stations": [], "old": True}), "utf-8") + drain = root / "drain" + pipes_file = drain / "edits" / "pipe_points.json" + pipes_file.parent.mkdir(parents=True) + pipes_file.write_text( + json.dumps( + {"route_signature": "old", "points": [{"chainage_m": 50.0, "x": 50.0, "y": 0.0}]} + ), + "utf-8", + ) + (drain / "00_watershed_response.json").write_text( + json.dumps({"route_signature": "old"}), "utf-8" + ) + state = {"stages": [{"stage_no": 2, "params": {"points": {}, "options": {}}}]} + + def solve(project_id, request, *, generate_sections=True): + order.append("solve") + assert generate_sections is False + route_file.write_text( + json.dumps({"geometry": {"coordinates": [[*p, 0] for p in NEW]}}), "utf-8" + ) + return _async( + SimpleNamespace( + route_id=8, + total_length_m=456.0, + route_data_path="B05_Profile/route/route_main.geojson", + ) + )() + + async def regenerate(*args, **kwargs): + order.append("sections") + (long_dir / "longitudinal.json").write_text( + json.dumps({"stations": [], "new": True}), "utf-8" + ) + return SimpleNamespace() + + async def watershed_must_not_run(*args, **kwargs): + raise AssertionError("배수유역은 체인에서 뗌") + + async def prepare(project_id): + order.append("pipes") + return {"vertices": vertices_of(NEW), "streams": []} + + monkeypatch.setattr(save, "get_db_pool", lambda: _Pool(sql)) + monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) + monkeypatch.setattr(workflow, "start_stage", _async(None, order, "restore_params")) + monkeypatch.setattr( + b06_repo, + "get_longitudinal_section", + _async( + {"data": {}, "longitudinal_file_path": "B06_Section/longitudinal/longitudinal.json"} + ), + ) + monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) + monkeypatch.setattr(b06_repo, "get_latest_section_options", _async({})) + monkeypatch.setattr( + structures_router, + "follow_structures_to_route", + lambda *a: order.append("structures") or {"moved": 0, "far": 0}, + ) + monkeypatch.setattr( + chain, + "redesign_solve_request", + lambda *a: b05_router.RouteSolveRequest( + filter_key="csf", + method="dtm", + smooth=False, + algorithm="as_planned", + bp={"x": 0, "y": 0}, + ep={"x": 400, "y": 0}, + ), + ) + monkeypatch.setattr(b05_router, "solve_route_record", solve) + monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) + monkeypatch.setattr(watershed, "get_primary_region", watershed_must_not_run) + monkeypatch.setattr(watershed, "_prepare", prepare) + monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run) + monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain) + monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0))) + monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate) + monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile")) + monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross")) + monkeypatch.setattr( + b06_confirm, "confirm_sections", _async(SimpleNamespace(), order, "confirm") + ) + monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) + monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) + return SimpleNamespace( + order=order, + sql=sql, + root=root, + route_file=route_file, + pipes_file=pipes_file, + long_dir=long_dir, + drain=drain, + ) + + +def _run(world, **kwargs): + old_route = {"id": 7, "route_data_path": "B05_Profile/route/route_main.geojson"} + return asyncio.run( + save.run_route_save_chain( + uuid4(), + world.root, + "p", + 1, + {}, + old_route, + [{"x": x, "y": y} for x, y in NEW], + {"old": 1}, + **kwargs, + ) + ) + + +def test_차례와_배수유역_뗌(chain_world): + result = _run(chain_world) + assert "error" not in result, result + assert chain_world.order == [ + "structures", + "solve", + "pipes", + "sections", + "profile", + "cross", + "confirm", + ] + assert result["cross_dropped"] == 2 and result["drainage_stale"] is True + assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0} + # 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」 + saved = json.loads(chain_world.pipes_file.read_text("utf-8")) + assert saved["route_signature"] == route_signature(vertices_of(NEW)) + assert analysis_route_signature("p") != saved["route_signature"] + assert len(saved["points"]) == 1 # 사용자 관 그대로(지우지 않음) + + +def test_실패하면_옛_노선을_되살림(chain_world, monkeypatch): + monkeypatch.setattr( + b06_confirm, "confirm_sections", _async(JSONResponse(status_code=500, content={})) + ) + before_pipes = chain_world.pipes_file.read_text("utf-8") + result = _run(chain_world) + assert result["error"].startswith("종횡단 확정 실패") + assert json.loads(chain_world.route_file.read_text("utf-8"))["geometry"]["coordinates"] == [ + [*p, 0] for p in OLD + ] + assert chain_world.pipes_file.read_text("utf-8") == before_pipes + assert json.loads((chain_world.long_dir / "longitudinal.json").read_text("utf-8")) == { + "stations": [], + "old": True, + } + assert any( + sql.startswith("DELETE FROM routes WHERE project_id = %s AND id > %s") + for sql in chain_world.sql + ) + assert chain_world.order[-1] == "restore_params" + assert not list(chain_world.root.glob("route_save_*")) # 손에 쥔 사본 치움 + + +def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch): + monkeypatch.setattr( + route_initial, "apply_profile_target", _async(None, chain_world.order, "balanced") + ) + _run(chain_world, profile_target=([0.0, 456.0], [100.0, 110.0])) + assert "balanced" in chain_world.order and "profile" not in chain_world.order + + +# ── 초기값 규칙 ─────────────────────────────────────────────────────────────── + + +@pytest.fixture +def api_world(monkeypatch, tmp_path): + calls: list = [] + monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p"))) + monkeypatch.setattr(replan, "_ensure_expected_route", lambda root: None) + monkeypatch.setattr(replan, "_plan_criteria", _async((20.0, 0.0, 0.0))) + monkeypatch.setattr(replan, "_ensure_planned_initial", lambda *a: None) + monkeypatch.setattr( + replan, + "_write_planned_polyline", + lambda *a, **k: {"vertices": 3, "curves": 0, "violations": 0}, + ) + monkeypatch.setattr(replan, "_recompute", _async({"route_id": 9, "total_length_m": 10.0})) + monkeypatch.setattr(replan, "read_initial_choice", lambda root: None) + monkeypatch.setattr( + route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)() + ) + return calls + + +def _body(**kwargs): + return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs) + + +def test_손으로_고친_노선_저장은_초기값_그대로(api_world): + result = asyncio.run(replan.replan_route(uuid4(), _body())) + assert result["initial_refreshed"] is False and api_world == [] + + +def test_추천_적용은_초기값_다시(api_world): + result = asyncio.run(replan.replan_route(uuid4(), _body(as_initial=True))) + assert result["initial_refreshed"] is True and len(api_world) == 1 + + +def test_초안_칸이_어긋나면_400(api_world): + body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}) + response = asyncio.run(replan.replan_route(uuid4(), body)) + assert response.status_code == 400 + + +def _longitudinal(total: float) -> dict: + from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options + from B05_Profile.B05_Profile_Engine_Grade_Profile import design_ground_following_profile + + samples = [ + {"chainage_m": float(s), "elevation_m": 100.0 + 0.05 * s + 2.0 * np.sin(s / 17.0)} + for s in range(int(total) + 1) + ] + stations = [{"chainage_m": float(s)} for s in range(0, int(total) + 1, 20)] + longitudinal = {"samples": samples, "stations": stations} + alignment, profile = design_ground_following_profile( + longitudinal, resolve_grade_options("trunk") + ) + return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]} + + +def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch): + new_xy = [list(p) for p in NEW] + total = float(vertices_of(NEW)[-1].chainage_m) + old = _longitudinal(400.0) + old["profile_alignment"]["edits"] = { + "station_offsets": {"40.000": 0.7, "200.000": -0.4, "380.000": 0.3}, + "curve_radii": {}, + } + path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(_longitudinal(total)), "utf-8") + import B05_Profile.B05_Profile_Repository as b05_repo + + monkeypatch.setattr(save, "get_db_pool", lambda: _Pool([])) + monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None)) + asyncio.run( + save._carry_profile( + tmp_path, + "B06_Section/longitudinal/longitudinal.json", + 8, + old["profile_alignment"], + [list(p) for p in OLD], + new_xy, + ) + ) + edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"] + delta = total - 400.0 + assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로 + assert "200.000" not in edits # 바뀐 구간 안 — 새 지반 + assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂 + + +def test_저장_확정이_옛_횡단_파일을_지움(tmp_path): + from B05_Profile.B05_Profile_Engine_Sections import cross_filename + + long_path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json" + cross_dir = tmp_path / "B06_Section" / "cross_sections" + long_path.parent.mkdir(parents=True) + cross_dir.mkdir(parents=True) + long_path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 20.0}]})) + for at in (0.0, 20.0, 37.0): + (cross_dir / cross_filename(at)).write_text("{}") + b06_confirm._prune_cross_files(tmp_path, "B06_Section/longitudinal/longitudinal.json") + assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted( + cross_filename(at) for at in (0.0, 20.0) + ) + source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8") + for name in ("async def save_sections(", "async def confirm_sections("): + body = source[source.index(name) :] + assert body.index("_stand_patch_stations(") < body.index("_prune_cross_files,") diff --git a/resources/tester/test_346_4_design_leave.py b/resources/tester/test_346_4_design_leave.py new file mode 100644 index 000000000..3a47c49a4 --- /dev/null +++ b/resources/tester/test_346_4_design_leave.py @@ -0,0 +1,57 @@ +"""346-4 떠남 확인 — B05 · B06 저장 안 한 조작이 있으면 탭 닫기 · 다른 페이지 때 물음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_design_leave.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).resolve().parent / "helper_346_4_design_leave.cjs" + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8" + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_고침_아닌_칸은_안_물음(out) -> None: + assert out["emptyDirty"] is False + assert out["nonEditDirty"] is False and out["nonEditUnload"] is False + assert out["nonEditLeave"] is True and out["askedNonEdit"] == 0 + + +def test_횡단_초안이면_물음(out) -> None: + assert out["editDirty"] is True and out["editUnload"] is True + assert out["cancelLeave"] is False + + +def test_B05_B06_사이는_안_묻고_다시_걺(out) -> None: + assert out["samePage"] is True and out["reArmed"] is True and out["askedSamePage"] == 0 + + +def test_저장_실패는_머묾_성공_뒤엔_안_물음(out) -> None: + assert out["saveFailLeave"] is False + assert out["saveLeave"] is True and out["saves"] == 2 + assert out["afterSaveDirty"] is False and out["afterSaveUnload"] is False + assert out["afterSaveLeave"] is True and out["askedAfterSave"] == 0 + + +def test_버리고_넘어감은_고침_칸만_지움(out) -> None: + assert out["reEditDirty"] is True and out["discardLeave"] is True + left = out["left"] + assert left["crossdesign"] is None and left["alignment"] is None and left["routeDraft"] is None + assert left["selection"] and left["stdCross"] and left["b08"] + assert out["afterDiscardDirty"] is False + + +def test_페이지가_빠지면_안_물음(out) -> None: + assert out["goneLeave"] is True and out["goneUnload"] is False and out["askedGone"] == 0 diff --git a/resources/tester/test_347_2_drainage_live_ui.py b/resources/tester/test_347_2_drainage_live_ui.py new file mode 100644 index 000000000..77940dcd9 --- /dev/null +++ b/resources/tester/test_347_2_drainage_live_ui.py @@ -0,0 +1,58 @@ +"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일). + +- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음. +- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림. +- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) · + [적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음). +Node 헬퍼(`helper_347_2_drainage_live.cjs`)가 TS 를 그 자리에서 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_347_2_drainage_live.cjs")], + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_mode_and_stale(r): + assert r["quietMs"] == 3000 + assert r["modes"] == ["manual", "auto", "manual"] + assert r["stale"] == [False, True] + + +def test_late_answers_dropped(r): + assert r["accept"] == [True, False, False, False] + assert r["body"] == {"generation": 5, "route": None, "points": [{"chainage_m": 1}]} + + +def test_suggestions_collected(r): + assert r["suggestions"] == [ + {"kind": "diameter", "chainageM": 100, "from": 1000, "to": 1200}, + {"kind": "position", "chainageM": 200}, + {"kind": "facility", "chainageM": 300, "from": "pipe", "to": "ford_bridge"}, + ] + + +def test_apply_once_without_touching_input(r): + assert r["untouched"] + applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]} + assert sorted(applied) == [100, 200, 300, 500] + assert applied[100]["options"]["pipe_diameter_mm"] == 1200 + assert applied[200]["source"] == "spacing" + assert applied[300]["facility"] == "ford_bridge" + assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로 diff --git a/resources/tester/test_session_keys_registered.py b/resources/tester/test_session_keys_registered.py index c49dbd168..b45cc4461 100644 --- a/resources/tester/test_session_keys_registered.py +++ b/resources/tester/test_session_keys_registered.py @@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다(): def test_옛_암경계_키가_코드에_안_남아_있다(): """실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지.""" persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - assert 'readState>("rockb"' in persist, "등록표를 안 거침" + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist, "등록표를 안 거침" # 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다. code = "\n".join( line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*")) diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 04ef0fefe..ff3053c52 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다(): persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( encoding="utf-8" ) - assert 'readState>("rockb"' in persist + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist # ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ─────────────────── diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 4c0e3ff0e..4488597ca 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -113,4 +113,5 @@ export const ui_locales_b4 = { "B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.", "Unsaved edits in B05 · B06 — save there first, then link.", ], + B05_Design_Leave: ["B05 · B06 에 저장 안 한 조작이 있음", "Unsaved edits in B05 · B06"], } as const;