diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f3df0b73c..b3f25c209 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -218,8 +218,6 @@ export const STATE_REGISTRY = { /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` }, - /** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */ - "profile-enforce-min-cover": { bucket: "result", scope: "project" }, /** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을 * 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리). * 노선이 바뀌면 `latest` 와 함께 버린다. */ diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 4937ff064..ffb4a246b 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -12,7 +12,6 @@ * ========================================================================== */ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans"; -import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import type { StructureInstance, @@ -44,6 +43,9 @@ import { toAlignmentBase, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { enforceMinCoverOf } from "../../B05_Profile/B05_Profile_Api_PageSettings"; +import { applyPageSettings } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; +import { useDesignStore } from "./design_store"; import { endOffsetsOf, withEndOffset, @@ -118,9 +120,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ let minCoverTargets: MinCoverPoint[] = []; - /** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제). - * 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */ - let enforceMinCover = readState("profile-enforce-min-cover", projectId) === true; /** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */ const takePipes = (pipes: ReadonlyArray): void => { // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. @@ -174,8 +173,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); /** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */ let preview: typeof store | null = null; - /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ - let savedEdits: AlignmentEdits = emptyEdits(); let redrawPending = false; let lastWidth = 0; let lastHeight = 0; @@ -262,14 +259,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), // 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다. drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined }, - restore: () => { - // 세션이 정본 — 편집 초안을 다시 읽음 · 노선 초안 동안은 노선 0 칸 · 계획고가 그대로면 다시 셈 예약 없음(349-0q) - const [was, draft] = [JSON.stringify(store.edits()), store === preview]; - const id = draft ? pagePipeline.DRAFT_ROUTE_ID : routeId; - store = createProfileEditStore(id, draft ? store.edits() : savedEdits, () => rebuild()); - if (draft) preview = store; - void (JSON.stringify(store.edits()) !== was ? rebuild() : draw()); - }, + // 세션이 정본 — 가게가 편집 초안을 다시 읽음(노선 초안 동안은 노선 0 칸) · 계획고가 그대로면 다시 셈 예약 없음(349-0q) + restore: () => void (store.resync() ? rebuild() : draw()), restoreSaved: () => store.restoreSaved(), canRestoreSaved: () => store.canRestoreSaved(), refresh: () => renderBalance(), @@ -291,7 +282,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { return false; } // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). - if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false; + // 최소고 강제 = 페이지 설정 한 칸(초안 ?? 저장 노선 · 472-5) — 보기마다 사본 없음 + const enforce = enforceMinCoverOf(projectId); + if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false; store.replace(next); history.record(); return true; @@ -437,7 +430,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); } - return { + const profile = { root, /** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */ setView(next: DesignView) { @@ -456,8 +449,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. if (routeChanged || (store !== preview && !store.dirty())) { routeId = nextRouteId ?? routeId; - savedEdits = stored?.edits ?? emptyEdits(); - store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); } storedBase = stored ? toAlignmentBase(stored) : null; if (store !== preview) base = listed(); // 노선 초안 그림은 그대로(갈아끼운 상세) @@ -506,11 +498,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { draw(); revealPick(); }, - /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ - setEnforceMinCover(enforce: boolean) { - enforceMinCover = enforce; - writeState("profile-enforce-min-cover", enforce, projectId); - }, /** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */ setMinCoverPipes(pipes: ReadonlyArray) { takePipes(pipes); @@ -598,7 +585,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { rebuild(), ); if (draft) return void ((base = nextBase), rebuild()); - if (store === preview) store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + if (store === preview) store = createProfileEditStore(routeId, undefined, () => rebuild()); [preview, base] = [null, listed()]; alignment = base && buildAlignment(base, store.edits()); // 그리기만 · 다시 셈 예약 없음(349-0l) drawSoon(); @@ -651,8 +638,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { drag.dispose(); crossPreview.dispose(); unbindViewport(); + unbindSettings(); }, }; + // 페이지 설정 = 가게 한 칸 — ↶ ↷ · 다른 보기가 고친 기울기 한도 · 시작 측점을 다시 얹음(N6 · 472-5) + const unbindSettings = useDesignStore(projectId, null).subscribe("pageSettings", () => + applyPageSettings(profile, projectId), + ); + return profile; } /** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */ diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 210e9ae2d..9d5b2c616 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -12,7 +12,9 @@ * * `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`) * 값은 안 받는다(387-1). B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c). - * 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. + * 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. `alignmentSaved` = 그 노선 서버 + * 저장분(메모리만 · 같은 프로젝트 규칙 · 472-5) — 지금 편집 = 초안 ?? 저장분(`_Profile_Edit`). + * `pageSettings` = 등록부 `pagesettings`(페이지 설정 초안 · 448) — 폼 · 기울기 한도 · 최소고 강제가 읽음. * * 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) · * `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 · @@ -24,6 +26,7 @@ import type { StateName } from "../b_page_state"; import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend"; import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures"; import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { PageSettingsBody } from "../../B05_Profile/B05_Profile_Api_Fetch"; import type { AlignmentEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import type { ProfileAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; @@ -48,6 +51,10 @@ export interface DesignStoreValues { pipes: DetailPipeInput[] | null; /** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */ alignmentDraft: AlignmentEdits | null; + /** 그 노선 서버 저장분 계획고 편집 — 초안이 없을 때의 지금 값 · [초기화] 기준점(메모리만). */ + alignmentSaved: AlignmentEdits | null; + /** 미저장 페이지 설정(설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점). */ + pageSettings: PageSettingsBody | null; /** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */ sectionDetail: SectionDetailResponse | null; /** 고른 측점 · 구조물. */ @@ -120,6 +127,16 @@ function slotOf( write: (value) => writeState("alignment-draft", value, project, routeId), }; } + if (key === "alignmentSaved") { + if (routeId === null) return { id: null, read: () => null, write: () => undefined }; + const id = `alignment-saved:${draftProjectId(projectId)}:${routeId}`; + return { + id, + read: () => (savedAlignments.get(id) as V | undefined) ?? null, + write: (value) => + void (value === null ? savedAlignments.delete(id) : savedAlignments.set(id, value)), + }; + } if (key in MEMORY_SLOTS) return memorySlot(key as MemoryKey, projectId); // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. const name = ( @@ -130,8 +147,9 @@ function slotOf( sectionDetail: "section-detail", selection: "selection", routeDraft: "route-draft", + pageSettings: "pagesettings", } as const - )[key as Exclude]; + )[key as Exclude]; const routeScoped = name === "section-detail"; if (!projectId || (routeScoped && routeId === null)) { return { id: null, read: () => null, write: () => undefined }; @@ -144,6 +162,9 @@ function slotOf( }; } +/** 노선마다 서버 저장분 계획고 편집(`alignmentSaved`) — 종단도가 상세를 받을 때 채움. */ +const savedAlignments = new Map(); + /** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */ const MEMORY_SLOTS = { stations: "design-stations", diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 68def71e5..41b74610c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -68,6 +68,7 @@ import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; import { pipeIdAt } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; +import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { loadSavedPlusStations, @@ -346,10 +347,16 @@ export async function mountProfileView( function markStale(): void { if (state.restoring || !state.routeReady) return; profilePanel.setGradeLimit(panel.gradeLimitPct()); - // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). - profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); + // 횡단배수 최소고 강제 체크는 페이지 설정 한 칸(가게)을 편집 가드가 바로 읽음(2026-09-01 · 472-5). sections.redrawStationLines(); } + // 페이지 설정 = 가게 한 칸 — ↶ ↷ 가 되쓴 값을 폼에 다시 얹음(N6 · 472-5) + host.onDispose( + designStore.subscribe("pageSettings", () => { + const values = pageSettingsPanelValues(activeProjectId); + if (values) panel.restore(values); + }), + ); viewer.markers.onChange(markStale); viewer.markers.onStationSelectionChange((stationId) => { diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index ea875446e..581953e42 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -11,7 +11,8 @@ * excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m · * has_foundation }] } · * cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) · - * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } } } + * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } · + * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). @@ -19,6 +20,7 @@ * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; +import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; import type { BermSpec } from "@util/common_util_cross_berm"; import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design"; @@ -64,6 +66,10 @@ interface CalcInputs extends FollowInputs { has_foundation?: boolean; }>; }; + berm?: { + structures: Parameters[0]; + chainages: number[]; + }; cross_design?: { standard: CrossDesignOptions["standard"]; stations?: Array<{ @@ -181,6 +187,11 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ), })), }), + // 소단 구간(C4) — 구조물 정본 → 구간 · 측점마다 제원(겹치면 먼저 놓은 것). + "cross.berm_spans": () => { + const spans = bermSpansFromStructures(inputs.berm?.structures ?? []); + return { spans, at: (inputs.berm?.chainages ?? []).map((c) => bermSpecAt(spans, c)) }; + }, // 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐). "cross.design": () => { const batch = inputs.cross_design; diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 9263a1d9d..7fc9c6126 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -364,11 +364,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_design.ts#computeCrossDesign`, - runner: `py:${B06}B06_Section_Engine_Design.py#compute_cross_design`, - until: "472-4", + runner: "node:cross.design", pair: `${B06}B06_Section_Engine_Design.py#compute_cross_design`, mirror: ["test_b06_cross_design_mirror", "test_178_2_berm_fill_mirror"], - note: "파이썬 짝은 B07 · 추천 · Route_Save · B05 확정도 부름 — 짝 남음", + note: "[저장] · [확정] 다시 셈 = Node(472-4 ②) · 파이썬 짝 남음(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) · 알려진 차 = 설계선 점 반올림 동점 1e-4 m", }, { id: "cross.areas", @@ -379,11 +378,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_design_areas.ts#splitCutAreas`, - runner: `py:${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, - until: "472-4", + runner: "node:cross.design", pair: `${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, mirror: ["test_b06_cross_design_mirror"], - note: "X3 — 표준 측점 = 파이썬 · 구조물 측점 = Node(structure.layouts)", + note: "설계선과 한 셈(cross.design) · X3 한 갈래 — 표준 측점 · 구조물 측점(structure.layouts) 모두 Node", }, { id: "cross.enforce", @@ -397,6 +395,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`, until: "472-4", pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`, + note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design", mirror: ["test_349_0n_cross_enforce_mirror"], }, { @@ -407,12 +406,11 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ outputs: ["소단 구간"], site: "browser_now", stage: null, - ts: `${B06}B06_Section_UI_Page_Persist.ts#bermSpansFromStructures`, - runner: `py:${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, - until: "472-4", + ts: `${U}common_util_berm_spans.ts#bermSpansFromStructures`, + runner: "node:cross.berm_spans", pair: `${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, mirror: ["test_272_berm_source"], - note: "TS 가 화면 파일 안 — common_util 로(472-4 ②)", + note: "[저장] 다시 셈 = Node(472-4 ②) · 파이썬 짝은 Node 못 부를 때만", }, { id: "cross.berm_slope", @@ -423,9 +421,9 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_berm.ts#cutProfilePoints`, - runner: `py:${U}common_util_cross_berm.py#cut_profile_points`, - until: "472-4", + runner: "node:cross.design", pair: `${U}common_util_cross_berm.py#cut_profile_points`, + note: "설계선 셈 안(cross.design) · 파이썬 짝 남음(cross.design 과 같은 까닭)", mirror: ["test_178_2_berm_fill_mirror"], }, { @@ -440,8 +438,8 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, until: "472-4", pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, - mirror: [], - note: "거울 시험 없음 — 옮기기 전 먼저", + mirror: ["test_472_4_underdrain_mirror"], + note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안", }, { id: "cross.fit", diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts index 561554ba7..92ff97392 100644 --- a/B05_Profile/B05_Profile_Api_PageSettings.ts +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -6,9 +6,12 @@ * 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). * 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀. * 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본. + * 값 주인 = 설계 가게 `pageSettings` 칸(472-5) — 폼 · 기울기 한도 · 최소고 강제가 가게를 듣고 읽음 · + * ↶ ↷ 가 세션을 되쓰면 가게가 듣는 쪽을 다시 부름(N6) · 최소고 강제 사본 칸 없음(N7). * ========================================================================== */ -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; +import { onPageHistory } from "./B05_Profile_UI_Profile_History"; import { readRouteLatestCache, savePageSettings, @@ -42,10 +45,25 @@ export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial - readState("pagesettings", projectId); + useDesignStore(projectId, null).get("pageSettings"); -export const writePageSettings = (projectId: string, value: PageSettingsDraft): void => - writeState("pagesettings", value, projectId); +export const writePageSettings = (projectId: string, value: PageSettingsDraft | null): void => + useDesignStore(projectId, null).set("pageSettings", value); + +/** 횡단배수 최소고를 편집에서 강제할지 — 초안이 있으면 초안 · 없으면 저장 노선 설정(기본 해제). */ +export function enforceMinCoverOf(projectId: string): boolean { + const draft = readPageSettings(projectId); + if (draft) return draft.enforce_pipe_clearance === true; + return readRouteLatestCache(projectId)?.route_params?.enforce_pipe_clearance === true; +} + +// 되돌리기 · 다시하기는 세션을 바로 되씀 — 가게로 다시 알려 폼 · 기울기 한도 · 시작 측점이 따라옴(N6) +onPageHistory({ + restore: () => { + const projectId = draftProjectId(); + writePageSettings(projectId, readPageSettings(projectId)); + }, +}); /** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움 * (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */ @@ -63,5 +81,5 @@ export async function flushPageSettings(projectId: string): Promise { }; writeRouteLatestCache(projectId, latest); } - clearState("pagesettings", projectId); + writePageSettings(projectId, null); } diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index 348e18adb..35bec4580 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -89,10 +89,27 @@ export function panelValuesOf(next: RouteLatestResponse): Partial | null { + const latest = readRouteLatestCache(projectId); + const draft = readPageSettings(projectId); + if (!latest) return draft ? panelValuesOfDraft(draft) : null; + const all = panelValuesOf(latest); + return { + designSpeed: all.designSpeed, + terrainType: all.terrainType, + maxGradePct: all.maxGradePct ?? null, + minVerticalRadius: all.minVerticalRadius ?? null, + minTangentLength: all.minTangentLength ?? null, + enforcePipeClearance: all.enforcePipeClearance === true, + stationDisplay: all.stationDisplay ?? { station: 0, cumulative: 0 }, + }; +} + +/** 페이지 설정 한 칸(463 · 요구 271-5) — 종단도 기울기 상한 · 시작 측점 = 세션 초안(448) ?? 저장 노선 * 설정(빈 칸 = 좌측 패널 기본). 어느 보기로 서도 종단도를 그린 뒤 얹음 · B05 패널은 세션에 쓰고 고친 즉시 얹음. */ export function applyPageSettings( - profile: Pick, + profile: Pick, projectId: string, latest: RouteLatestResponse | null = readRouteLatestCache(projectId), ): void { @@ -106,6 +123,5 @@ export function applyPageSettings( profile.setGradeLimit( gradeLimitOf(speed, values.terrainType ?? "special", values.maxGradePct ?? null), ); - profile.setEnforceMinCover(values.enforcePipeClearance === true); profile.setStationDisplay(values.stationDisplay ?? { station: 0, cumulative: 0 }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index c943b4944..ef4cc08be 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -73,7 +73,7 @@ export interface ProfileEditStore { /** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */ savedRadii(): Readonly>; replace(next: AlignmentEdits): void; - /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. + /** 서버 저장이 끝났음을 표시한다 — 초안을 저장분으로 옮기고 초안을 지운다. * 이때의 값이 [초기화]의 기준점이 된다. */ markSaved(): void; /** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */ @@ -87,6 +87,8 @@ export interface ProfileEditStore { dirty(): boolean; /** 자동 선형 대비 편집 델타가 하나라도 있는가. */ edited(): boolean; + /** ↶ ↷ 가 세션을 되쓴 뒤 — 이 창구가 마지막으로 본 편집과 다른가(다시 셈 여부). */ + resync(): boolean; } /** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */ @@ -111,124 +113,84 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). */ export function clearAlignmentDrafts(): void { - shared.clear(); // 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다. clearState("alignment-draft", draftProjectId()); } /** - * 노선마다 **한 벌**인 편집 상태 — 두 보기의 종단도(`design_profile`)가 같은 벌을 - * 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 — - * 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다. + * 노선마다 편집 값 주인 = 설계 가게 두 칸(472-5) — 두 보기의 종단도(`design_profile`)가 같은 칸을 읽는다. + * `alignmentDraft` = 미저장 편집(세션 · 되돌리기가 사진 찍음) · 있으면 미저장 + * `alignmentSaved` = 서버 저장분(메모리 · 종단도가 상세를 받을 때 · [저장]이 옮김) — [초기화]의 기준점 · + * 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준(2026-08-23 · 2026-09-03 사용자 지시) + * 지금 편집 = 초안 ?? 저장분 — 모듈 사본이 없어 ↶ ↷ 가 세션을 되써도 두 보기가 같은 값을 본다. */ -interface EditState { - current: AlignmentEdits; - unsaved: boolean; - /** - * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). - * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 - * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. - */ - savedBaseline: AlignmentEdits; - /** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */ - saved: AlignmentEdits; -} - -const shared = new Map(); - -/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */ -function freshState(routeId: number | null, saved: AlignmentEdits): EditState { - const draft = readAlignmentDraft(routeId); - return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved }; -} +const savedOf = (routeId: number | null): AlignmentEdits => + (routeId === null ? null : draftOf(routeId).get("alignmentSaved")) ?? emptyEdits(); /** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */ export function pendingAlignment(routeId: number): AlignmentEdits | null { - const state = shared.get(routeId); - if (state) return state.unsaved ? state.current : null; return readAlignmentDraft(routeId); } /** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */ export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void { draftOf(routeId).set("alignmentDraft", edits); - const state = shared.get(routeId); - if (state) Object.assign(state, { current: edits, unsaved: true }); } -/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ +/** 계획고가 정본에 나갔다 — 그 노선 초안을 저장분으로 옮기고 초안을 지운다. */ export function markAlignmentSaved(routeId: number): void { - const state = shared.get(routeId); - if (state) { - state.unsaved = false; - state.savedBaseline = state.current; - } - draftOf(routeId).set("alignmentDraft", null); + const store = draftOf(routeId); + const draft = store.get("alignmentDraft"); + if (draft) store.set("alignmentSaved", draft); + store.set("alignmentDraft", null); } /** - * 편집 델타 보관소. + * 편집 창구. * - * 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이 - * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 - * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. - * - * 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤 - * 다시 읽는 길). 같은 노선이면 어느 화면이 불러도 같은 한 벌이다. + * `saved` = 서버에 저장된 편집분(종단도가 상세를 받을 때) — 주면 그 노선 저장분 칸을 갈아 끼움 · 안 주면 + * 칸 그대로. 세션 초안이 남아 있으면(= 확정 없이 새로고침한 경우) 초안이 이기고 미저장이다. routeId가 + * 바뀌면 칸도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. * * routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에 * 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다. */ export function createProfileEditStore( routeId: number | null, - saved: AlignmentEdits, + saved: AlignmentEdits | undefined, onChange: () => void, ): ProfileEditStore { - const fresh = freshState(routeId, saved); - // 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다. - const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh); - if (routeId !== null) shared.set(routeId, state); - return handleOf(routeId, state, onChange); -} - -function handleOf( - routeId: number | null, - state: EditState, - onChange: () => void, -): ProfileEditStore { - const draft = routeId === null ? null : draftOf(routeId); - - function dropDraft(): void { - draft?.set("alignmentDraft", null); - } + const cells = routeId === null ? localCells(saved) : storeCells(routeId, saved); + const editsNow = (): AlignmentEdits => cells.draft() ?? cells.saved(); + /** 이 창구가 마지막으로 본 편집 — ↶ ↷ 가 세션을 되쓴 뒤 바뀌었는지(`resync`). */ + let seen = JSON.stringify(editsNow()); function commit(next: AlignmentEdits): void { - state.current = next; - state.unsaved = true; - draft?.set("alignmentDraft", state.current); + seen = JSON.stringify(next); + cells.setDraft(next); onChange(); } return { - edits: () => state.current, - savedRadii: () => state.saved.curve_radii ?? {}, + edits: editsNow, + savedRadii: () => cells.saved().curve_radii ?? {}, replace: commit, markSaved() { - state.unsaved = false; - state.savedBaseline = state.current; - dropDraft(); + const draft = cells.draft(); + if (draft) cells.setSaved(draft); + cells.setDraft(null); }, restoreSaved() { - state.current = state.savedBaseline; - state.unsaved = false; - dropDraft(); + seen = JSON.stringify(cells.saved()); + cells.setDraft(null); onChange(); }, - canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline), + canRestoreSaved: () => JSON.stringify(editsNow()) !== JSON.stringify(cells.saved()), resetStation(chainageM, moveTo) { + const current = editsNow(); const key = chainageKey(chainageM); - const stationOffsets = { ...state.current.station_offsets }; - const curveRadii = { ...state.current.curve_radii }; + const stationOffsets = { ...current.station_offsets }; + const curveRadii = { ...current.curve_radii }; if (moveTo !== undefined) { const to = chainageKey(moveTo); if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key]; @@ -240,7 +202,7 @@ function handleOf( delete stationOffsets[key]; // 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면 // 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23). - const savedRadius = state.saved.curve_radii?.[key]; + const savedRadius = cells.saved().curve_radii?.[key]; if (savedRadius === undefined) delete curveRadii[key]; else curveRadii[key] = savedRadius; commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); @@ -249,10 +211,47 @@ function handleOf( // 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경 // (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 — // 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단). - commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } }); + commit({ ...emptyEdits(), curve_radii: { ...(cells.saved().curve_radii ?? {}) } }); }, - dirty: () => state.unsaved, - edited: () => hasEdits(state.current), + dirty: () => cells.draft() !== null, + edited: () => hasEdits(editsNow()), + resync() { + const now = JSON.stringify(editsNow()); + const changed = now !== seen; + seen = now; + return changed; + }, + }; +} + +interface EditCells { + draft(): AlignmentEdits | null; + setDraft(value: AlignmentEdits | null): void; + saved(): AlignmentEdits; + setSaved(value: AlignmentEdits): void; +} + +/** 노선 칸 = 설계 가게 두 칸. */ +function storeCells(routeId: number, saved: AlignmentEdits | undefined): EditCells { + const store = draftOf(routeId); + if (saved) store.set("alignmentSaved", saved); + return { + draft: () => store.get("alignmentDraft"), + setDraft: (value) => store.set("alignmentDraft", value), + saved: () => savedOf(routeId), + setSaved: (value) => store.set("alignmentSaved", value), + }; +} + +/** 노선이 아직 없음 — 초안을 세션에 두지 않음(창구 안 메모리만 · 2026-09-04). */ +function localCells(saved: AlignmentEdits | undefined): EditCells { + let draft: AlignmentEdits | null = null; + let base = saved ?? emptyEdits(); + return { + draft: () => draft, + setDraft: (value) => void (draft = value), + saved: () => base, + setSaved: (value) => void (base = value), }; } diff --git a/B06_Section/B06_Section_Engine_Design_Node.py b/B06_Section/B06_Section_Engine_Design_Node.py new file mode 100644 index 000000000..2f67bc54a --- /dev/null +++ b/B06_Section/B06_Section_Engine_Design_Node.py @@ -0,0 +1,104 @@ +"""횡단 설계선 · 면적 · 소단 구간을 서버 Node 입구로(PLAN 472-4 ② · 등록부 `cross.design` · +`cross.berm_spans`) — 브라우저와 같은 TS(`common_util_cross_design` · `common_util_berm_spans`). + +파이썬은 입력 조립만 — 측점마다 `compute_cross_design` 키를 모아 Node 한 번에 보냄. +Node 를 못 부르면 파이썬 짝으로(거울 시험 `test_b06_cross_design_mirror` · `test_272_berm_source` · +알려진 차 = 설계선 점 반올림 동점 1e-4 m — 파이썬 round 짝수 쪽 · TS 위쪽). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from B06_Section.B06_Section_Engine_Design import _resolve_group, compute_cross_design +from common_util.common_util_calc_runner import run_calc + +logger = logging.getLogger(__name__) + +# 한 측점 입력 = (지반 표본, 계획고, `compute_cross_design` 키 — standard 뺌) +DesignJob = tuple[list[dict[str, Any]], float | None, dict[str, Any]] + + +def full_standard(standard: dict[str, Any] | None) -> dict[str, Any]: + """config 기본값 위에 저장분을 덮은 표준 횡단 한 벌 — TS 는 config 를 모름(입력으로 받음).""" + out: dict[str, Any] = {} + for key in ("soil", "rock", "paved"): + g = _resolve_group(key, standard) + out[key] = { + "road_width_m": g["road_width_m"], + "shoulder_left_m": g["shoulder_left_m"], + "shoulder_right_m": g["shoulder_right_m"], + "ditch": { + "top_width_m": g["ditch_top_width_m"], + "bottom_width_m": g["ditch_bottom_width_m"], + "depth_m": g["ditch_depth_m"], + }, + "ditch_l_type": {"width_m": g["l_ditch_width_m"], "depth_m": g["l_ditch_depth_m"]}, + "cross_slope_pct": {"min": g["cross_slope_pct"]}, + "fill_slope_ratio": g["fill_slope_ratio"], + "cut_slope_ratio": g["cut_slope_ratio"], + "pavement_thickness_m": g["pavement_thickness_m"], + } + return out + + +def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] | None: + samples, elevation, options = job + try: + return compute_cross_design(samples, elevation, standard=standard, **options) + except (ValueError, KeyError): + return None + + +def compute_cross_designs( + jobs: list[DesignJob], standard: dict[str, Any] | None +) -> list[dict[str, Any] | None]: + """측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리).""" + if not jobs: + return [] + stations = [ + { + "samples": samples, + "design_elevation_m": elevation, + "options": { + key: (value._asdict() if key == "berm" and value is not None else value) + for key, value in options.items() + }, + } + for samples, elevation, options in jobs + ] + results = run_calc( + ["cross.design"], + {"cross_design": {"standard": full_standard(standard), "stations": stations}}, + ) + if results is None: + logger.warning("Node 횡단 설계 실패 — 파이썬 짝으로 셈(%d 측점)", len(jobs)) + return [_python(job, standard) for job in jobs] + return [None if "error" in row else row for row in results["cross.design"]] + + +def berm_specs_at( + project_root: Path, chainages: list[float] +) -> list[dict[str, float] | None] | None: + """측점마다 소단 제원(구조물 정본 구간) · None = 정본을 못 읽음(부르는 쪽이 저장분 그대로).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + + try: + structures = [ + item.model_dump(include={"type_id", "start_m", "end_m", "chainage_m", "options"}) + for item in load_structures(str(project_root))[1] + ] + except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬 + logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)") + return None + results = run_calc( + ["cross.berm_spans"], {"berm": {"structures": structures, "chainages": chainages}} + ) + if results is not None: + return results["cross.berm_spans"]["at"] + logger.warning("Node 소단 구간 실패 — 파이썬 짝으로 셈") + spans = load_berm_spans(project_root) + return None if spans is None else [berm_span_at(spans, c) for c in chainages] diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 333bf609c..4b69f70ce 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -118,6 +118,7 @@ def extend_unclosed_sections( sampler: Any, ) -> int: """저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수.""" + from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, @@ -132,23 +133,21 @@ def extend_unclosed_sections( return 0 drops = ford_surface_drops(project_root) cross_dir = project_root / "B06_Section" / "cross_sections" - changed = 0 + done: list[tuple[dict[str, Any], dict[str, Any], float, list[dict[str, Any]], dict, tuple]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict) or not design.get("slope_unclosed"): continue chainage = float(section.get("chainage_m", 0.0)) - def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict: - return compute_cross_design( - samples, - design_elevation_from_longitudinal(longitudinal, chainage), + try: + elevation = design_elevation_from_longitudinal(longitudinal, chainage) + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -162,6 +161,12 @@ def extend_unclosed_sections( **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) + except (ValueError, KeyError): + continue + + # 넓힘 찾기 = 파이썬 짝(되풀이 셈) · 넓힌 뒤 정본 설계 = Node 한 번(472-4 ② · 아래) + def compute(samples: list[dict[str, Any]], z=elevation, opts=options) -> dict: + return compute_cross_design(samples, z, standard=standard, **opts) try: result = extend_unclosed( @@ -174,7 +179,14 @@ def extend_unclosed_sections( samples, info = result if not info["left_m"] and not info["right_m"]: continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음 - recomputed = compute(samples) + done.append((section, design, chainage, samples, info, (samples, elevation, options))) + results = compute_cross_designs([job for *_rest, job in done], standard) + changed = 0 + for (section, design, chainage, samples, info, _job), recomputed in zip( + done, results, strict=True + ): + if recomputed is None: + continue for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 29231cc3e..e6b84416b 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,11 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, stored_tilt, ) +from B06_Section.B06_Section_Engine_Design_Node import ( + DesignJob, + berm_specs_at, + compute_cross_designs, +) from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges # 포장 · 세월교 구간(나눈 파일) — 이 모듈 이름으로 다시 내보냄(부르는 곳 · 시험 갈아 끼움 그대로). @@ -130,6 +135,26 @@ def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> di return standard if isinstance(standard, dict) else None +def apply_designs( + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]], standard: dict[str, Any] | None +) -> int: + """모은 측점을 Node 한 번(TS 짝 · 472-4 ②)으로 다시 세워 자리에서 갈아 끼움 · 바꾼 수. + + `design` = 승계 원천(사용자 조작값 · 상태) · 못 세운 측점은 저장분 그대로(옛 ValueError 자리). + """ + results = compute_cross_designs([job for _section, _design, job in jobs], standard) + changed = 0 + for (section, design, _job), recomputed in zip(jobs, results, strict=True): + if recomputed is None: + continue + for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): + if design.get(key) is not None: + recomputed[key] = design[key] + section["design"] = recomputed + changed += 1 + return changed + + def enforce_pavement_ranges( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -146,7 +171,7 @@ def enforce_pavement_ranges( ranges = pavement_ranges(project_root) tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) - changed = 0 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict): @@ -157,15 +182,12 @@ def enforce_pavement_ranges( if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt: continue try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=wanted, - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -179,14 +201,11 @@ def enforce_pavement_ranges( **stored_shoulder_extend(design), # 노견 확장(139-6) **tilt, ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def enforce_design_lines( @@ -203,29 +222,23 @@ def enforce_design_lines( 확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계. 소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1). """ - from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans - ford_drops = ford_surface_drops(project_root) tilts = tilt_ranges(project_root) - spans = load_berm_spans(project_root) - changed = 0 - for section in cross_sections: - design = section.get("design") - if not isinstance(design, dict): - continue - chainage = float(section.get("chainage_m", 0.0)) - if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 - design = {**design, "berm": berm_span_at(spans, chainage)} + targets = [section for section in cross_sections if isinstance(section.get("design"), dict)] + chainages = [float(section.get("chainage_m", 0.0)) for section in targets] + berms = berm_specs_at(project_root, chainages) # Node 소단 구간(C4) · None = 못 읽음 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] + for index, (section, chainage) in enumerate(zip(targets, chainages, strict=True)): + design = section["design"] + if berms is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 + design = {**design, "berm": berms[index]} try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -239,14 +252,11 @@ def enforce_design_lines( **stored_shoulder_extend(design), **tilt_args(chainage, tilts), ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def enforce_ford_surface_drops( @@ -262,7 +272,7 @@ def enforce_ford_surface_drops( 월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다. """ drops = ford_surface_drops(project_root) - changed = 0 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict): @@ -273,15 +283,12 @@ def enforce_ford_surface_drops( if abs(wanted - stored) < 1e-6: continue try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -295,14 +302,11 @@ def enforce_ford_surface_drops( **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3d6e0b82b..2c3607452 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -25,12 +25,6 @@ import { writeState, } from "../A00_Common/b_page_state"; import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options"; -import { - BERM_DEFAULT_DEPTH_M, - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, -} from "@util/common_util_cross_berm"; import { crossDesignChoices, readBermDepths, @@ -43,7 +37,15 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; 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"; +// 소단 구간(C4) — common_util 로 옮김 · 부르는 곳 그대로(다시 내보냄). +import type { BermSpan } from "@util/common_util_berm_spans"; +export { + bermSpansFromStructures, + bermSpecAt, + isBermType, + type BermSessionSpec, + type BermSpan, +} from "@util/common_util_berm_spans"; import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys"; /** @@ -107,27 +109,6 @@ function persistKeyed(name: KeyedState, storageKey: string, values: Map catalogRow(typeId)?.shape === "berm"; - -/** - * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 - * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). - * - * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 - * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 - * 그 사본만 본다. - */ -export function bermSpansFromStructures( - structures: ReadonlyArray<{ - type_id: string; - start_m?: number | null; - end_m?: number | null; - chainage_m?: number | null; - options?: Record; - }>, -): BermSpan[] { - const spans: BermSpan[] = []; - for (const item of structures) { - if (!isBermType(item.type_id)) continue; - const anchor = item.chainage_m ?? item.start_m ?? null; - const start = item.start_m ?? anchor; - const end = item.end_m ?? anchor; - if (start === null || end === null) continue; - const options = item.options ?? {}; - const number = (key: string, fallback: number): number => { - const parsed = Number(options[key]); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - }; - const width = number("width_m", BERM_DEFAULT_WIDTH_M); - const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); - if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) - // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 - const rawDepth = Number(options.depth_m); - const depth = - options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) - ? BERM_DEFAULT_DEPTH_M - : Math.max(rawDepth, 0); - spans.push({ - start_m: Math.min(start, end), - end_m: Math.max(start, end), - width_m: width, - interval_m: interval, - slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), - depth_m: depth, - }); - } - return spans; -} - -/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ -export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { - const found = spans.find( - (span) => - chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && - chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, - ); - return found - ? { - width_m: found.width_m, - interval_m: found.interval_m, - slope_deg: found.slope_deg, - depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, - } - : null; -} - /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md index c008e4dd5..9d4f5e231 100644 --- a/B06_Section/CLAUDE.md +++ b/B06_Section/CLAUDE.md @@ -12,6 +12,7 @@ ① 짝(ts ↔ py · 거울 시험): - `Engine_Design.py` ↔ `common_util_cross_design.ts` · `Engine_Areas.py` ↔ `common_util_cross_design_areas.ts` · `test_b06_cross_design_mirror.py` +- [저장] · [확정] 다시 셈(`Router_Design` enforce · 넓힘 뒤 설계 · 소단 구간)은 Node(`Engine_Design_Node.py` → design-calc `cross.design` · `cross.berm_spans`) · 파이썬 짝은 B07 · 추천 · Route_Save · Node 못 부를 때 - `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts` · `logic/UI_Cross_Culvert_Const.ts` · `test_b06_culvert_sets_mirror.py` · `test_b06_pipe_wall_height_mirror.py` - `Engine_Structures_Wall.py` ↔ `common_util_structure_walls.ts` · `test_b06_structure_walls_mirror.py` - 지형 표본 = `common_util_surface_sampler` 하나(`Engine_SampleExtend` 도) diff --git a/common_util/common_util_berm_spans.ts b/common_util/common_util_berm_spans.ts new file mode 100644 index 000000000..58ad71e3f --- /dev/null +++ b/common_util/common_util_berm_spans.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * common_util_berm_spans.ts + * 소단 구간 — 구조물 목록(표 형상 berm)에서 구간 · 측점마다 제원(PLAN 472-4 C4 · 화면 파일 + * `B06_Section_UI_Page_Persist.ts` 에서 옮김 · 그쪽이 다시 내보냄). + * 브라우저(시설 줄 · 다시 셈)와 서버 Node 입구(`design_calc_node` · 등록부 `cross.berm_spans`)가 같은 셈. + * 파이썬 짝 = `B06_Section_Engine_Structures_Wall.py` `load_berm_spans` · `berm_span_at` + * (Node 를 못 부를 때만 · 거울 시험 `test_272_berm_source.py`). + * ========================================================================== */ + +import { + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "./common_util_cross_berm"; +import { catalogRow } from "./common_util_structure_catalog"; + +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; + /** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */ + depth_m: number; +} + +/** + * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. + * + * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). + * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 + * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. + */ +export interface BermSpan extends BermSessionSpec { + start_m: number; + end_m: number; +} + +/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */ +export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (!isBermType(item.type_id)) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) + // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 + const rawDepth = Number(options.depth_m); + const depth = + options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) + ? BERM_DEFAULT_DEPTH_M + : Math.max(rawDepth, 0); + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: depth, + }); + } + return spans; +} + +/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ +export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { + const found = spans.find( + (span) => + chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && + chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, + ); + return found + ? { + width_m: found.width_m, + interval_m: found.interval_m, + slope_deg: found.slope_deg, + depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, + } + : null; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index f15a44e30..7686ac7ff 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -27,6 +27,8 @@ NODE_IDS = frozenset( "profile.seat", "facility.plus_follow", "facility.follow", + "cross.design", + "cross.berm_spans", } ) diff --git a/docs/raw/요구_1017/사용자요구_1017.md b/docs/raw/요구_1017/사용자요구_1017.md index e14539f63..8789374a8 100644 --- a/docs/raw/요구_1017/사용자요구_1017.md +++ b/docs/raw/요구_1017/사용자요구_1017.md @@ -258,3 +258,7 @@ 1. b05/b06의 조작과 데이터 흐름 프론트에서 계산하는지 백엔드에서 계산하는지를 브레인 참고문서를 하나 만들어서 코더들이 b05/b06을 수정할때 참고하게하자. 2. 재정리를 당연히 해야함. 단계적으로 계획서를 세워서 진행하고 데이터 저장의 관점을 보면 보안과 사용자 편의성을 위해 충돌이남. 그래서 로직들은 프론트와 백엔드에서 동작하게 하는 로직을 동시에 구성하고 최종적으로 하나의관리코드에서 선택적으로 할수 있게 반영하는건 어때? 물런 코드가 하나면 유지보수에 좋을테니 더 좋고 어때? + +## 요구 274 (10-19 · 사용자 · 472-7 물음 답) + +1. 측구는 변경이 되었으니 사용자가 이전에 선택한 값른 의미가 없어짐. diff --git a/docs/wiki/concepts/design_page/calculations.md b/docs/wiki/concepts/design_page/calculations.md index 2fbf731b3..4055c0dc2 100644 --- a/docs/wiki/concepts/design_page/calculations.md +++ b/docs/wiki/concepts/design_page/calculations.md @@ -8,7 +8,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md # 계산 목록 — TS · 파이썬 짝 · Node 묶음 · 실행 자리 -- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent`. +- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · dc = design-calc(등록부 입구 `A00_Common/design/logic/design_calc_node.ts` · 파이썬 `common_util_calc_runner.run_calc`) · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent|design-calc`. - 실행 자리 — 짝 = 브라우저 즉시 TS + [저장] 정본 파이썬 · ② = 같은 TS 를 브라우저 · Node 둘 다 · 브라우저 = 입력 도구 · 표시만 · 서버 = 무거운 GIS · 추천. - 줄 더함 · 바꿈 = 새 계산 · 짝 · 묶음이 바뀐 커밋에서. @@ -34,11 +34,11 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md | P6 | 옮긴 시설 꼭짓점 세움 | `logic/B05_Profile_Grade_Seat.ts:32` · `common_util_structure_station.ts:356` | `B05_Profile_Router_Confirm.py:220 · 306` | — | 짝 · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | | P7 | 직선화 · 자유지정 · 쉬프트 · 시종점 | `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/design_profile_free.ts` · `logic/B05_Profile_UI_Profile_Pivot.ts` · `logic/B05_Profile_EndOffsets.ts` | — | — | 브라우저(편집값만 저장 → P2) | | P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` · 경고 글 `logic/B05_Profile_UI_Profile_Alignment.ts` buildAlignment | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` · 경고 글 `B05_Profile_Engine_Grade_Alignment.py` build_alignment | — | 짝 · [저장] · [확정] = `B06_Section_Server_Calc_Prebuild.py` refresh_alignment 가 경고만 다시 씀(기하 그대로) · `test_442_2_grade_limit_mirror` · `test_472_save_warnings` | -| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195` | ex | 짝 · `test_b06_cross_design_mirror` | -| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | sc · ex | 짝 · 정본 = 표준 측점 파이썬 · 구조물 측점 Node | -| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design.py` · `B06_Section_Engine_Tilt.py` | — | 짝 · `test_349_0n_cross_enforce_mirror` | -| C4 | 소단 구간(구조물 → 구간) | `B06_Section_UI_Page_Persist.ts:157`(시설 줄이 부름) | `B06_Section_Engine_Structures_Wall.py:124` | — | 짝 · `test_272_berm_source` | -| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | sc · co · ex | 짝 · `test_178_2_berm_fill_mirror` | +| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195`(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) | dc · ex | ② · [저장] · [확정] 다시 셈 = Node(`B06_Section_Engine_Design_Node.py` 측점 묶음 · 파이썬은 입력 조립) · `test_b06_cross_design_mirror` · `test_472_1_golden` 설계선 점 1e-4 | +| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | dc · sc · ex | ② · C1 과 한 셈 · 정본 = 표준 · 구조물 측점 모두 Node | +| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design_Ranges.py` · `B06_Section_Engine_Tilt.py` | dc(다시 세운 설계) | 구간 판정 짝 · `test_349_0n_cross_enforce_mirror` | +| C4 | 소단 구간(구조물 → 구간) | `common_util_berm_spans.ts`(시설 줄 · 다시 셈이 부름) | `B06_Section_Engine_Structures_Wall.py:124`(Node 못 부를 때만) | dc | ② · `test_272_berm_source` · `test_472_3_calc_runner` | +| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | dc · sc · co · ex | C1 과 한 셈 · `test_178_2_berm_fill_mirror` | | C6 | 맹암거 얹기 · 기울임 | `common_util_structure_underdrain.ts` | `B06_Section_Engine_Tilt.py` | — | 짝 · 거울 시험 없음 | | C7 | 반폭 맞춤 · 성토 5 m 경고 | `logic/B06_Section_UI_Cross_Fit.ts` · `logic/B06_Section_UI_Cross_FillSlope_Warn.ts` | — | ex(Fit) | 브라우저 | | F1 | 시설 따라감(노선 바뀜) | `common_util_route_follow.ts:291` · `logic/B05_Profile_Pipeline_Follow.ts` · `logic/B05_Profile_Structure_Follow.ts` | `common_util_route_follow.py:120` | — | 짝 · `test_410_follow_xy` · `test_344_2_route_pairs` | @@ -64,7 +64,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md |---|---|---|---|---| | X1 | 단면 모양 기본값 | `"left_cut"`(`B06_Section_Cross_Refresh.ts:438`) | `default_section_modes`(`B06_Section_Router_Design.py:402`) | 사용자 협의 · 손대지 않음 | | X2 | 지반 표본 출처 · 입력 선 | 격자 `.bin` · `/route/plan` planned 선 | npz `sample_xy` · 저장 경로 폴리라인 | 노선 초안 없는 차례 = 정본 표본 유지로 막음 | -| X3 | 단면적 두 갈래 | 전부 TS | 표준 측점 파이썬 · 구조물 측점 Node | 1e-4 ㎡ 차 | +| X3 | 단면적 두 갈래 | 전부 TS | 모두 Node(472-4 ②) | 풀림 · 남은 차 = 설계선 점 반올림 동점 1e-4 m(파이썬 round 짝수 쪽 · TS 위쪽) | | X4 | 평면 발자국 | TS PlanFootprint | B07 파이썬 PlanSlope | 거울 시험 | | X5 | 경고 기준표 | `B05_Profile_UI_Panel_Fields.ts` 안 표 | `FOREST_ROAD_PROFILE_CRITERIA` | 거울 시험 · 자료 두 벌 | | X6 | 노선 바뀜 넘기는 칸 | `B06_Section_Station_Keys.ts:46` 근처 | `B05_Profile_Route_Save.py:275-279` CARRY_INPUT_KEYS · ROUTE_CARRY_KEYS | 손으로 맞춘 목록 둘 · 거울 없음 | diff --git a/docs/wiki/concepts/design_page/index.md b/docs/wiki/concepts/design_page/index.md index dc113a292..29cdc4c89 100644 --- a/docs/wiki/concepts/design_page/index.md +++ b/docs/wiki/concepts/design_page/index.md @@ -30,7 +30,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ ``` 사용자 조작(끌기 · 단추 · 칸) ▼ - ① 화면 메모리 — 편집기 · 종단 EditState · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄 + ① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄 ② 상세 가게 — Section_Store cache(페이지 수명) · 정본 사본 canon(세션) ③ 설계 가게 design_store — stations · profile · footprint 는 메모리 우선(멈춘 뒤 1 s 세션) ④ 세션 sessionStorage — aislo:{통}:{이름}:{프로젝트}[:{노선}] · draft · result · pref(localStorage) @@ -47,7 +47,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 층 | 위치(파일:줄) | 역할 · 비우는 때 | |---|---|---| -| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Profile_Edit.ts:137` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 | +| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 | | ② 상세 가게 | `B06_Section_Section_Store.ts:68` cache · `:69` pending · `:117` loadSectionDetail · `:431` peekSectionDetail | 상세 횡단(살아 있는 객체) · 떠남 `forgetSectionMemory`(`:424`) · 정본 바뀜 `invalidateSectionDetail`(`:403`) | | ② 정본 사본 | `B06_Section_Section_Canon.ts:36` keepCanon · `:59` sessionDetail · `:72` savedDesigns · `:84` forgetCanon | 서버 정본 설계 · 자리 · 표본 · invalidate 때 비움 | | ③ 설계 가게 | `design_store.ts:226` useDesignStore · `:151` 메모리 칸 · `:160` 1 s | 읽고 쓰는 창구 · 쓰면 듣는 쪽 부름 | diff --git a/docs/wiki/concepts/design_page/operations_b05.md b/docs/wiki/concepts/design_page/operations_b05.md index 73132b23d..a5b2c7764 100644 --- a/docs/wiki/concepts/design_page/operations_b05.md +++ b/docs/wiki/concepts/design_page/operations_b05.md @@ -35,10 +35,10 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) | |---|---|---|---|---|---|---| -| 계획고 ▲▼ | 종단 메모리 `EditState.current` → 세션 `alignment-draft` | 종단선 · 묶음 profile → cross(전 측점 횡단 · 면적) → 누가토량 · 알약 | 유토 배분(400 ms 뒤 · Node · `_derived/haul`) | `PUT /route/profile-alignment`(편집 델타) · 파이썬 종단 다시 셈 · 횡단 차례 Node 면적 · 누가토량 | 8 | `design_profile.ts:311-344` · `B05_Profile_UI_Profile_Edit.ts:124-137 · 389-400` · `logic/B05_Profile_UI_Profile_Alignment.ts:566-603` · `B05_Profile_Router.py:416-500` → `B05_Profile_Engine_Grade_Profile.py:537-575` | +| 계획고 ▲▼ | 가게 `alignmentDraft` → 세션 `alignment-draft`(지금 값 = 초안 ?? 가게 `alignmentSaved` · 모듈 사본 없음) | 종단선 · 묶음 profile → cross(전 측점 횡단 · 면적) → 누가토량 · 알약 | 유토 배분(400 ms 뒤 · Node · `_derived/haul`) | `PUT /route/profile-alignment`(편집 델타) · 파이썬 종단 다시 셈 · 횡단 차례 Node 면적 · 누가토량 | 8 | `design_profile.ts:311-344` · `B05_Profile_UI_Profile_Edit.ts:113-258` · `logic/B05_Profile_UI_Profile_Alignment.ts:566-603` · `B05_Profile_Router.py:416-500` → `B05_Profile_Engine_Grade_Profile.py:537-575` | | 직선화 | ▲▼ 와 같음(R = L/Δ 로 적음) | ▲▼ 와 같음 · 실시간 띠 | 같음 | 같음 | 8 | `B05_Profile_UI_Profile_Panel_Tools.ts:133-158` · `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/B05_Profile_Profile_CurveLaw.ts:21` | | 자유지정 | 그리기 자체 되돌리기 → 끝나면 ▲▼ 와 같음 | 같음 | 같음 | 같음 | 8 | `logic/design_profile_free.ts:127-170 · 224-313` | | 쉬프트(직선 구간 올림 · 내림) | ▲▼ 와 같음 | 같음 · 붉은 강조 | 같음 | 같음 | 8 | `B05_Profile_UI_Profile_Tools.ts:111-114 · 216-217` | | 종단 R(L 칸) | `curve_radii` → `alignment-draft` | 같음 | 같음 | 같음 · 서버는 R 로 받음 | 8 | `B05_Profile_UI_Profile_Table.ts:360-413` · `logic/B05_Profile_UI_Profile_Alignment.ts:685-695` | | 시 · 종점 계획고 | `alignment-draft`(끝 측점 ▲▼) | 같음 | 같음 | 같음 | 8 | `B05_Profile_UI_Panel_Settings.ts:113-133` · `logic/B05_Profile_EndOffsets.ts:43` | -| 페이지 설정(설계속도 · 지형 · 기준 · 최소고 강제) | 세션 `pagesettings` + result `profile-enforce-min-cover` | 최대 기울기만 종단에 얹음 · 횡단 안 셈 | — | `PUT /route/page-settings`(설정 차례) · 서버는 경고만 다시(기하 그대로) | 4 | `B05_Profile_UI_Panel.ts:130-142` · `B05_Profile_Api_PageSettings.ts:47-67` · `B05_Profile_UI_Page_Latest.ts:94-111` · `B05_Profile_PageSettings.py:79-107` | +| 페이지 설정(설계속도 · 지형 · 기준 · 최소고 강제) | 가게 `pageSettings` → 세션 `pagesettings`(최소고 강제 = 편집 가드가 이 칸을 바로 읽음) | 최대 기울기만 종단에 얹음 · 횡단 안 셈 · ↶ ↷ 뒤 가게가 폼 · 기울기 한도 · 시작 측점을 다시 부름 | — | `PUT /route/page-settings`(설정 차례) · 서버는 경고만 다시(기하 그대로) | 4 | `B05_Profile_UI_Panel.ts:130-142` · `B05_Profile_Api_PageSettings.ts:47-85` · `B05_Profile_UI_Page_Latest.ts:92-128` · `design_profile.ts:645` · `design_view_profile.ts:355` · `B05_Profile_PageSettings.py:79-107` | diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index ee35c93ad..4b3dae5e2 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -51,7 +51,6 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 | | ↶ ↷ 가 세션을 직접 되씀 — B06 조정 메모리 맵 · B05 상단측 맵이 옛 값일 수 있음 | `design_store.ts:231-235` | 추정 | -| 페이지 설정 ↶ = 세션만 · 폼 · 기울기 한도 안 얹음 · `profile-enforce-min-cover` = result 통 | `b_page_state.ts:226` | 추정 | | 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 | | `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 | | 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 | diff --git a/docs/wiki/concepts/design_page/session_keys.md b/docs/wiki/concepts/design_page/session_keys.md index e003563ef..f8f142074 100644 --- a/docs/wiki/concepts/design_page/session_keys.md +++ b/docs/wiki/concepts/design_page/session_keys.md @@ -40,7 +40,6 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common | `section-detail` | `:232` | 상세 서버 정본 사본(브라우저 셈 안 실음) | `invalidateSectionDetail` | | `routeedit-initial` | `:247` | 추천 노선 | 추천 다시 셈 | | `design-stations` · `design-profile` · `design-footprint` | `:249-251` | 가게 메모리 칸의 세션 사본(멈춘 뒤 1 s) | 가게 칸 지움 | -| `profile-enforce-min-cover` | `:226` | 최소고 강제(사용자 값인데 result 통) | 결과 비우기 | ## pref(localStorage) diff --git a/docs/wiki/concepts/design_page/values.md b/docs/wiki/concepts/design_page/values.md index ef62b6e8b..bd1e19324 100644 --- a/docs/wiki/concepts/design_page/values.md +++ b/docs/wiki/concepts/design_page/values.md @@ -17,7 +17,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 측점 목록 · 열쇠 | DB `cross_sections` 행 · 종단 파일 stations · 서버 `generate_sections` | 묶음 stations · 가게 `stations`(메모리) → 세션 `design-stations` · 상세 cache · 세션 `section-detail` · canon · `design_pipeline_page.ts` savedStations · `B06_Section_Station_Keys.ts:92` 목록 | TS `logic/design_pipeline_stations.ts` · `logic/B05_Profile_Sections_Stations.ts:276` ↔ 파이썬 `B05_Profile_Engine_Sections_Core.py:449` · 열쇠 `common_util_station_key.ts:32` ↔ `.py:32` · `test_344_3_pipeline_stations_mirror` · `test_344_3_station_key_mirror` | 가게 `stations` 알림 → 갈아끼움(구독 둘 · 같은 목록이면 안 함) · 조용한 차례 끝 = 상세 측점(`design_pipeline_page_publish.ts` shown) · [저장] 뒤 비움 | | 측점 자리 · 축 | 서버 측점 틀(저장 경로 폴리라인) | 브라우저 측점 틀(`/route/plan` planned 선) · 상세 cache frame | TS `logic/B05_Profile_UI_RouteEdit_Frame.ts:61` ↔ 파이썬 `B05_Profile_Engine_Sections_Core.py:449-519`(같은 식 · 입력 선 다름 · mm 차) · `test_b05_routeedit_frame_mirror` | 노선 초안 없는 차례 = 정본 자리 · 축 유지(`logic/B06_Section_Station_Swap.ts:238-247`) | | 지반 표본 | 서버 `sample_xy`(npz · 저장 노선 선) → `cross_*.json` samples | 지형 격자(`B05_Profile_Api_TerrainGrid.ts:29-31` · IndexedDB) · 묶음 ground · 상세 cache samples | TS `common_util_surface_sampler.ts:65` ↔ 파이썬 `common_util_surface_sampler.py:210` · `test_344_1_surface_sampler_mirror` | 갈아끼움 — 노선 초안 없음 = 같은 자리(0.01 m · 축 1e-3) 앞 횡단 → 그 자리 옛 횡단 → 저장 정본 표본 · 노선 초안 = 새 · 옮긴 측점만 브라우저 표본 | -| 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | `EditState` current · saved(`B05_Profile_UI_Profile_Edit.ts:137`) · 세션 `alignment-draft`(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 | +| 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | ★ 가게 `alignmentDraft`(세션 `alignment-draft`) · 가게 `alignmentSaved`(서버 저장분 · 메모리 · `B05_Profile_UI_Profile_Edit.ts:127 · 235`)(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 | | 종단 R | 같은 `edits.curve_radii` | 위와 같음 · 칸은 L · 저장은 R | TS `logic/B05_Profile_Profile_CurveLaw.ts` ↔ `B05_Profile_Engine_Grade_CurveLaw.py` · `test_432_1_curve_law` | 위와 같음 · R = L/Δ 로 적어 Δ 바뀌면 L 흐름 | | 계획선 꼭짓점(시설 측점) | 종단 파일 base PVI | 묶음 planline · 종단도 | TS `common_util_structure_station.ts:356` followMovedNodes ↔ 파이썬 `B05_Profile_Router_Confirm.py:306` `_follow_moved_stations` · `:220` `_seat_born_nodes` · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | 짝 있는 옮긴 측점 = 꼭짓점 따라감 · 짝 없는 새 측점 = 안 세움 | | 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | 세션 칸(`crossdesign` · 조정 맵 등) · B06 조정 메모리 맵(`B06_Section_UI_Page_Link_Session.ts:47`) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 | @@ -28,7 +28,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 관 | `pipe_points.json` | 배수판 둘(`B05_Profile_UI_Drainage_Panel.ts:123 · 142`) · `B06_Section_UI_Page_Structures_Panel.ts:176` · 시설 줄 `:101` · `design_view_profile.ts:116` · savedPipes · `design_pipe_ids.ts:24` · 세션 `pipes` · `pipe-ids` · `culvertopt` · `kindreset` · `pipe-option-queue` · cache culvert · 유역 pkl | 관 길이 TS `design_structure_pipe_calc.ts` · `B06_Section_Structure_Layouts.ts:95`(Node 한 벌) · `test_b06_pipe_length` | 시설 줄 알림 · 배수 다시 나눔 응답 · [저장] 뒤 다시 받음 | | 추가 측점 | `plus_stations.json` | `B05_Profile_Api_PlusStations.ts:14 · 74 · 76` · 세션 `plus-stations` | 따라감 TS `followPlusDraft` ↔ 파이썬 `B05_Profile_Plus_Stations.py:82` · `test_459_route_seat_mirror` | 노선 초안 follow 뒤 · [저장] | | 소단 구간 · 깊이 | 구간 = `structures.json` · 깊이 = DB 패치 | 세션 `berm`(시설 줄만 씀) · `bermdepth` · cache `design.berm` · 초안 노선 `bermAt`(`design_shared.ts:248`) | TS `B06_Section_UI_Page_Persist.ts:157` bermSpansFromStructures ↔ 파이썬 `B06_Section_Engine_Structures_Wall.py:124` load_berm_spans · `test_272_berm_source` | 구조물 바뀜 → 시설 줄 `syncBerm`(같으면 안 씀) → cross 한 번 · 사본 없으면 저장분 | -| 페이지 설정 | DB 단계 2 설정 · 종단 정책 | 세션 `pagesettings` · result `profile-enforce-min-cover` · 종단도 기울기 한도 · 폼 | 정책 파이썬 기준표 ↔ TS `B05_Profile_UI_Panel_Fields.ts` · `test_442_2_grade_limit_mirror` | 열 때 두 보기가 얹음 · [저장] 설정 차례 | +| 페이지 설정 | DB 단계 2 설정 · 종단 정책 | ★ 가게 `pageSettings`(세션 `pagesettings`) — 폼 · 종단도 기울기 한도 · 시작 측점이 듣고 최소고 강제는 바로 읽음 | 정책 파이썬 기준표 ↔ TS `B05_Profile_UI_Panel_Fields.ts` · `test_442_2_grade_limit_mirror` | 열 때 두 보기가 얹음 · [저장] 설정 차례 | | 표준 횡단 | 종단 행 `standard_cross_section` | 세션 `std-cross` · `std-cross-default` · `section-context` | 서버 설계선 다시 | [전체 반영] · [저장] | | 3D | corridor 파일(Node) | `B05_Profile_UI_Corridor.ts:45` cache | Node `B05_Profile_Corridor_Prebuild.py` | 노선 초안 동안 낡음 표시 · [저장] 뒤 | | 되돌리기 사진첩 | 없음(메모리 · 탭 수명) | 사진 n 장 = 세션 draft 칸 글 | — | 조작 뒤 `record()` · 같은 사진이면 걸음 아님 | diff --git a/resources/tester/helper_271_profile_settings.cjs b/resources/tester/helper_271_profile_settings.cjs index 048e38ee6..53cdfe39b 100644 --- a/resources/tester/helper_271_profile_settings.cjs +++ b/resources/tester/helper_271_profile_settings.cjs @@ -24,6 +24,7 @@ globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStora const REAL = new Set( [ "A00_Common/b_page_state.ts", + "A00_Common/design/design_store.ts", // 페이지 설정 값 주인 = 가게 칸(472-5) "B05_Profile/B05_Profile_Api_Fetch.ts", "B05_Profile/B05_Profile_Api_PageSettings.ts", "B05_Profile/B05_Profile_UI_Page_Latest.ts", @@ -56,18 +57,20 @@ const settings = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettin const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts")); const P = "P463"; -/** 가짜 종단도 — 받은 기준을 적음. */ +/** 가짜 종단도 — 받은 기준을 적음. 최소고 강제는 편집 가드가 페이지 설정 한 칸을 바로 읽음(472-5) — + * 화면은 늘 캐시에 둔 노선을 넘기므로(`writeRouteLatestCache` 뒤) 받은 노선을 캐시에 두고 읽음. */ function apply(latest) { const got = {}; + if (latest) state.writeState("latest", latest, P); latestApi.applyPageSettings( { setGradeLimit: (pct) => (got.grade = pct), - setEnforceMinCover: (on) => (got.enforce = on), setStationDisplay: (next) => (got.display = next), }, P, latest, ); + if (Object.keys(got).length) got.enforce = settings.enforceMinCoverOf(P); return got; } const latestOf = (params) => ({ project_id: P, surface_params: {}, route_params: params }); diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index 18e064ea8..cb53502db 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -78,6 +78,18 @@ async function run() { route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 }, }; const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), { + // 값 주인 = 설계 가게 칸(472-5) — 같은 세션 흉내 위에 + "../A00_Common/design/design_store": { + useDesignStore: (p) => ({ + get: () => (session.has(`pagesettings:${p}`) ? session.get(`pagesettings:${p}`) : null), + set: (_key, value) => + value === null + ? session.delete(`pagesettings:${p}`) + : session.set(`pagesettings:${p}`, value), + }), + draftProjectId: () => "p1", + }, + "./B05_Profile_UI_Profile_History": { onPageHistory: () => () => undefined }, "../A00_Common/b_page_state": { readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null), writeState: (name, value, p) => session.set(`${name}:${p}`, value), diff --git a/resources/tester/helper_472_4_underdrain_entry.ts b/resources/tester/helper_472_4_underdrain_entry.ts new file mode 100644 index 000000000..425117255 --- /dev/null +++ b/resources/tester/helper_472_4_underdrain_entry.ts @@ -0,0 +1,5 @@ +/* 472-4 C6 거울 헬퍼 — 맹암거 얹기 TS 짝(vite ssr) · test_472_4_underdrain_mirror.py 가 JSON 으로 받음. */ +export { + attachUnderdrainSpecs, + underdrainSpecsFrom, +} from "../../common_util/common_util_structure_underdrain"; diff --git a/resources/tester/helper_472_5_value_owner.cjs b/resources/tester/helper_472_5_value_owner.cjs new file mode 100644 index 000000000..3674a6c3c --- /dev/null +++ b/resources/tester/helper_472_5_value_owner.cjs @@ -0,0 +1,119 @@ +/* 472-5 5-C 헬퍼 — 종단 편집 · 페이지 설정 값 주인 = 설계 가게 칸 하나. 진짜 `b_page_state` · `design_store` · + * `_Profile_Edit` · `_Api_PageSettings` 를 Node 로(세션 = 메모리 · 되돌리기 = 세션 칸 되쓰기 + 훅 부름 흉내). + * node helper_472_5_value_owner.cjs (짝 = `test_472_5_value_owner.py`) + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; + +const REAL = new Set( + [ + "A00_Common/b_page_state.ts", + "A00_Common/design/design_store.ts", + "B05_Profile/B05_Profile_UI_Profile_Edit.ts", + "B05_Profile/B05_Profile_Api_PageSettings.ts", + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", + ].map((rel) => path.join(ROOT, rel)), +); +const historyHooks = []; +const STUBS = { + B05_Profile_UI_Profile_History: { onPageHistory: (entry) => historyHooks.push(entry) }, + B05_Profile_Api_Fetch: { + readRouteLatestCache: (p) => state().readState("latest", p), + writeRouteLatestCache: (p, value) => state().writeState("latest", value, p), + savePageSettings: async () => ({}), + }, +}; +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); +const cache = new Map(); +function load(file) { + if (cache.has(file)) return cache.get(file); + const box = { exports: {} }; + cache.set(file, box.exports); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (request) => { + const stub = STUBS[path.basename(request)]; + if (stub) return stub; + if (request === "@ui/ui_template_locale") return { ui_locales: {}, currentLanguageIndex: 0 }; + if (!request.startsWith(".")) return anything(); + const target = path.resolve(path.dirname(file), request); + const full = fs.existsSync(`${target}.ts`) ? `${target}.ts` : target; + return REAL.has(full) ? load(full) : anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const state = () => load(path.join(ROOT, "A00_Common", "b_page_state.ts")); + +const P = "P4725"; +const ROUTE = 7; +const store = load(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +store.pinDraftProject(P); +const edit = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts")); +const settings = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts")); +const out = {}; + +// ① 두 보기 = 창구 둘 · 같은 노선 — ↶ 가 세션 칸을 되쓰면 둘 다 그 값(모듈 사본이 옛 값을 쥐지 않음) +const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } }; +const a = edit.createProfileEditStore(ROUTE, saved, () => undefined); +const b = edit.createProfileEditStore(ROUTE, saved, () => undefined); +const x = { station_offsets: { "20.000": 0.5 }, curve_radii: { "40.000": 30 } }; +const y = { station_offsets: { "20.000": 0.2 }, curve_radii: { "40.000": 30 } }; +a.replace(x); +state().writeState("alignment-draft", y, P, ROUTE); // 되돌리기 = 세션 되쓰기 +out.undo = { + a: a.edits(), + b: b.edits(), + aDirty: a.dirty(), + resync: a.resync?.() ?? "없음", + again: a.resync?.() ?? "없음", +}; +// ② [저장] 뒤 ↶ 로 초안 칸이 비면 = 저장한 값(저장 전 서버 값으로 안 돌아감) +a.replace(x); +a.markSaved(); +state().writeState("alignment-draft", null, P, ROUTE); +out.afterSave = { edits: b.edits(), dirty: b.dirty(), canRestore: b.canRestoreSaved() }; + +// ③ 페이지 설정 — ↶ 훅이 가게로 다시 알림(폼 · 기울기 한도 · 시작 측점이 들음 · N6) +const heard = []; +store.useDesignStore(P, null).subscribe("pageSettings", (value) => heard.push(value)); +const draft = { + design_speed_kph: 30, + terrain_type: "normal", + max_grade_pct: 12, + min_vertical_radius_m: null, + min_tangent_length_m: null, + enforce_pipe_clearance: true, + station_display: { station: 0, cumulative: 0 }, +}; +settings.writePageSettings(P, draft); +state().writeState("pagesettings", null, P); // 되돌리기 = 세션 되쓰기 +historyHooks.forEach((entry) => entry.restore?.("undo")); +out.settingsHeard = [...heard]; + +// ④ 최소고 강제 = 페이지 설정 한 칸(초안 ?? 저장 노선) · 사본 세션 칸 없음(N7) +state().writeState("latest", { project_id: P, route_params: { enforce_pipe_clearance: true } }, P); +out.enforceSaved = settings.enforceMinCoverOf?.(P) ?? "없음"; // 옛 코드 = 창구 없음 +settings.writePageSettings(P, { ...draft, enforce_pipe_clearance: false }); +out.enforceDraft = settings.enforceMinCoverOf?.(P) ?? "없음"; +// 등록부에 없으면 열쇠가 안 섬(null) — 옛 코드는 결과 통 열쇠가 섬 +out.copyKey = state().stateKey("profile-enforce-min-cover", P) ?? "none"; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_138_cross_detail.py b/resources/tester/test_138_cross_detail.py index 2944db779..82c6d8272 100644 --- a/resources/tester/test_138_cross_detail.py +++ b/resources/tester/test_138_cross_detail.py @@ -42,8 +42,11 @@ def test_글은_재계산에서도_남는다(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index 1de2e3061..daf1ae1de 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -202,7 +202,8 @@ def test_좌측_패널_칸_구간_깊이_나름(): """옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴). 좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`).""" - source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + # 소단 구간 셈은 472-4 C4 에서 `common_util_berm_spans.ts` 로 옮김 + source = (ROOT / "common_util" / "common_util_berm_spans.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 569535660..e7b4b8cf8 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -59,8 +59,11 @@ def test_CAD_칸은_재계산에서도_남는다(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 diff --git a/resources/tester/test_472_1_golden.py b/resources/tester/test_472_1_golden.py index aff996468..79ae0e1b1 100644 --- a/resources/tester/test_472_1_golden.py +++ b/resources/tester/test_472_1_golden.py @@ -19,6 +19,7 @@ X3 면적 두 갈래(구조물 = 관 351 하나 · 두 길 다 Node TS 로 셈) from __future__ import annotations +import copy import json from dataclasses import dataclass @@ -131,6 +132,25 @@ def test_두_길_사이_알려진_차만(got, pair) -> None: assert not gone, f"안 나타난 알려진 차 {gone} — KNOWN 에서 지움" +def test_저장_설계선_점_정본과_1e4_안(tmp_path) -> None: + """[저장] 다시 셈 설계선(472-4 ② = Node TS) — 정본과 점 수 · offset 같음 · 고도 1e-4 m 안. + + 알려진 차 = 반올림 동점(파이썬 round 짝수 쪽 · TS 위쪽) 13 점(브레인 10-19 승인). + """ + detail = copy.deepcopy(paths.read("detail.json")) + before = {s["chainage_m"]: s["design"]["design_line"] for s in detail["cross_sections"]} + sections, _ = paths._save_tail(detail, paths.stage_project(tmp_path / "project")) + moved = 0 + for section in sections: + old, new = before[section["chainage_m"]], section["design"]["design_line"] + assert [p["offset_m"] for p in new] == [p["offset_m"] for p in old], section["chainage_m"] + for a, b in zip(old, new, strict=True): + gap = abs(a["elevation_m"] - b["elevation_m"]) + assert gap <= 1e-4 + 1e-9, (section["chainage_m"], a, b) + moved += gap > 0 + assert moved == 13, f"반올림 동점 점 수 {moved} — 알려진 차(13)와 다름" + + def test_화면_참값() -> None: stored = GOLDEN["stored"] assert stored["final_cumulative_m3"] == -17570.7 diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index ad4bdd7d3..5bf35026e 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -43,6 +43,8 @@ NODE_IDS = { "profile.seat", "facility.plus_follow", "facility.follow", + "cross.design", + "cross.berm_spans", } pytestmark = pytest.mark.skipif( @@ -199,7 +201,7 @@ def test_id_마다_실행기_있음(loaded) -> None: if row["site"] == "browser_only": ok = kind == "browser" and _has_name(ref) elif kind == "node": - ok = ref == row["id"] and ref in NODE_IDS + ok = ref in NODE_IDS # 한 셈에서 나오는 값(면적 · 소단 사면)은 그 셈의 id else: ok = kind == "py" and _has_name(ref) ok = ok and ("until" not in row or kind == "py") @@ -208,8 +210,10 @@ def test_id_마다_실행기_있음(loaded) -> None: if not ok: bad.append(row["id"]) assert bad == [] - node_rows = {row["id"] for row in loaded["registry"] if row["runner"].startswith("node:")} - assert node_rows == NODE_IDS + node_runs = { + row["runner"][5:] for row in loaded["registry"] if row["runner"].startswith("node:") + } + assert node_runs == NODE_IDS def test_묶음_STAGE_DEPS_등록부_입력과_같음(loaded) -> None: diff --git a/resources/tester/test_472_3_calc_runner.py b/resources/tester/test_472_3_calc_runner.py index 98f3323ec..3243a35b6 100644 --- a/resources/tester/test_472_3_calc_runner.py +++ b/resources/tester/test_472_3_calc_runner.py @@ -68,3 +68,36 @@ def test_실행기_값_server_calc_와_같음() -> None: def test_Node_실행기_아닌_id_는_ValueError() -> None: with pytest.raises(ValueError, match="stations.list"): runner.run_calc(["masshaul.cumulative", "stations.list"], {}) + + +# ── 472-4 ② — 횡단 설계선 · 소단 구간 Node 길 ────────────────────────────────── + + +def test_소단_구간_Node_와_파이썬_짝_같음(tmp_path, monkeypatch) -> None: + from test_272_berm_source import STRUCTURE_CASES, _project + + from B06_Section import B06_Section_Engine_Design_Node as node_mod + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + + calls = [] + real = node_mod.run_calc + monkeypatch.setattr( + node_mod, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs) + ) + chainages = [0.0, 5.0, 50.0, 65.0, 80.0, 100.0, 150.0, 250.0] + for index, structures in enumerate(STRUCTURE_CASES): + root = _project(tmp_path / f"c{index}", structures) + got = node_mod.berm_specs_at(root, chainages) + spans = load_berm_spans(root) + want = [berm_span_at(spans, c) for c in chainages] + assert got == [pytest.approx(w) if w else w for w in want], index + assert len(calls) == len(STRUCTURE_CASES) # 파이썬 물러섬 아님 + + +def test_횡단_설계_못_세운_측점은_None() -> None: + from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs + + ground = [{"offset_m": o, "elevation_m": 100.0 + 0.3 * o} for o in range(-20, 21)] + options = {"ground_type": "soil", "section_mode": "left_cut"} + got = compute_cross_designs([(ground, 100.0, options), (ground, None, options)], None) + assert got[0]["cut_area_m2"] > 0 and got[1] is None diff --git a/resources/tester/test_472_4_underdrain_mirror.py b/resources/tester/test_472_4_underdrain_mirror.py new file mode 100644 index 000000000..947c16d13 --- /dev/null +++ b/resources/tester/test_472_4_underdrain_mirror.py @@ -0,0 +1,120 @@ +"""PLAN 472-4 ② C6 (요구 273) — 맹암거 얹기 거울: TS `common_util_structure_underdrain` ↔ 파이썬 +`B06_Section_Engine_Tilt.underdrains_from` · `attach_underdrain_specs`. + +못박는 것 + - 구조물 목록 → 제원(높이 · 경사 · 빈 값 · 0 · 음수 · 글 · 맹암거 아닌 종류) 두 쪽 같음. + - 기준 측점(±0.02 m) 얹기 · 얹은 수 같음. + - 알려진 차 하나 — 옛 `underdrain` 이 남은 측점: TS 는 걷음 · 파이썬은 둠 + (서버는 파일에서 새로 읽어 옛 값 없음 · Node 로 옮길 때 한 벌). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_underdrain_mirror.py -q +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _drain(at: float, **options) -> dict: + return { + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": at, + "options": options, + } + + +STRUCTURES = [ + _drain(40.0, culvert_height_m=1.2, culvert_slope_pct=3), + _drain(80.0, culvert_height_m="", culvert_slope_pct="2.5"), + _drain(120.0, culvert_height_m=0, culvert_slope_pct=-1), + _drain(160.0), + {"type_id": "berm", "placement": "interval", "start_m": 0.0, "end_m": 50.0, "options": {}}, +] +SECTIONS = [ + {"chainage_m": 40.01}, + {"chainage_m": 79.99}, + {"chainage_m": 120.0}, + {"chainage_m": 160.03}, # 0.02 밖 + {"chainage_m": 200.0, "underdrain": {"chainage_m": 200.0}}, # 옛 값 — 알려진 차 +] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("u4724") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_4_underdrain_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_4_underdrain_entry.js", bundle) + job = json.dumps({"structures": STRUCTURES, "sections": SECTIONS}) + script = ( + f"const m = await import({json.dumps(bundle.as_uri())});" + f"const job = {job};" + "const specs = m.underdrainSpecsFrom(job.structures);" + "const count = m.attachUnderdrainSpecs(job.sections, specs);" + "console.log(JSON.stringify({specs, count, sections: job.sections}));" + ) + out = subprocess.run( # noqa: S603 + ["node", "--input-type=module", "-e", script], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + return json.loads(out.stdout) + + +def _python() -> tuple[list[dict], int, list[dict]]: + specs = underdrains_from([StructureInstance(**item) for item in STRUCTURES]) + sections = copy.deepcopy(SECTIONS) + return specs, attach_underdrain_specs(sections, specs), sections + + +def test_제원_같음(ts) -> None: + specs, _count, _sections = _python() + assert ts["specs"] == specs + + +def test_얹기_같음_알려진_차_하나(ts) -> None: + _specs, count, sections = _python() + assert ts["count"] == count == 3 + assert ts["sections"][:4] == sections[:4] + # 알려진 차 — 옛 underdrain: TS 걷음 · 파이썬 둠 + assert "underdrain" not in ts["sections"][4] and "underdrain" in sections[4] diff --git a/resources/tester/test_472_5_value_owner.py b/resources/tester/test_472_5_value_owner.py new file mode 100644 index 000000000..4cff1b7ad --- /dev/null +++ b/resources/tester/test_472_5_value_owner.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""472-5 5-C — 종단 편집 · 페이지 설정 값 주인 = 설계 가게 칸 하나. + +옛 흠: 종단 편집은 모듈 사본(EditState)이 지금 값을 쥐어 ↶ 뒤 다른 창구가 옛 값 · [저장] 뒤 ↶ 가 저장 전 +서버 값으로 돌아감 · 페이지 설정 ↶ 는 세션 칸만 돌아오고 폼 · 기울기 한도는 안 들음(N6) · 최소고 강제는 +결과 통 사본 칸(N7). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "resources" / "tester" / "helper_472_5_value_owner.cjs" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=120 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +Y = {"station_offsets": {"20.000": 0.2}, "curve_radii": {"40.000": 30}} +X = {"station_offsets": {"20.000": 0.5}, "curve_radii": {"40.000": 30}} + + +def test_되돌리면_두_창구가_같은_값(got): + assert got["undo"]["a"] == Y + assert got["undo"]["b"] == Y + assert got["undo"]["aDirty"] is True + assert got["undo"]["resync"] is True # 다시 셈 + assert got["undo"]["again"] is False # 같은 값 = 다시 셈 예약 없음 + + +def test_저장_뒤_되돌려도_저장한_값(got): + assert got["afterSave"] == {"edits": X, "dirty": False, "canRestore": False} + + +def test_페이지_설정_되돌리면_가게가_알림(got): + assert got["settingsHeard"][-1] is None # ↶ 로 초안이 빔 → 듣는 쪽이 저장 설정으로 다시 얹음 + + +def test_최소고_강제는_페이지_설정_한_칸(got): + assert got["enforceSaved"] is True + assert got["enforceDraft"] is False + assert got["copyKey"] == "none" diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index 926d876a8..d594affeb 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -76,10 +76,9 @@ def test_source_guard_sits_on_the_single_apply_path(): 방향키가 그냥 지나갔다. 판정 로직을 `_Profile_MinCover.blocksMinCover` 로 옮기고 `_Profile_Panel.applyEdits` 한 곳에서만 부른다 — 화면 쪽에는 사본을 두지 않는다. """ - assert ( - "if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;" - in PANEL - ) + # 강제 여부 = 페이지 설정 한 칸을 그 자리에서 읽음(472-5 · 보기마다 사본 없음) + assert "const enforce = enforceMinCoverOf(projectId);" in PANEL + assert "if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;" in PANEL assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐. assert "export function blocksMinCover(" in GUARD assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다. diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 7477c7430..ef6782a39 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -31,6 +31,8 @@ TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" # 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해 # 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다. +# 알려진 차(472-4 ②) — 반올림 동점(…5)에서 파이썬 round 는 짝수 쪽 · TS 는 위쪽이라 설계선 점이 +# 1e-4 m 갈릴 수 있음(이 시험 입력엔 없음 · [저장] 정본은 Node TS 쪽 · test_472_1_golden). _TOLERANCE = 1e-9 diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py index 584c0c472..e01503854 100644 --- a/resources/tester/test_b06_pavement_and_ford.py +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -146,8 +146,11 @@ def test_enforce_turns_paved_on_and_off(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [ {"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬 diff --git a/resources/tester/test_b06_recompute_keeps_user_values.py b/resources/tester/test_b06_recompute_keeps_user_values.py index c3d44b299..15357b95d 100644 --- a/resources/tester/test_b06_recompute_keeps_user_values.py +++ b/resources/tester/test_b06_recompute_keeps_user_values.py @@ -80,7 +80,12 @@ def _install_fakes(monkeypatch) -> dict: } monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)]) - monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 + monkeypatch.setattr( + design_mod, + "compute_cross_designs", + lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs], + ) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) return seen diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 73f8ee72d..e2026d432 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -120,7 +120,12 @@ def _fake_engine(monkeypatch): return {"paved": True, "cut_area_m2": 1.0, "fill_area_m2": 2.0} monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)]) - monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 + monkeypatch.setattr( + design_mod, + "compute_cross_designs", + lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs], + ) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})