From f830088298306b3691689edbbb5873b17a49f20b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 14:54:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(511-B=20B-4=20cross=20=EC=A4=84=20?= =?UTF-8?q?=EC=97=B6=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80=20cross=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8):=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20cross=20=3D=20server(DTM=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9E=88=EC=9D=8C)=EB=A9=B4=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20cross=20=EB=A5=BC=20=EC=84=9C=EB=B2=84=20Node=20=EA=B0=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20TS=20=EB=A1=9C=20=E2=80=94=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=85=B8=EC=84=A0=20=EB=8B=A4=EC=8B=9C=20=EC=85=88?= =?UTF-8?q?=20=3D=20cross.refresh(Cross=5FRefresh=20=EC=85=88=20=EB=AA=AB?= =?UTF-8?q?=EC=9D=84=20logic/B06=5FSection=5FCross=5FRefresh=5FCalc.ts=20?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=EB=88=94=20=C2=B7=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=EB=8A=94=20=ED=99=94=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=8F=90=ED=9A=8C=EB=A1=9C=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=EC=B8=B5=EA=B9=8C=EC=A7=80)=20=C2=B7=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EA=B8=B8(=EB=85=B8=EC=84=A0=20=EC=B4=88?= =?UTF-8?q?=EC=95=88)=20=3D=20stage.cross(design=5Fpipeline=5Fcross=5Fserv?= =?UTF-8?q?er.ts=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80=20676=20=EC=A4=84)?= =?UTF-8?q?=20=C2=B7=20=EB=AA=BB=20=EB=B0=9B=EC=9D=8C=20=3D=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=C2=B7=20DTM=20=EC=95=84=EB=8B=8C?= =?UTF-8?q?=20=EC=A7=80=ED=91=9C=EB=A9=B4=20=3D=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=C2=B7=20/calc=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=94=EB=A9=B4=20=EA=B8=B8=20=3D=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EA=B0=80=20=EA=B2=A9=EC=9E=90=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80(=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8=20=EA=B2=BD=EB=A1=9C=20=EB=B2=84=EB=A6=BC)=20=C2=B7?= =?UTF-8?q?=20=ED=8C=90=20cross=20=EC=A4=84=20=EC=97=B6(=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20both=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD=20=EA=B8=80)=20=C2=B7=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20test=5F511=5Fb4=5Fcross=5Fserver=5Fbrow?= =?UTF-8?q?ser(=EA=B2=80=EC=A6=9D=5Fsub2=20=C2=B7=20both=20=EB=8C=80=20ser?= =?UTF-8?q?ver=200=20=EC=B0=A8=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20535=20ms=20=C2=B7=20=EC=B4=88=EC=95=88=20462=20ms?= =?UTF-8?q?=20=EC=99=95=EB=B3=B5=20=3D=20=EC=9E=A0=EC=9E=A0=20400=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8)=20=C2=B7=20=EA=B8=80=20=EC=9D=BD=EB=8A=94?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=2010=20=3D=20=EC=85=88=20=EB=AA=AB=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=8F=84=20=EC=9D=BD=EA=B2=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../design/design_pipeline_cross_server.ts | 44 +++ A00_Common/design/design_pipeline_page.ts | 21 +- A00_Common/design/logic/design_calc_node.ts | 6 +- .../design/logic/design_calc_node_cross.ts | 33 +- B06_Section/B06_Section_Cross_Refresh.ts | 355 +++++------------- B06_Section/B06_Section_Router_Calc.py | 35 +- .../logic/B06_Section_Cross_Refresh_Calc.ts | 303 +++++++++++++++ common_util/common_util_calc_runner.py | 6 +- common_util/common_util_calc_sites.py | 10 +- resources/master_template/calc_sites.json | 1 + .../tester/test_178_2_berm_fill_mirror.py | 7 +- resources/tester/test_272_choice_undo.py | 9 +- .../tester/test_286_1_ford_pavement_design.py | 5 +- resources/tester/test_288_underdrain_cross.py | 7 +- resources/tester/test_309_2_calc_sites.py | 3 +- .../tester/test_309_2_calc_sites_browser.py | 4 +- resources/tester/test_314_stale_structure.py | 5 +- resources/tester/test_316_defaults.py | 2 +- .../tester/test_511_b1_calc_node_stages.py | 6 +- .../test_511_b4_cross_server_browser.py | 128 +++++++ .../tester/test_b06_cut_slope_user_input.py | 5 +- resources/tester/test_b06_ditch_choice.py | 2 +- .../tester/test_b06_user_touched_keys.py | 3 +- ui_template/ui_template_locale_m1.ts | 4 + 24 files changed, 712 insertions(+), 292 deletions(-) create mode 100644 A00_Common/design/design_pipeline_cross_server.ts create mode 100644 B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts create mode 100644 resources/tester/test_511_b4_cross_server_browser.py diff --git a/A00_Common/design/design_pipeline_cross_server.ts b/A00_Common/design/design_pipeline_cross_server.ts new file mode 100644 index 000000000..97c0b6f73 --- /dev/null +++ b/A00_Common/design/design_pipeline_cross_server.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_cross_server.ts + * 계산 자리 cross = server 의 묶음 cross 단계(PLAN 511-B B-4) — 엔진 길(노선 초안 · 저장 상세 없음)을 서버 Node + * 단계(`stage.cross` · `design_calc_node_stages.ts`)가 같은 단계 함수 · 같은 붙임으로 셈. 저장 노선 다시 셈은 + * `B06_Section_Cross_Refresh` 의 `cross.refresh`. 판 both · DTM 격자 없음(DTM 아닌 지표면 · 받기 전) = null + * (브라우저). 격자 경로 · 구조물 정의 = 서버 창구가 채움(`B06_Section_Router_Calc.py`). + * ========================================================================== */ + +import { readState } from "../b_page_state"; +import { designTerrainGridNow } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; +import { calcOnServerOnly } from "./design_calc_server"; +import { calcOnServerNow } from "./design_calc_sites"; +import type { StagesInputs } from "./logic/design_calc_node_stages"; +import type { CrossOutput, StageContext } from "./logic/design_pipeline_types"; + +/** 단계 입력 중 페이지 저장 맥락(묶음 입력 · 격자 · 관 세션 장부 뺌). */ +export type CrossStageContext = Omit< + NonNullable, + "input" | "grid" | "pipe_ids" +>; + +/** 서버 단계 답 · 끊김 · 실패 = undefined(부르는 쪽이 브라우저) · server 아님 = null. */ +export function crossOnServer( + projectId: string, + ctx: StageContext<"cross">, + context: () => CrossStageContext | null, +): Promise | null { + if (!calcOnServerNow("cross") || !designTerrainGridNow(projectId)) return null; + const saved = context(); + if (!saved) return null; + // ponytail: 저장 상세(1.4 MB)를 부름마다 올림 — 시험용 길 · 느리면 서버가 정본 맥락을 붙임 + return calcOnServerOnly( + "cross", + "C.stage", + { + run: ["stage.cross"], + inputs: { + stages: { ...saved, pipe_ids: readState("pipe-ids", projectId), input: { ...ctx.inputs } }, + }, + pick: (results) => results["stage.cross"], + }, + ctx.signal, + ); +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 7812afd9b..786b9bf63 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -96,6 +96,7 @@ import { } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server"; +import { crossOnServer, type CrossStageContext } from "./design_pipeline_cross_server"; import type { PlanPipe, PlanStructure, @@ -270,6 +271,19 @@ function createPagePipeline( { intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) }, ).map((station) => station.chainageM), }); + /** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */ + const serverContext = (): CrossStageContext | null => + plan && { + plan, + detail: savedSource().detail ?? null, + types, + saved_pipes: savedPipes, + saved_structures: savedStructures, + saved_edits: savedEdits(), + saved_plus: savedPlusRows(projectId), + before_draft: beforeDraft, + interval_m: options.stationIntervalM, + }; /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ const savedOut = new WeakSet(); const pipe = createDesignPipeline({ @@ -311,14 +325,17 @@ function createPagePipeline( ...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null), prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev, }); + // 계산 자리 cross = server — 엔진 길도 서버 Node 단계(같은 TS · 511-B B-4) · 못 받음 = 브라우저 + const remote = () => + crossOnServer(projectId, ctx, serverContext)?.then((o) => o ?? engine()); const { detail = null, routeId } = savedSource(); const saved = !ctx.inputs.route && detail && typeof routeId === "number" ? options.savedCross?.(detail, routeId, stations) : null; - if (!saved) return engine(); + if (!saved) return remote() ?? engine(); return saved.then((out) => { - if (!out) return engine(); + if (!out) return remote() ?? engine(); savedOut.add(out); return out; }); diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 546ab2ac5..8477b25b8 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -50,7 +50,7 @@ import { structureAreaRows, } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; -import { crossRunners, useDefinition, type CrossInputs } from "./design_calc_node_cross"; +import { crossRunners, PAGE_IDS, useDefinition, type CrossInputs } from "./design_calc_node_cross"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages"; import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; @@ -103,6 +103,7 @@ interface CalcInputs extends FollowInputs, CarryInputs, CrossInputs, TrialInputs /** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const memo = new Map(); + useDefinition(inputs.definition); // 구조물 표 = 서버 정의(M02 반영 · 511-B B-4) const sections = inputs.detail?.cross_sections ?? []; const context = inputs.context ?? {}; let massHaul: MassHaulResult | null = null; @@ -170,7 +171,6 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { // 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐). "cross.design": () => { const batch = inputs.cross_design; - if (batch?.facilities) useDefinition(inputs.definition); const ranges = batch?.facilities ? facilityRanges(batch.facilities.pipes, batch.facilities.structures ?? []) : null; @@ -205,7 +205,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const unknown = run.filter((id) => !(id in RUNNERS)); const results: Record = {}; if (!unknown.length) for (const id of run) results[id] = await need(id); - const outside = [...TRIAL_IDS, ...STAGE_IDS]; + const outside = [...TRIAL_IDS, ...STAGE_IDS, ...PAGE_IDS]; const available = Object.keys(RUNNERS).filter((id) => !outside.includes(id)); return { results, unknown, available }; } diff --git a/A00_Common/design/logic/design_calc_node_cross.ts b/A00_Common/design/logic/design_calc_node_cross.ts index aebfa22e1..34d129ad8 100644 --- a/A00_Common/design/logic/design_calc_node_cross.ts +++ b/A00_Common/design/logic/design_calc_node_cross.ts @@ -9,6 +9,10 @@ * cross.enforce [저장] 횡단 한 바퀴(`enforceSavedCross` · 설계선 → 미교차 넓힘 → 포장 → 세월교 하강 · G2 C3) * 입력 cross_enforce = `SaveCrossInput` + grid(DTM 격자 이진 경로 · 넓힘 때만 읽음) * 출력 측점마다 { design · samples?(넓힘) · extended? } | null + * cross.refresh 계산 자리 cross = server 의 저장 노선 횡단 다시 셈(`refreshDesigns` + 구조물 폐회로 면적) + * 입력 cross_refresh { detail · inputs: `RefreshInputs` · grid(서버 창구가 채움) } + * 출력 { design_profiles · sections: [{ design · samples?(넓힘) }] · rows: 구조물 면적 줄 } + * — 등록부 밖(`PAGE_IDS` · 파이썬 `calc_runner.PAGE_IDS`) · 화면 cross 단계 길 * 구조물 표 = 서버 정의(`definition` · M02 반영) — 받으면 갈아 끼움(파이썬 `catalog_row` 와 같은 표). * ========================================================================== */ @@ -19,6 +23,12 @@ import { enforceSavedCross, type SaveCrossInput, } from "../../../B06_Section/logic/B06_Section_Cross_Save"; +import { + refreshDesigns, + type RefreshInputs, +} from "../../../B06_Section/logic/B06_Section_Cross_Refresh_Calc"; +import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; +import { structureAreaRows } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { gridAt } from "./design_calc_node_stages"; export interface CrossInputs { @@ -30,11 +40,15 @@ export interface CrossInputs { types: AttachType[]; }; cross_enforce?: SaveCrossInput & { grid?: string | null }; + cross_refresh?: { detail: SectionDetailResponse; inputs: RefreshInputs; grid?: string | null }; } +/** 등록부 밖 화면 길 id(`available` 에 안 넣음 · 파이썬 `PAGE_IDS` 와 같음). */ +export const PAGE_IDS = ["cross.refresh"]; + let defined = ""; -/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node). */ +/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node · 요청마다 입구가 부름). */ export function useDefinition(definition: CatalogDefinition | null | undefined): void { if (!definition) return; const key = JSON.stringify(definition); @@ -48,7 +62,6 @@ const SHAPE_FIELDS = [...SHAPE_KEYS, "blank"]; export function crossRunners(inputs: CrossInputs): Record unknown> { return { "facility.attach": () => { - useDefinition(inputs.definition); const attach = inputs.attach; if (!attach) throw new Error("형상 얹기 입력(attach) 없음"); const sections = attach.sections.map((section) => ({ ...section })) as Array< @@ -63,10 +76,24 @@ export function crossRunners(inputs: CrossInputs): Record unknown> return { shapes, misses }; }, "cross.enforce": () => { - useDefinition(inputs.definition); const input = inputs.cross_enforce; if (!input) throw new Error("횡단 한 바퀴 입력(cross_enforce) 없음"); return enforceSavedCross(input, () => (input.grid ? gridAt(input.grid) : null)); }, + "cross.refresh": () => { + const job = inputs.cross_refresh; + if (!job) throw new Error("횡단 다시 셈 입력(cross_refresh) 없음"); + const { detail } = job; + const before = detail.cross_sections.map((section) => section.samples); + refreshDesigns(detail, job.inputs, job.grid ? gridAt(job.grid) : null); + return { + design_profiles: detail.longitudinal.design_profiles, + sections: detail.cross_sections.map((section, index) => ({ + design: section.design, + ...(section.samples !== before[index] ? { samples: section.samples } : {}), + })), + rows: structureAreaRows(detail.cross_sections), + }; + }, }; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index d41bb1491..bc5b86ae0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -24,22 +24,10 @@ * 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다. * ========================================================================== */ -import { computeCrossDesign } from "@util/common_util_cross_design"; -import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { - bermSpecAt, - readCutSlopeSession, - readRockBoundarySession, -} from "./B06_Section_UI_Page_Persist"; -import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist"; -import { - BERM_DEFAULT_DEPTH_M, - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, - type BermSpec, -} from "@util/common_util_cross_berm"; +import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSpan } from "./B06_Section_UI_Page_Persist"; import { readState } from "../A00_Common/b_page_state"; import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys"; import { savedDesigns } from "./B06_Section_Section_Canon"; @@ -51,7 +39,6 @@ import { import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel"; import { buildAlignment, - planElevationAt, toAlignmentBase, } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; @@ -66,8 +53,18 @@ import { calcOnServerOnly, } from "../A00_Common/design/design_calc_server"; import { withPipeOptions } from "./B06_Section_Section_Adjusts"; -import { extendUnclosed } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid"; +import { + pavedBySpecs, + refreshDesigns, + rockKey, + storedBermSpec, + type RefreshInputs, + type RefreshMemo, +} from "./logic/B06_Section_Cross_Refresh_Calc"; + +/** 포장 판정 · 저장분 소단 읽기 — 셈 몫(logic)으로 옮김 · 부르는 곳 그대로(다시 내보냄). */ +export { pavedBySpecs, storedBermSpec }; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -88,30 +85,6 @@ export interface CrossRefreshInput { /** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */ export { USER_TOUCHED_KEYS }; -/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */ -const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; - -/** - * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. - * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. - */ -export function pavedBySpecs(section: Pick): boolean { - return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); -} - -function preserveUserFields( - next: NonNullable, - previous: CrossSection["design"], -): NonNullable { - if (!previous) return next; - const merged = { ...next } as Record; - const source = previous as unknown as Record; - for (const key of PRESERVED_KEYS) { - if (source[key] !== undefined) merged[key] = source[key]; - } - return merged as unknown as NonNullable; -} - /** * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리. * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다 @@ -164,6 +137,16 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise [section, JSON.stringify(section.design)])); + // 계산 자리 cross = server(DTM 격자 있음) — 서버 Node 가 같은 셈 · 구조물 층까지(511-B B-4) · 못 받음 = 브라우저 + const remote = + calcOnServerNow("cross") && designTerrainGridNow(input.projectId) !== null + ? await refreshOnServer(input) + : false; + if (remote) { + return sections + .filter((section) => JSON.stringify(section.design) !== before.get(section)) + .map((section) => section.chainage_m); + } refreshLocally(input); // 선형 저장분 · 표준값이 없으면 셈 없음(옛 서버 폴백 걷음 · 511-B B-4) // 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는 // 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점). @@ -191,10 +174,6 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { const raw = readRockBoundarySession(projectId, routeId) ?? {}; const offsets = new Map(); @@ -207,38 +186,6 @@ function readRockOffsets(projectId: string, routeId: number): Map; - const value = (key: string, fallback: number): number => { - const raw = spec[key]; - if (raw === undefined) return fallback; - return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw); - }; - const out = { - width_m: value("width_m", BERM_DEFAULT_WIDTH_M), - interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M), - slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG), - depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0), - }; - return Object.values(out).every(Number.isFinite) ? out : null; -} - -/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ -function bermDepthReader(projectId: string, routeId: number) { - const session = readBermDepths(projectId, routeId); - return (chainageM: number, design: Record, side: Side): number | null => { - const own = session.get(rockKey(chainageM))?.[side]; - const stored = design[`berm_depth_${side}_m`]; - const value = own ?? (typeof stored === "number" ? stored : null); - return value === null ? null : Math.max(value, 0); - }; -} - /** * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. * @@ -263,13 +210,8 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map(); - -function refreshLocally(input: CrossRefreshInput): number[] | null { +/** 세션 · 가게에서 셈 입력을 모음(`RefreshInputs`) — 선형 저장분 · 표준값이 없으면 null(셈 없음). */ +function gatherRefresh(input: CrossRefreshInput): RefreshInputs | null { const { projectId, detail, edits } = input; const stored = readAlignment(detail.longitudinal); // 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1 @@ -282,188 +224,73 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { curve_radii: edits.curve_radii ?? {}, }) : null); - if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. + if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 셈 없음(옛 서버 폴백 걷음 · 511-B B-4) const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; - if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. - const standardKey = JSON.stringify(standard); - // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 - // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 - // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 - // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** - // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. - detail.longitudinal.design_profiles = [ - toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), - ]; - - const rockOffsets = readRockOffsets(projectId, input.routeId); - const rockDefault = readRockBoundaryDefault(projectId); - // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 - // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). - const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); - // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 - // 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm` - // (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467). - const bermCopy = readState("berm", projectId, input.routeId); - const bermSpans = Array.isArray(bermCopy) ? bermCopy : null; - const bermDepthAt = bermDepthReader(projectId, input.routeId); - const bermAt = (chainageM: number, design: Record): BermSpec | null => { - const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm); - return spec - ? { - widthM: spec.width_m, - intervalM: spec.interval_m, - slopeDeg: spec.slope_deg, - depthM: spec.depth_m, - // 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 - depthLeftM: bermDepthAt(chainageM, design, "left"), - depthRightM: bermDepthAt(chainageM, design, "right"), - } - : null; - }; - // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 - // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). - /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ - const cutSlopeAt = (chainageM: number, design: Record): number | null => { - const session = cutSlopeRatios.get(rockKey(chainageM)); - if (session !== undefined) return session > 0 ? session : null; - const stored = design.cut_slope_ratio_user; - return typeof stored === "number" && stored > 0 ? stored : null; - }; - const choices = crossDesignChoices(projectId, input.routeId); - const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); - // 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로 - const shoulderExtends = readShoulderExtends(projectId, input.routeId); - const extendAt = (chainageM: number, design: Record, side: "left" | "right") => { - const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side]; - const stored = design[`shoulder_extend_${side}_m`]; - return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0); - }; - - // 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음) - const grid = designTerrainGridNow(projectId)?.grid ?? null; - // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로 - // (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계 - const canon = new Map( - (savedDesigns(projectId, input.routeId) ?? []).map((row) => [ + if (!standard) return null; // 컨텍스트를 아직 못 받음 — 받은 뒤 다시 셈 + const { routeId } = input; + // 소단 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`(파이썬 `load_berm_spans` 못 읽음 → + // 저장분 · `stored_berm` 짝 · 467) + const bermCopy = readState("berm", projectId, routeId); + return { + alignment, + standard, + rockOffsets: [...readRockOffsets(projectId, routeId)], + rockDefault: readRockBoundaryDefault(projectId), + // 측점별 암 절토 경사 · 소단 · 카드 선택 · 노견 확장 = 세션에만 있는 값 — **계산 전에** 실음(2026-09-07 · 3-9) + cutSlopes: [...readCutSlopeRatios(projectId, routeId)], + bermSpans: Array.isArray(bermCopy) ? bermCopy : null, + bermDepths: [...readBermDepths(projectId, routeId)], + choices: [...crossDesignChoices(projectId, routeId)], + shoulderExtends: [...readShoulderExtends(projectId, routeId)], + // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계(467 덧) + canon: (savedDesigns(projectId, routeId) ?? []).map((row) => [ rockKey(row.chainage_m), row.design, ]), - ); - const updated: number[] = []; - for (const section of detail.cross_sections) { - const previous = section.design; - if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. - const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record< - string, - unknown - >; - // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 - // `recompute_designs_for_alignment` 의 우선순위와 같다. - const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); - const storedOffset = design.rock_boundary_offset_m; - const rockBoundaryOffsetM = - typeof sessionOffset === "number" - ? sessionOffset - : typeof storedOffset === "number" - ? storedOffset - : rockDefault; - const choice = choiceAt(section.chainage_m); - const extendLeft = extendAt(section.chainage_m, design, "left"); - const extendRight = extendAt(section.chainage_m, design, "right"); - const elevation = planElevationAt(alignment, section.chainage_m); - const options: CrossDesignOptions = { - groundType: - choice?.ground_type ?? - (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), - sectionMode: - choice?.section_mode ?? - (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), - ditchSide: - choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null), - // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. - ditchType: - choice?.ditch_type ?? - (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: pavedBySpecs(section), - // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) - tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) - standard, - berm: bermAt(section.chainage_m, design), - rockBoundaryOffsetM, - cutSlopeRatio: cutSlopeAt(section.chainage_m, design), - twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), - // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, - // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. - ditchChoice: choice - ? (choice.ditch_choice ?? null) - : typeof design.ditch_choice === "boolean" - ? design.ditch_choice - : null, - ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, - // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) - surfaceDropM: - Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, - // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). - planRadiusM: section.plan_radius_m ?? null, - curveOuterSide: - section.curve_outer_side === "left" || section.curve_outer_side === "right" - ? section.curve_outer_side - : null, - curveWideningM: section.curve_widening_m ?? null, - shoulderExtendLeftM: extendLeft, - shoulderExtendRightM: extendRight, - }; - // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 - // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. - const samples = section.samples ?? []; - const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`; - const memo = memos.get(section); - if ( - memo?.samples === section.samples && - memo.fingerprint === fingerprint && - memo.design === previous - ) { - continue; - } - let next; - try { - next = computeCrossDesign(samples, elevation, options); - // 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음) - const wide = - grid && next.slope_unclosed - ? extendUnclosed( - samples, - [section.center_x, section.center_y], - section.frame.left_xy, - (more) => computeCrossDesign(more as typeof samples, elevation, options), - grid, - ) - : null; - if (wide && (wide.info.left_m || wide.info.right_m)) { - section.samples = wide.samples as typeof samples; - next = computeCrossDesign(section.samples, elevation, options); - } - } catch { - continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). - } - section.design = preserveUserFields( - next as unknown as NonNullable, - previous, - ); - section.design.shoulder_extend_left_m = extendLeft; - section.design.shoulder_extend_right_m = extendRight; - // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이 - // 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음) - for (const side of SIDES) { - const own = next[`berm_depth_${side}_m`]; - if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; - else delete section.design[`berm_depth_${side}_m`]; - } - if (next.berm) section.design.berm = next.berm; - else delete section.design.berm; - memos.set(section, { samples: section.samples, fingerprint, design: section.design }); - updated.push(section.chainage_m); - } - return updated; + }; +} + +/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */ +const memos: RefreshMemo = new WeakMap(); + +/** 브라우저 안에서 전 측점을 다시 셈(셈 = `logic/B06_Section_Cross_Refresh_Calc`) · 입력 못 모음 = null. */ +function refreshLocally(input: CrossRefreshInput): number[] | null { + const inputs = gatherRefresh(input); + if (!inputs) return null; + const grid = designTerrainGridNow(input.projectId)?.grid ?? null; // 미교차 넓힘 격자(받기 전 = null) + return refreshDesigns(input.detail, inputs, grid, memos); +} + +/** 서버 답(`cross.refresh`) — 계획선 표본 · 측점마다 설계(넓혔으면 표본) · 구조물 폐회로 면적 줄. */ +interface RefreshAnswer { + design_profiles: SectionDetailResponse["longitudinal"]["design_profiles"]; + sections: Array<{ design: CrossSection["design"]; samples?: CrossSection["samples"] }>; + rows: ReturnType; +} + +/** 계산 자리 cross = server — 모은 입력 · 상세를 서버 Node(`cross.refresh` · 같은 셈)에 넘기고 답을 제자리에 + * 얹음 · 입력 못 모음 = 셈 없음(true) · 못 받음 = false(브라우저가 셈). */ +async function refreshOnServer(input: CrossRefreshInput): Promise { + const inputs = gatherRefresh(input); + if (!inputs) return true; + const { detail } = input; + const answer = await calcOnServerOnly("cross", "C.saved", { + run: ["cross.refresh"], + inputs: { + cross_refresh: { + detail: { longitudinal: detail.longitudinal, cross_sections: detail.cross_sections }, + inputs, + }, + }, + pick: (results) => results["cross.refresh"], + }); + if (!answer || answer.sections.length !== detail.cross_sections.length) return false; + detail.longitudinal.design_profiles = answer.design_profiles; + answer.sections.forEach((row, index) => { + const section = detail.cross_sections[index]; + section.design = row.design; + if (row.samples) section.samples = row.samples; + }); + applyStructureAreaRows(detail.cross_sections, answer.rows); + return true; } diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py index b7aa5fd68..a7780ada9 100644 --- a/B06_Section/B06_Section_Router_Calc.py +++ b/B06_Section/B06_Section_Router_Calc.py @@ -7,16 +7,19 @@ 권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. 실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · 정본 저장 길은 이 창구를 안 씀. +단계(`stage.*`) · 화면 길(`cross.refresh`) = 서버가 DTM 격자 경로 · 구조물 정의를 채움 — +화면이 보낸 경로는 버림(파일 경로를 화면에 맡기지 않음 · DTM 아닌 지표면 = 격자 없음 · 511-B B-4). """ import asyncio import json +from pathlib import Path from uuid import UUID from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from common_util.common_util_calc_runner import run_calc_served +from common_util.common_util_calc_runner import PAGE_IDS, STAGE_IDS, run_calc_served router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"]) @@ -48,6 +51,8 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe return _error(400, "run = id 목록") if not isinstance(inputs, dict): return _error(400, "inputs = 객체") + if set(run) & (STAGE_IDS | PAGE_IDS): + await _server_context(project_id, inputs) try: answer = await asyncio.to_thread(run_calc_served, run, inputs) except ValueError as exc: @@ -57,3 +62,31 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe if "error" in answer: return _error(422, str(answer["error"])) return {"status": "success", "project_id": str(project_id), **answer} + + +async def _server_context(project_id: UUID, inputs: dict) -> None: + """단계 · 화면 길 입력에 서버 맥락을 채움 — DTM 격자 이진 경로(없으면 None) · + 구조물 정의(M02 반영).""" + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_Profile.B05_Profile_Structures_Schema import load_definition + from B06_Section.B06_Section_Server_Calc_Prebuild import design_grid_file + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_surface_confirmation import ( + design_terrain, + get_surface_confirmation_params, + ) + from config.config_db import run_with_connection + + stored, params = await asyncio.gather( + run_with_connection(get_project_storage_relative_path, project_id), + run_with_connection(get_surface_confirmation_params, str(project_id)), + ) + root = Path(resolve_stored_project_path(stored)) + grid = await asyncio.to_thread(design_grid_file, root, params) + inputs["definition"] = load_definition() + if isinstance(inputs.get("stages"), dict): + inputs["stages"]["grid"] = ( + {"path": str(grid), "design": design_terrain(params)} if grid else None + ) + if isinstance(inputs.get("cross_refresh"), dict): + inputs["cross_refresh"]["grid"] = str(grid) if grid else None diff --git a/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts new file mode 100644 index 000000000..4a01067a2 --- /dev/null +++ b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts @@ -0,0 +1,303 @@ +/* ============================================================================= + * B06_Section_Cross_Refresh_Calc.ts + * 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은 + * 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node + * (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값. + * 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`. + * 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽). + * ========================================================================== */ + +import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans"; +import { + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + type BermSpec, +} from "@util/common_util_cross_berm"; +import { computeCrossDesign } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; +import type { DtmGrid } from "@util/common_util_surface_sampler"; +import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch"; +import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session"; +import { + planElevationAt, + type ProfileAlignment, +} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys"; + +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; +type SideValues = Partial>; + +/** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */ +export interface RefreshInputs { + /** 화면이 쥔 계획선(측점 한 벌 · 342-1). */ + alignment: ProfileAlignment; + standard: StandardCrossSectionSpec; + /** 암 경계 세션 오프셋 · 기본 오프셋(config). */ + rockOffsets: Array<[number, number]>; + rockDefault: number | null; + /** 측점별 암 절토 경사(0 = 표준값). */ + cutSlopes: Array<[number, number]>; + /** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */ + bermSpans: BermSpan[] | null; + bermDepths: Array<[number, SideValues]>; + /** 카드 선택(지반 · 측구 · 2단 경사 …). */ + choices: Array<[number, CrossDesignChoice]>; + shoulderExtends: Array<[number, SideValues]>; + /** 저장 정본 설계(세션 고름 ?? 정본). */ + canon: Array<[number, unknown]>; +} + +/** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */ +export type RefreshMemo = WeakMap< + CrossSection, + { samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] } +>; + +/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; + +/** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */ +export function rockKey(chainageM: number): number { + return Math.round(chainageM * 100) / 100; +} + +/** + * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. + * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. + */ +export function pavedBySpecs(section: Pick): boolean { + return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); +} + +function preserveUserFields( + next: NonNullable, + previous: CrossSection["design"], +): NonNullable { + if (!previous) return next; + const merged = { ...next } as Record; + const source = previous as unknown as Record; + for (const key of PRESERVED_KEYS) { + if (source[key] !== undefined) merged[key] = source[key]; + } + return merged as unknown as NonNullable; +} + +/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */ +export function storedBermSpec(berm: unknown): BermSessionSpec | null { + if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null; + const spec = berm as Record; + const value = (key: string, fallback: number): number => { + const raw = spec[key]; + if (raw === undefined) return fallback; + return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw); + }; + const out = { + width_m: value("width_m", BERM_DEFAULT_WIDTH_M), + interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M), + slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0), + }; + return Object.values(out).every(Number.isFinite) ? out : null; +} + +/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ +function bermDepthReader(session: Map) { + return (chainageM: number, design: Record, side: Side): number | null => { + const own = session.get(rockKey(chainageM))?.[side]; + const stored = design[`berm_depth_${side}_m`]; + const value = own ?? (typeof stored === "number" ? stored : null); + return value === null ? null : Math.max(value, 0); + }; +} + +/** + * 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리. + * 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌). + */ +export function refreshDesigns( + detail: SectionDetailResponse, + inputs: RefreshInputs, + grid: DtmGrid | null, + memos?: RefreshMemo, +): number[] { + const { alignment, standard } = inputs; + const standardKey = JSON.stringify(standard); + // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 + // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 + // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 + // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** + // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. + detail.longitudinal.design_profiles = [ + toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), + ]; + + const rockOffsets = new Map(inputs.rockOffsets); + const rockDefault = inputs.rockDefault; + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = new Map(inputs.cutSlopes); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm` + // (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467). + const bermSpans = inputs.bermSpans; + const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths)); + const bermAt = (chainageM: number, design: Record): BermSpec | null => { + const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm); + return spec + ? { + widthM: spec.width_m, + intervalM: spec.interval_m, + slopeDeg: spec.slope_deg, + depthM: spec.depth_m, + // 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 + depthLeftM: bermDepthAt(chainageM, design, "left"), + depthRightM: bermDepthAt(chainageM, design, "right"), + } + : null; + }; + // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 + // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ + const cutSlopeAt = (chainageM: number, design: Record): number | null => { + const session = cutSlopeRatios.get(rockKey(chainageM)); + if (session !== undefined) return session > 0 ? session : null; + const stored = design.cut_slope_ratio_user; + return typeof stored === "number" && stored > 0 ? stored : null; + }; + const choices = new Map(inputs.choices); + const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); + // 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로 + const shoulderExtends = new Map(inputs.shoulderExtends); + const extendAt = (chainageM: number, design: Record, side: "left" | "right") => { + const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side]; + const stored = design[`shoulder_extend_${side}_m`]; + return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0); + }; + + // 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음) + // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로 + // (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계 + const canon = new Map(inputs.canon); + const updated: number[] = []; + for (const section of detail.cross_sections) { + const previous = section.design; + if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. + const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record< + string, + unknown + >; + // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 + // `recompute_designs_for_alignment` 의 우선순위와 같다. + const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); + const storedOffset = design.rock_boundary_offset_m; + const rockBoundaryOffsetM = + typeof sessionOffset === "number" + ? sessionOffset + : typeof storedOffset === "number" + ? storedOffset + : rockDefault; + const choice = choiceAt(section.chainage_m); + const extendLeft = extendAt(section.chainage_m, design, "left"); + const extendRight = extendAt(section.chainage_m, design, "right"); + const elevation = planElevationAt(alignment, section.chainage_m); + const options: CrossDesignOptions = { + groundType: + choice?.ground_type ?? + (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), + sectionMode: + choice?.section_mode ?? + (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), + ditchSide: + choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null), + // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. + ditchType: + choice?.ditch_type ?? + (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), + paved: pavedBySpecs(section), + // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) + tilted: Boolean(section.ford_pavement || section.underdrain), + tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) + standard, + berm: bermAt(section.chainage_m, design), + rockBoundaryOffsetM, + cutSlopeRatio: cutSlopeAt(section.chainage_m, design), + twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), + // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, + // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. + ditchChoice: choice + ? (choice.ditch_choice ?? null) + : typeof design.ditch_choice === "boolean" + ? design.ditch_choice + : null, + ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, + // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) + surfaceDropM: + Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, + // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). + planRadiusM: section.plan_radius_m ?? null, + curveOuterSide: + section.curve_outer_side === "left" || section.curve_outer_side === "right" + ? section.curve_outer_side + : null, + curveWideningM: section.curve_widening_m ?? null, + shoulderExtendLeftM: extendLeft, + shoulderExtendRightM: extendRight, + }; + // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 + // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. + const samples = section.samples ?? []; + const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`; + const memo = memos?.get(section); + if ( + memo?.samples === section.samples && + memo.fingerprint === fingerprint && + memo.design === previous + ) { + continue; + } + let next; + try { + next = computeCrossDesign(samples, elevation, options); + // 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음) + const wide = + grid && next.slope_unclosed + ? extendUnclosed( + samples, + [section.center_x, section.center_y], + section.frame.left_xy, + (more) => computeCrossDesign(more as typeof samples, elevation, options), + grid, + ) + : null; + if (wide && (wide.info.left_m || wide.info.right_m)) { + section.samples = wide.samples as typeof samples; + next = computeCrossDesign(section.samples, elevation, options); + } + } catch { + continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). + } + section.design = preserveUserFields( + next as unknown as NonNullable, + previous, + ); + section.design.shoulder_extend_left_m = extendLeft; + section.design.shoulder_extend_right_m = extendRight; + // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이 + // 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음) + for (const side of SIDES) { + const own = next[`berm_depth_${side}_m`]; + if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; + else delete section.design[`berm_depth_${side}_m`]; + } + if (next.berm) section.design.berm = next.berm; + else delete section.design.berm; + memos?.set(section, { samples: section.samples, fingerprint, design: section.design }); + updated.push(section.chainage_m); + } + return updated; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 00b49c437..96657f539 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -6,7 +6,7 @@ 입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`). `run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) — 늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 · -시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) 도 받음. +시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) · 화면 길 `PAGE_IDS`(B-4) 도 받음. """ from __future__ import annotations @@ -65,6 +65,8 @@ TRIAL_IDS = frozenset( STAGE_IDS = frozenset( {"stage.route", "stage.follow", "stage.stations", "stage.profile", "stage.cross"} ) +# 화면 길(`design_calc_node_cross.ts` `PAGE_IDS` · 511-B B-4) — cross = server 저장 노선 다시 셈 +PAGE_IDS = frozenset({"cross.refresh"}) SERVE_TIMEOUT_S = 60 @@ -138,7 +140,7 @@ atexit.register(_SERVED.stop) def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: """창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None.""" - unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS] + unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS | PAGE_IDS] if unknown: raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}") t0 = time.perf_counter() diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index 789e460df..b51b69843 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -2,7 +2,8 @@ 판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`. both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길 -(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구). +(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구 · +cross = 511-B B-4 묶음 cross 단계 · 저장 노선 다시 셈). 판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. 묶음 12 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. """ @@ -53,12 +54,13 @@ GROUPS: tuple[dict[str, Any], ...] = ( "fixed": "both", "why": "pair_B3", }, + # cross = 짝 모음 끝(B-4) — server = 묶음 cross 를 서버 Node 가 같은 TS 로(DTM 아닌 지표면 = 브라우저) { "key": "cross", "nos": ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], - "switchable": False, - "fixed": "both", - "why": "pair_B4", + "switchable": True, + "fixed": None, + "why": "cross", }, # 폼 기본값 도구(B04 · B08 파이썬 · 고를 것 없음) {"key": "form_default", "nos": ["F9"], "switchable": False, "fixed": "both", "why": None}, diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index c0ad2f7a9..b40c9a101 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -5,6 +5,7 @@ "corridor_mesh": "both", "quantity": "both", "carry": "both", + "cross": "both", "fixed_browser": "both" } } diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index daf1ae1de..7064bb727 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -205,5 +205,10 @@ def test_좌측_패널_칸_구간_깊이_나름(): # 소단 구간 셈은 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") + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_272_choice_undo.py b/resources/tester/test_272_choice_undo.py index fda0524ed..55cbf0e60 100644 --- a/resources/tester/test_272_choice_undo.py +++ b/resources/tester/test_272_choice_undo.py @@ -107,5 +107,10 @@ def test_단추가_메모리_설계를_안_덮음(): sync.index("async function handleDesignChange") : sync.index("function recomputeIfRock") ] assert "target.design" not in body - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") - assert "savedDesigns(projectId, input.routeId)" in refresh + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) + assert "savedDesigns(projectId, routeId)" in refresh diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py index 1131348f5..444f9615c 100644 --- a/resources/tester/test_286_1_ford_pavement_design.py +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -137,7 +137,10 @@ def test_노면은_표고_낮은_쪽으로(): def test_저장_뒤_서버가_물넘이_측구를_맞춤(): router = _read("B06_Section", "B06_Section_Router_Design.py") assert "stored_tilt(design) == tilt" in router - refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + refresh = _read( + "B06_Section", "B06_Section_Cross_Refresh.ts" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts") assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py index c048d4bf6..0abf3f19f 100644 --- a/resources/tester/test_288_underdrain_cross.py +++ b/resources/tester/test_288_underdrain_cross.py @@ -175,7 +175,12 @@ def test_횡단도_B07_이_맹암거를_그림(): encoding="utf-8" ) assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07 - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text( + encoding="utf-8" + ) assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index 1e9b60dcd..8e883105b 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -63,6 +63,7 @@ def test_묶음_여덟_줄_등록부_전부(): "corridor_mesh", "quantity", "carry", + "cross", # 511-B B-4 끝에 엶 "fixed_browser", } # 511-A 셋 더 for group in calc_sites.GROUPS: @@ -155,7 +156,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client): def test_잠금_모르는_줄_400_감사_한_줄(client): http, _role, board, log = client url = "/api/dashboard/admin/calc-sites" - for bad in ({"cross": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + for bad in ({"profile": "server"}, {"fixed_server": "both"}, {"nope": "server"}): assert http.put(url, json={"sites": bad}).status_code == 400, bad assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 assert log == [] diff --git a/resources/tester/test_309_2_calc_sites_browser.py b/resources/tester/test_309_2_calc_sites_browser.py index 1b257bc15..c79724ed6 100644 --- a/resources/tester/test_309_2_calc_sites_browser.py +++ b/resources/tester/test_309_2_calc_sites_browser.py @@ -150,11 +150,13 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser): calcs = [row for row in rows if not row["head"]] assert len(heads) == 12 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 폼 기본값 whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs} - for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3, "C1": 4, "F6": 4}.items(): + for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3}.items(): assert whys[no] == f"짝 모으는 중 · B-{n}", no + assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶 assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음 editable = [row["name"] for row in calcs if not row["boxes"][0][1]] opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더 + opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4 opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용 assert [name.split(" · ")[0] for name in editable] == opened assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 41be0ff25..af3627548 100644 --- a/resources/tester/test_314_stale_structure.py +++ b/resources/tester/test_314_stale_structure.py @@ -72,7 +72,10 @@ def test_structure_change_reconciles_and_ford_reattach() -> None: view = _read("A00_Common", "design", "design_view_cross.ts") body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0] assert "void reconcileStaleDesigns();" in body - refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") + refresh = _read( + "B06_Section", "B06_Section_Cross_Refresh.ts" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts") assert "Number(section.ford?.overflow_depth_m) > 0" in refresh # 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 · # 동작 시험 = test_323_4_attach_mirror) diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py index 46fcbd6c3..bcf895892 100644 --- a/resources/tester/test_316_defaults.py +++ b/resources/tester/test_316_defaults.py @@ -77,7 +77,7 @@ def test_ts_defaults_off_and_stored_kept(): ) # 브라우저 재계산 인자 — 세션 선택 → 저장값 → 꺼짐 assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( - "B06_Section/B06_Section_Cross_Refresh.ts" + "B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts" # 511-B B-4 셈 몫 ) # 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만 assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts") diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py index 619e3556c..233e04cf5 100644 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -80,12 +80,15 @@ def test_판_다섯_줄_잠금_기본값_그대로(): "follow": (["P5", "P6", "F1", "F2"], "pair_B5"), "stations": (["R5", "R6", "R7", "F3"], "pair_B2"), "profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"), - "cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"), } for key, (nos, why) in expected.items(): group = by_key[key] assert group["nos"] == nos and group["why"] == why, key assert group["switchable"] is False and group["fixed"] == "both", key + # cross = B-4 끝에 엶(511-B B-4 · 기본값 both 그대로) + cross = by_key["cross"] + assert cross["nos"] == ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] + assert cross["switchable"] is True and cross["fixed"] is None and cross["why"] == "cross" assert by_key["form_default"]["nos"] == ["F9"] and by_key["form_default"]["fixed"] == "both" assert "stages" not in by_key and "structure_pair" not in by_key assert set(calc_sites.SWITCHABLE) == { @@ -93,6 +96,7 @@ def test_판_다섯_줄_잠금_기본값_그대로(): "corridor_mesh", "quantity", "carry", + "cross", "fixed_browser", } committed = subprocess.run( # noqa: S603 — 고정 실행 파일 diff --git a/resources/tester/test_511_b4_cross_server_browser.py b/resources/tester/test_511_b4_cross_server_browser.py new file mode 100644 index 000000000..b2fa3c5d3 --- /dev/null +++ b/resources/tester/test_511_b4_cross_server_browser.py @@ -0,0 +1,128 @@ +"""PLAN 511-B B-4 (요구 320-4) — 계산 자리 cross = server 화면 길(검증_sub2 · 이 폴더 서버 · Playwright). + +못박는 것 + - 판 both 와 server(판 응답만 바꿈 · 판 파일 안 씀)에서 페이지 묶음 cross 출력 · 저장 상세 설계(구조물 폐회로 + 면적 층까지)가 0 차 — 저장 노선(`cross.refresh` · 다시 셈 창구) · 노선 초안 둔 채 다시 열기(`stage.cross`). + - server 일 때만 그 id 를 서버에 묻고 · both 면 /calc 0. + - 왕복 시간(`__aisloCalcServer.calls`)을 남김(PLAN 시간 줄). +저장 안 함 · 세션만(노선 초안은 창과 함께 버림). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_server_browser.py -q +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_511_b1_calc_node_stages import BASE, JS_DRAFT, PROJECT, _diff, screen # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) +OUT = ROOT / "tmp" / "511b4_capture.json" + +# 묶음 cross 출력 · 저장 상세 설계(면적 · 구조물 층) · 서버 부름 기록 +JS_STATE = r"""async () => { + const urls = performance.getEntriesByType('resource').map((e) => e.name); + const mod = async (re) => { + const u = urls.find((n) => re.test(n)); + if (!u) throw new Error('모듈 없음 ' + re); + return import(u); + }; + const page = await mod(/design_pipeline_page\.ts/); + const pid = localStorage.getItem('frd_current_project_id'); + const pipe = page.pagePipeline(pid); + await page.reconcilePagePipeline(pid); + await pipe.settled(); + const data = await mod(/design_page_data\.ts/); + const routeId = data.pageRouteId(pid); + const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId); + const shown = page.pipelineDetail(pid) ?? detail; + const calls = (window.__aisloCalcServer?.calls ?? []).map((c) => ({ + id: c.id, roundMs: c.roundMs, nodeMs: c.nodeMs, wallMs: c.wallMs, bytes: c.bytes, + })); + return JSON.parse(JSON.stringify({ + cross: pipe.output('cross'), + designs: (shown?.cross_sections ?? []).map((s) => ({ + chainage_m: s.chainage_m, design: s.design, samples: (s.samples ?? []).length, + })), + calls, + runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome), + })); +}""" + + +def _open(play, cred: dict, cross: str) -> tuple: + chrome = play.chromium.launch(channel="msedge", headless=True) + context = chrome.new_context(viewport={"width": 1600, "height": 1000}) + login = {k: cred[k] for k in ("email", "password")} + assert context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok + + def board(route): # 판 응답만 바꿈(판 파일 안 씀) + response = route.fetch() + body = response.json() + body["sites"]["cross"] = cross + route.fulfill(response=response, json=body) + + context.route("**/api/dashboard/calc-sites", board) + page = context.new_page() + page.add_init_script( + f"localStorage.setItem('frd_current_project_id', '{PROJECT}');" + " performance.setResourceTimingBufferSize(20000);" + ) + return chrome, page + + +def _shot(page) -> dict: + page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + return page.evaluate(JS_STATE) + + +@pytest.fixture(scope="module") +def shots(): + sync = pytest.importorskip("playwright.sync_api") + cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + out = {} + with sync.sync_playwright() as play: + for cross in ("both", "server"): + chrome, page = _open(play, cred, cross) + try: + out[f"saved_{cross}"] = _shot(page) + page.evaluate(JS_DRAFT) # 노선 초안(가운데 교각점 옮김 · 세션만) + page.wait_for_timeout(3000) + page.reload(wait_until="domcontentloaded") + page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000) + page.wait_for_timeout(9000) + out[f"draft_{cross}"] = page.evaluate(JS_STATE) + finally: + chrome.close() # 세션 초안은 창과 함께 버림 + OUT.write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") + return out + + +@screen +@pytest.mark.parametrize("case", ["saved", "draft"]) +def test_cross_server_화면_값_브라우저와_0차(shots, case): + both, server = shots[f"{case}_both"], shots[f"{case}_server"] + assert both["runs"] == ["done"] and server["runs"] == ["done"], (both["runs"], server["runs"]) + assert both["cross"]["byKey"], "cross 출력 없음" + hit = _diff(server["cross"], both["cross"], "cross") + assert hit is None, hit + hit = _diff(server["designs"], both["designs"], "designs") + assert hit is None, hit + # 부름 — both = /calc 없음 · server = 그 길 id + assert not [c for c in both["calls"] if "cross" in c["id"]] + want = "cross.refresh" if case == "saved" else "stage.cross" + assert any(c["id"] == want for c in server["calls"]), server["calls"] diff --git a/resources/tester/test_b06_cut_slope_user_input.py b/resources/tester/test_b06_cut_slope_user_input.py index 7dd605cc9..bbf90889e 100644 --- a/resources/tester/test_b06_cut_slope_user_input.py +++ b/resources/tester/test_b06_cut_slope_user_input.py @@ -103,7 +103,10 @@ def test_계산_전에_넘어간다(): """ router = _ROUTER_DESIGN.read_text(encoding="utf-8") assert router.count("cut_slope_ratio=") >= 3, "재계산 경로 중 값을 안 넘기는 곳이 있음" - refresh = _REFRESH.read_text(encoding="utf-8") + refresh = _REFRESH.read_text( + encoding="utf-8" + ) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔) + refresh += (_B06 / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(encoding="utf-8") assert "cutSlopeRatio: cutSlopeAt(" in refresh, "브라우저 재계산이 값을 안 넘김" # 계산이 끝난 뒤 베껴 붙이는 꼴이면 안 된다. assert not re.search(r"design\.cut_slope_ratio\s*=", refresh), "계산 뒤에 값만 덮어쓰고 있음" diff --git a/resources/tester/test_b06_ditch_choice.py b/resources/tester/test_b06_ditch_choice.py index fa0edf202..ac62f50b2 100644 --- a/resources/tester/test_b06_ditch_choice.py +++ b/resources/tester/test_b06_ditch_choice.py @@ -108,7 +108,7 @@ def test_선택이_저장_patch_까지_실린다() -> None: for name in ( "B06_Section_Cross_Design_Session.ts", # 캐시 칸 "B06_Section_UI_Page_Design_Sync.ts", # 캐시에 쓰는 자리 - "B06_Section_Cross_Refresh.ts", # 브라우저 재계산이 읽는 자리 + "logic/B06_Section_Cross_Refresh_Calc.ts", # 브라우저 재계산이 읽는 자리(511-B B-4 셈 몫) "B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch ): assert "ditch_choice" in _reads(name), f"{name} 에서 측구 선택이 빠졌다" diff --git a/resources/tester/test_b06_user_touched_keys.py b/resources/tester/test_b06_user_touched_keys.py index bf048edfa..757325d32 100644 --- a/resources/tester/test_b06_user_touched_keys.py +++ b/resources/tester/test_b06_user_touched_keys.py @@ -12,7 +12,8 @@ from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS ROOT = Path(__file__).resolve().parents[2] TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6) -REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts" +# 셈 몫(PRESERVED_KEYS) = logic 으로 나눔(511-B B-4) +REFRESH = ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts" # 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다. _CALLER_ADDED = {"status", "pavement_suggested"} diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index cb45089fc..e30a90e35 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -118,6 +118,10 @@ export const ui_locales_m1 = { B01_CalcSites_Why_pair_B2: ["짝 모으는 중 · B-2", "Merging pairs · B-2"], B01_CalcSites_Why_pair_B3: ["짝 모으는 중 · B-3", "Merging pairs · B-3"], B01_CalcSites_Why_pair_B4: ["짝 모으는 중 · B-4", "Merging pairs · B-4"], + B01_CalcSites_Why_cross: [ + "끄면 횡단을 서버 Node 가 셈(잠잠 뒤 · 왕복) · DTM 아닌 지표면 = 브라우저", + "Off = server Node calcs cross sections (after a pause · round trip) · non-DTM surface = browser", + ], B01_CalcSites_Why_pair_B5: ["짝 모으는 중 · B-5", "Merging pairs · B-5"], B01_CalcSites_Why_form_default: [ "폼 기본값 도구 — 입력 칸 기본값 · 서버 다시 안 셈(B04 · B08 은 파이썬)",