diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 3cd50ce7a..580b2e3b9 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -12,7 +12,8 @@ * 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음. * ========================================================================== */ -import { readState, readStateRaw } from "../b_page_state"; +import { readState } from "../b_page_state"; +import { savedDesigns } from "../../B06_Section/B06_Section_Section_Canon"; import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { effectiveStandardCross, @@ -41,14 +42,6 @@ const SPEC_KEYS = [ /** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */ const SAME_M = 0.01; -/** 세션 저장 상세(노선 초안 동안 안 덮음 · 365-1) — 글이 같으면 앞 해석 그대로. */ -let storedText: { text: string | null; detail: SectionDetailResponse | null } | null = null; -function storedDetail(projectId: string, routeId: number | null): SectionDetailResponse | null { - const text = routeId === null ? null : readStateRaw("section-detail", projectId, routeId); - if (storedText?.text !== text) storedText = { text, detail: text ? JSON.parse(text) : null }; - return storedText.detail; -} - /** 짧은 지문(FNV-1a) — 제원 묶음이 같은지만 견줌. */ function digest(text: string): string { let hash = 0x811c9dc5; @@ -56,6 +49,11 @@ function digest(text: string): string { return `${text.length}:${hash >>> 0}`; } +/** 다시 셈 부탁 수(387-4) — 입력 칸에 없는 세션 고침(노견 · 소단 구간 · 조정창 4축 · 단면 유형)을 고친 화면이 + * 올림 · cross 입력 지문에 들어가 같은 입력이어도 묶음 cross 가 한 번 더 돎(다시 셈 창구 한 곳). */ +let forced = 0; +export const forceCross = (): void => void (forced += 1); + const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; @@ -142,9 +140,9 @@ export function pipelineCrossSource( const specs = new Map(); // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) · // 시설 제원 = 화면 상세(초안 형상을 얹은 것) - const stored = storedDetail(projectId, routeId); + const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7) const storedAt = (chainageM: number) => - stored?.cross_sections.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design; + stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design; const put = (key: string, section: CrossSection, at = section.chainage_m): void => { byKey[key] = carried(section, storedAt(at)); const own: Record = {}; @@ -180,7 +178,7 @@ export function pipelineCrossSource( standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId), - specsKey: digest(JSON.stringify([...specs])), + specsKey: `${digest(JSON.stringify([...specs]))}|${forced}`, }, specs, }; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 8d9063801..c1ae3da63 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -67,10 +67,6 @@ import "../../B06_Section/B06_Section_UI_Style_Cross.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { - applyStructureAreaRows, - structureAreaRows, -} from "../../B06_Section/B06_Section_Structure_Layouts"; import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common"; import { createCrossViewPart, @@ -228,14 +224,7 @@ export async function mountCrossView( await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4) const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; - sectionDetail = fresh; - // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측). - if (sectionDetail) { - applyStructureAreaRows( - sectionDetail.cross_sections, - structureAreaRows(sectionDetail.cross_sections), - ); - } + sectionDetail = fresh; // 구조물 면적은 상세 가게가 읽을 때 얹음(387-4 · 화면은 안 얹음) renderSectionDetail(); void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314) } catch (error) { @@ -292,7 +281,9 @@ export async function mountCrossView( const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다. sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), - refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + // 조정창 값 = 입력 칸 밖 세션 고침 → 묶음 cross 한 번 더 · 면적은 거기서(387-4 · 카드 그리기는 안 고침) + refreshCard: (chainageM) => + void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })), detail: () => sectionDetail, crossHalfWidth, sampledHalfWidth, @@ -366,7 +357,8 @@ export async function mountCrossView( structureSpan: stationControls.structureSpan, soilTiers: pipeExtraCount, overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values), - refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + refreshCard: (chainageM) => + void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })), }; // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). const structureSelection = wireStructureSelection( @@ -631,14 +623,7 @@ export async function mountCrossView( // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) - // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 - // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). - if (sectionDetail) { - applyStructureAreaRows( - sectionDetail.cross_sections, - structureAreaRows(sectionDetail.cross_sections), - ); - } + // 구조물 초안(저장 안 한 벽) · 그 면적은 상세 가게가 얹어 줌(387-4 · 화면은 안 얹음). // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 const summaryData = existing.longitudinal.data as { options?: { diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 46a4d4c73..38b31f5d8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -20,7 +20,7 @@ import { writeCachedBytes, } from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; @@ -207,8 +207,8 @@ export async function refreshCorridor( rebuild = true, ): Promise { if (!routeId) return; - // [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1) - if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []); + // [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1) + if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) .then((build) => viewer.setCorridor(withSections(build, detail))) .catch(() => viewer.setCorridor(null)); diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 65b587ff7..06b7a969c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -2,20 +2,17 @@ * B05_Profile_UI_Profile_Preview.ts * 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계). * - * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해 - * **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과 - * 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). - * - * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. - * 노선 초안 동안은 서버에 안 물음 — 계산 묶음 cross 가 그 차례를 끝낼 때까지만 「다시 계산 중」 · 그 뒤 남은 - * 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f). + * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 다시 셈은 계산 묶음 cross 단계 **한 곳**(`design_shared` + * savedCross · 387-4)이 하고 여기서는 그 차례만 기다림(▲ 한 번 = cross 한 번). 예전에는 그리기가 낡음을 보면 + * 여기서도 따로 다시 셈 → 묶음과 두 번 셈. + * 기다리는 동안 「다시 계산 중」 · 묶음 차례가 cross 를 지났는데 남은 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f). * ========================================================================== */ import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { holdScreenAlignment } from "../B06_Section/B06_Section_Cross_Refresh"; import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { pagePipeline, pipelineCross } from "../A00_Common/design/design_pipeline_page"; import { useDesignStore } from "../A00_Common/design/design_store"; import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline"; @@ -38,31 +35,24 @@ export interface CrossPreviewContext { } export interface CrossPreview { - /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ + /** 묶음에 cross 다시 부탁(같은 입력이면 안 셈). */ schedule: () => void; /** - * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). - * 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 - * 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16). - * `draft` = 노선 초안 동안 — 서버에 안 묻고 묶음 cross 차례만 기다림(349-0f). + * 그리기가 잰 낡음 지문 — 돌려주는 값 = 묶음 cross 차례를 기다리는 중인가. 차례가 지난 뒤에도 남은 낡음은 + * false — 곡선은 그리고 그 측점만 표시(342-1 · 사용자 확정 10-16). `_draft` = 옛 갈래(노선 초안) · 같은 길. */ - scheduleIfStale: (key: string, draft?: boolean) => boolean; - /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ + scheduleIfStale: (key: string, _draft?: boolean) => boolean; + /** 패널을 걷을 때 기다림 타이머를 끈다. */ dispose: () => void; } export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { - let timer = 0; - let seq = 0; - let lastStale = ""; - /** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */ - let busy = false; let warned = ""; const release = holdScreenAlignment((routeId) => routeId === ctx.routeId() ? ctx.alignment() : null, ); - /** 노선 초안 동안 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ + /** 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ let awaiting: number | null = null; let waitTimer = 0; let watched: DesignPipeline | null = null; @@ -91,58 +81,16 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { ); }; - const preview: CrossPreview = { - scheduleIfStale(key, draft = false) { - if (draft) { - if (awaiting === null) warnLeft(key); - return key !== "" && awaiting !== null; - } - if (key && key !== lastStale) preview.schedule(); - else if (!busy) warnLeft(key); - lastStale = key; - return key !== "" && busy; - }, - schedule() { - if (!ctx.detail() || ctx.routeId() === null) return; - busy = true; - window.clearTimeout(timer); - timer = window.setTimeout(() => { - const detail = ctx.detail(); - const routeId = ctx.routeId(); - if (!detail || routeId === null) return void (busy = false); - const current = (seq += 1); - // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 - // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). - const isCurrent = (): boolean => - current === seq && ctx.detail() === detail && ctx.routeId() === routeId; - void refreshCrossDesigns({ - projectId: ctx.projectId, - routeId, - detail, - edits: ctx.edits(), - alignment: ctx.alignment(), - // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. - shouldApply: isCurrent, - }) - .then((updated) => { - if (current === seq) busy = false; - // 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1) - if (updated.length || lastStale) ctx.onApplied(updated); - }) - .catch(() => { - /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ - if (current !== seq) return; - busy = false; - if (lastStale) ctx.onApplied([]); - }); - }, ctx.debounceMs()); + return { + scheduleIfStale(key) { + if (awaiting === null) warnLeft(key); + return key !== "" && awaiting !== null; }, + schedule: () => pipelineCross(ctx.projectId), dispose() { - window.clearTimeout(timer); window.clearTimeout(waitTimer); stopGeneration(); release(); }, }; - return preview; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 3b00ffc4d..673e6c6a0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -54,7 +54,6 @@ import { import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { useStationKeys } from "./B06_Section_Station_Keys"; -import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; @@ -168,35 +167,10 @@ export function staleAgainstScreen(detail: SectionDetailResponse, routeId: numbe }); } -/** - * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). - * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). - */ -export async function refreshStaleDesigns( - input: Omit, -): Promise { - const draft = readAlignmentDraft(input.routeId); - if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return []; - const saved = ( - input.detail.longitudinal.profile_alignment as - | { - edits?: { - station_offsets?: Record; - curve_radii?: Record; - }; - } - | undefined - )?.edits; - const edits = draft ?? { - station_offsets: saved?.station_offsets ?? {}, - curve_radii: saved?.curve_radii ?? {}, - }; - return refreshCrossDesigns({ ...input, edits }); -} - /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. - * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` savedCross · 387-4) — 화면은 `pipelineCross` 로 부탁. * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { @@ -206,16 +180,13 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise section.preview)) return []; useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); - const updated = refreshLocally(input) ?? (await refreshFromServer(input)); - // 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시 - // 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가 - // 갈렸다(계획서 24-2a · 실측 B05 −14,144.5 · B06 −12,533.2㎥). - if (updated.length) { - applyStructureAreaRows( - input.detail.cross_sections, - structureAreaRows(input.detail.cross_sections), - ); - } + if (!refreshLocally(input)) await refreshFromServer(input); + // 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는 + // 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점). + applyStructureAreaRows( + input.detail.cross_sections, + structureAreaRows(input.detail.cross_sections), + ); return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); diff --git a/B06_Section/B06_Section_Section_Adjusts.ts b/B06_Section/B06_Section_Section_Adjusts.ts new file mode 100644 index 000000000..81559469d --- /dev/null +++ b/B06_Section/B06_Section_Section_Adjusts.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B06_Section_Section_Adjusts.ts + * 저장 안 한 조정창 값(세션 · 측점 누가거리 열쇠)을 상세 설계 칸에 얹음 — 상세 가게가 읽을 때 한 번(387-4). + * + * 조정창(`_UI_Page_Station_Controls` · `_Ford_Controls` · `_Span_Control`)이 고칠 때 캐시에 얹는 것과 같은 + * 칸 · 같은 꼴. 예전에는 고칠 때만 얹어 새로고침 · B05 만 연 페이지는 세션 값을 몰랐음 — 카드 그리기가 면적을 + * 고쳐 들어 B06 을 연 뒤에만 면적이 바뀜(B05 만 연 면적 ≠ B06). 조정창은 세션 → 설계 순으로 읽으니 겹쳐도 같음. + * ========================================================================== */ + +import { readState } from "../A00_Common/b_page_state"; +import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom"; +import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; + +type Cells = Record; +type Obj = Record; +const INLET = ["auto", "revet", "I", "L", "U"]; +const isObj = (value: unknown): value is Obj => !!value && typeof value === "object"; +const count = (value: unknown): number => + Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0; + +/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */ +function wallAdjust(value: unknown): WallAdjust | null { + if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; + if (!isObj(value)) return null; + const partial = value as Partial; + return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; +} + +export function withDraftAdjusts( + detail: SectionDetailResponse, + projectId: string, + routeId: number, +): void { + const read = (name: Parameters[0]): Cells => + readState(name, projectId, routeId) ?? {}; + const revet = read("revetx"); + const basin = read("basinadjust"); + const inlet = read("inletstruct"); + const extra = read("extrawall"); + const spans = read("extraspan"); + const ford = read("fordadjust"); + const box = read("boxadjust"); + const link = read("revetlink") as { detached?: Cells; grade?: Cells }; + for (const section of detail.cross_sections) { + const design = section.design as (CrossDesign & Obj) | null | undefined; + if (!design) continue; + const at = section.chainage_m.toFixed(2); + const own = (cells: Cells): Array<[string, unknown]> => + Object.entries(cells) + .filter(([key]) => key.startsWith(`${at}:`)) + .map(([key, value]) => [key.slice(at.length + 1), value]); + for (const [role, value] of own(revet)) { + const adjust = wallAdjust(value); + if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust }; + } + if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string)) + design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"]; + if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] }; + if (at in extra || `b${at}` in extra) + design.extra_wall_counts = { outlet: count(extra[at]), basin: count(extra[`b${at}`]) }; + for (const [wall, value] of own(spans)) { + const span = value as { lengthM: number; beforeM: number; afterM: number } | null; + if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue; + design.extra_spans = { + ...(design.extra_spans ?? {}), + [wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM }, + }; + } + if (isObj(link.detached) && at in link.detached) + design.revet_link_detached = !!link.detached[at]; + if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at]; + if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]); + const sides = box[at] as { left?: Obj; right?: Obj } | undefined; + if (isObj(sides)) + design.box_adjust = { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right }, + }; + } +} diff --git a/B06_Section/B06_Section_Section_Canon.ts b/B06_Section/B06_Section_Section_Canon.ts new file mode 100644 index 000000000..85f3a3014 --- /dev/null +++ b/B06_Section/B06_Section_Section_Canon.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * B06_Section_Section_Canon.ts + * 세션 상세(`section-detail`) = **저장 정본만**(387-7) — 서버에서 받은 그대로 한 벌 · 갈아끼움 · 되돌림 · + * 다시 셈 결과는 안 씀(메모리 캐시 몫). 세션 글은 **한 번만** 풂 — 상세 가게(`_Section_Store`)와 계산 묶음 + * cross 입력(`design_pipeline_cross`)이 이 파일을 함께 봄(예전에는 둘이 따로 1.5 MB 를 풂). + * 가벼운 자리 — 상세 가게의 화면 줄기를 안 끌고 묶음 입력이 부름. + * ========================================================================== */ + +import { readState } from "../A00_Common/b_page_state"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; + +/** 정본 측점 설계 — 받을 때 뜬 사본(다시 셈 · 초안 얹기가 고치는 캐시 객체와 따로). */ +export type CanonRow = { chainage_m: number; design: CrossSection["design"] }; + +const canon = new Map(); +/** 묶음이 먼저 푼 세션 상세 — 상세 가게가 처음 읽을 때 가져감(다시 안 풂). */ +const parsed = new Map(); +const keyOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; + +/** 서버에서 받은(또는 세션에서 푼) 정본의 설계 사본을 떠 둠. */ +export function keepCanon(projectId: string, routeId: number, detail: SectionDetailResponse): void { + const key = keyOf(projectId, routeId); + parsed.delete(key); + canon.set( + key, + detail.cross_sections.map(({ chainage_m, design }) => ({ + chainage_m, + design: design ? { ...design } : design, + })), + ); +} + +/** 새로고침 — 세션 상세(묶음이 먼저 풀었으면 그 객체) · 정본 사본도 뜸. */ +export function sessionDetail(projectId: string, routeId: number): SectionDetailResponse | null { + const key = keyOf(projectId, routeId); + const ready = parsed.get(key); + if (ready) { + parsed.delete(key); + return ready; + } + const stored = readState("section-detail", projectId, routeId); + if (stored) keepCanon(projectId, routeId, stored); + return stored; +} + +/** 정본 측점 설계(묶음 cross 입력 · 365-5) — 아직 안 읽었으면 세션에서 한 번(상세 가게가 그 객체를 이어 씀). */ +export function savedDesigns(projectId: string, routeId: number): CanonRow[] | null { + const key = keyOf(projectId, routeId); + if (!canon.has(key)) { + const stored = readState("section-detail", projectId, routeId); + if (!stored) return null; + keepCanon(projectId, routeId, stored); + parsed.set(key, stored); + } + return canon.get(key) ?? null; +} + +/** 정본이 바뀜(새로 받을 것) — 사본을 버림. 노선을 안 주면 그 프로젝트 전부. */ +export function forgetCanon(projectId: string, routeId?: number): void { + for (const map of [canon, parsed]) + for (const key of [...map.keys()]) + if ( + routeId === undefined ? key.startsWith(`${projectId}:`) : key === keyOf(projectId, routeId) + ) + map.delete(key); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index ea766a1d2..28318c965 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -19,7 +19,6 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { useDesignStore } from "../A00_Common/design/design_store"; import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station"; import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { @@ -28,7 +27,9 @@ import { withDraftStations, } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; +import { withDraftAdjusts } from "./B06_Section_Section_Adjusts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; @@ -71,6 +72,7 @@ async function withDraftWalls( await loadSavedPlusStations(projectId); await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) await withDraftShapes(detail, projectId); + withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도) return withStructureAreas(detail); } @@ -102,7 +104,7 @@ export async function loadSectionDetail( const inFlight = pending.get(key); if (inFlight) return inFlight; // 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다. - const stored = readState("section-detail", projectId, routeId); + const stored = sessionDetail(projectId, routeId); // 세션 글 = 한 번만 풂(387-7) if (stored) { cache.set(key, stored); return withDraftWalls(stored, projectId, routeId); @@ -112,6 +114,7 @@ export async function loadSectionDetail( .then(async (detail) => { cache.set(key, detail); writeState("section-detail", detail, projectId, routeId); + keepCanon(projectId, routeId, detail); return withDraftWalls(detail, projectId, routeId); }) .finally(() => { @@ -160,10 +163,8 @@ export async function swapSectionStations( const nextKeys = stations.map((station) => ({ key: station.key, chainageM: station.chainageM })); if (swap.moved) carryStationKeys(projectId, routeId, nextKeys); else useStationKeyList(projectId, routeId, nextKeys); + // 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1) cache.set(key, swap.detail); - // 노선 초안 동안 = 메모리만(365-1) — 세션 상세는 저장 노선 그대로(새로고침 · B06 바로 열기 = 묶음이 초안 다시 셈) - if (!useDesignStore(projectId, null).get("routeDraft")) - writeState("section-detail", swap.detail, projectId, routeId); await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 @@ -229,8 +230,7 @@ export async function restoreSectionDetail( }); for (const [name, value] of hold.cells) writeState(name, value ?? null, projectId, routeId); useStationKeyList(projectId, routeId, hold.keys); - cache.set(key, saved); - writeState("section-detail", saved, projectId, routeId); + cache.set(key, saved); // 세션 상세는 정본 그대로(387-7) await withDraftShapes(saved, projectId); withStructureAreas(saved); if (cache.get(key) !== saved) return null; // 그 사이 다른 갈아끼움 · 새로 받음 @@ -247,6 +247,7 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo forgetDraftStations(projectId); if (routeId !== undefined) { cache.delete(keyOf(projectId, routeId)); + forgetCanon(projectId, routeId); clearState("section-detail", projectId, routeId); return; } @@ -254,6 +255,7 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo for (const key of [...cache.keys()]) { if (!key.startsWith(prefix)) continue; cache.delete(key); + forgetCanon(projectId, Number(key.slice(prefix.length))); clearState("section-detail", projectId, Number(key.slice(prefix.length))); } } diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 037a325b2..abe2571dc 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -281,7 +281,7 @@ function areaRowOf( /** * 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다. - * 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출 + * 다시 셈 창구 · 상세 가게(화면이 보는 면적 · 카드 그리기는 안 얹음 · 387-4)와 [저장]·[확정] · 초기값 산출 * (Node 진입점)이 이 한 벌을 함께 쓴다. */ export function structureAreaRows( diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9b18571e5..1384ae987 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -42,7 +42,6 @@ import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendCardFrame, appendGroundProfile, - applyStructureAreas, drawnContentBounds, } from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; @@ -507,10 +506,7 @@ export function createCrossSectionCard( fordLayout?.designTrim ?? boxLayout?.designTrim ?? ownDesignTrim; - // 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다 - // (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이 - // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. - applyStructureAreas(section, designTrim); + // 구조물 측점 면적(그려지는 폐회로)은 다시 셈 창구 · 상세 가게가 같은 배치 셈으로 얹음 — 그리기는 안 고침(387-4). const designFrom = plotLayer.childElementCount; appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, { layer: svg, diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts index ca30247af..22e364a15 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Frame.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -5,7 +5,6 @@ * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; -import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common"; type Sample = CrossSection["samples"][number]; @@ -88,43 +87,6 @@ export function appendCardFrame( ); } -/** - * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). - * - * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 - * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. - * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. - * - * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 - * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). - */ -export function applyStructureAreas( - section: CrossSection, - trim: DesignTrim | undefined | null, -): void { - const design = section.design; - if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { - return; - } - const ground = section.samples - .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") - .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - const areas = computeStructureAreas({ - designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, - ground, - trim, - rockBoundaryOffsetM: - typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, - }); - if (!areas) return; - design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); - design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); - if (typeof design.cut_soil_area_m2 === "number") { - design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); - } -} - /** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */ export function appendGroundProfile( plotLayer: SVGElement, diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 590837950..4598ef1da 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -4,7 +4,7 @@ * 동작은 그대로다(2026-09-13 분리). * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 - * - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) + * - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4) * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2). @@ -15,21 +15,20 @@ import { writeCrossDesignChoice, writeShoulderExtend, } from "./B06_Section_Cross_Design_Session"; -import { showToast } from "@ui/ui_template_elements"; -import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh"; import { bermSpansFromStructures, readBermSpans, writeBermSpans, } from "./B06_Section_UI_Page_Persist"; -import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; import { useStructureIds } from "./B06_Section_Station_Keys"; -import { pipelineCross } from "../A00_Common/design/design_pipeline_page"; +import { pipelineCross, reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { forceCross } from "../A00_Common/design/design_pipeline_cross"; export interface DesignSyncContext { projectId: string | null; @@ -120,74 +119,22 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); } - /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로 + /** 암 경계 · 암 경사 변경 뒤 다시 셈(암 측점 2단계 무릎 · 단면적) — 지금 값을 「사용자 선택」으로 * 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */ - function recomputeIfRock(chainageM: number): void { - const design = ctx - .detail() - ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design; - if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true }); - else if (projectId) pipelineCross(projectId); + function recomputeIfRock(_chainageM: number): void { + void reconcileStaleDesigns({ force: true }); } /** - * 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6). - * 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이 - * 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선 - * (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API - * 호출이 없다. - * - * 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인 - * — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼 - * 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선), - * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· - * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. + * 횡단 다시 셈 = 계산 묶음 cross 단계 **한 곳**(387-4) — 여기서는 묶음에 부탁하고 그 차례가 끝날 때까지 + * 기다림. 입력이 같으면 안 셈 · `force` = 입력 칸 밖 세션 고침(노견 · 소단 · 단면 유형 · 조정창)이라 한 번 더. + * 바뀐 카드는 묶음 알림(`onCrossDesignsUpdated`)이 다시 그림. */ async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { - const sectionDetail = ctx.detail(); - const currentRouteId = ctx.routeId(); - if (!sectionDetail || !projectId || currentRouteId === null) return; - const draft = readAlignmentDraft(currentRouteId); - // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + - // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. - // - // 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은 - // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 - // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 - // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - // 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1). - if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return; - // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 - // (CLAUDE.md 5장). - try { - const alignment = sectionDetail.longitudinal.profile_alignment as - | { - edits?: { - station_offsets?: Record; - curve_radii?: Record; - }; - } - | undefined; - const edits = draft ?? { - station_offsets: alignment?.edits?.station_offsets ?? {}, - curve_radii: alignment?.edits?.curve_radii ?? {}, - }; - // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 - // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). - const updated = await refreshCrossDesigns({ - projectId, - routeId: currentRouteId, - detail: sectionDetail, - edits, - }); - // 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단 - // 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12). - ctx.view().refreshCards(updated); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Design_Failed")}${detail}`, "error"); - } - pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈) + if (!projectId) return; + if (options?.force) forceCross(); + pipelineCross(projectId); + await reconcilePagePipeline(projectId); } /** diff --git a/resources/tester/helper_384_4_adjusts.cjs b/resources/tester/helper_384_4_adjusts.cjs new file mode 100644 index 000000000..9a4a46c6e --- /dev/null +++ b/resources/tester/helper_384_4_adjusts.cjs @@ -0,0 +1,90 @@ +/* 387-4 헬퍼 — 저장 안 한 조정창 값(세션)을 상세 가게가 얹는지(`B06_Section_Section_Adjusts`) · 얹은 뒤 구조물 + * 면적(`structureAreaRows`)이 그 값으로 바뀌는지 · 다른 노선 칸은 안 얹는지. 짝 = `test_384_4_cross_window.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +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 B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { structureAreaRows } = loadTs(B06("B06_Section_Structure_Layouts")); +const { withDraftAdjusts } = loadTs(B06("B06_Section_Section_Adjusts")); +const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); + +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_186_1_pipe_292.json"), "utf8"), +).cross_sections; +const detail = { cross_sections: sections }; +const section = sections.find((s) => s.culvert); +const at = section.chainage_m.toFixed(2); +const area = () => { + const row = structureAreaRows(sections).find((r) => r.chainage_m === section.chainage_m); + return row ? [row.cut_area_m2, row.fill_area_m2] : null; +}; +const out = { before: area() }; +// 다른 노선 칸 — 안 얹음 +writeState("revetx", { [`${at}:outlet`]: { x: 3, d: null, h: null, m: null } }, "p", 8); +withDraftAdjusts(detail, "p", 7); +out.otherRoute = section.design.revet_adjust?.outlet ?? null; +// 이 노선 — 유출 벽 4축 · 유입 형식 · 다단 수 +writeState("revetx", { [`${at}:outlet`]: { x: 1.5, d: 0, h: null, m: null } }, "p", 7); +writeState("inletstruct", { [at]: "L" }, "p", 7); +writeState("extrawall", { [at]: 2 }, "p", 7); +withDraftAdjusts(detail, "p", 7); +out.laid = { + revet: section.design.revet_adjust.outlet, + inlet: section.design.inlet_structure, + extra: section.design.extra_wall_counts, +}; +out.after = area(); +withDraftAdjusts(detail, "p", 7); +out.again = area(); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py index 125f2a936..50bb87f6c 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -1,8 +1,8 @@ """PLAN 317-1 — B05 [3D 업데이트]가 낡은 횡단을 먼저 다시 셈(요구 213). - 낡음 지문(`staleKey`) — 낡은 측점이 있으면 지문 · 모두 맞으면 "" -- 그릴 때 잰 지문이 새것일 때만 다시 셈 예약(같은 낡음 되풀이 · 서버 폴백 반복 막음) -- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 낡은 설계부터 다시 셈 +- 그릴 때 잰 낡음은 다시 셈을 직접 안 부름 — 계산 묶음 cross 한 곳이 셈(387-4 · ▲ 한 번 = cross 한 번) +- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 묶음 차례(다시 셈)를 기다림 실제 TS 를 `helper_317_stale_preview.cjs` 가 Node 로 돌린다(다시 셈 창구는 흉내). """ @@ -47,9 +47,9 @@ def test_stale_key_marks_only_stale_sections(result: dict) -> None: assert result["key_fresh"] == "" -def test_same_stale_key_schedules_once(result: dict) -> None: - # a → 부름 · a(같음) → 안 부름 · "" → 안 부름(되돌림) · a(다시 낡음) → 부름 · b → 부름 - assert result["calls"] == [1, 1, 1, 2, 3] +def test_stale_key_never_refreshes_directly(result: dict) -> None: + # 낡음이 와도 미리 셈은 다시 셈 창구를 안 부름 — 묶음 cross 한 곳(387-4) + assert result["calls"] == [0, 0, 0, 0, 0] def test_draw_and_button_wiring() -> None: @@ -65,5 +65,5 @@ def test_draw_and_button_wiring() -> None: assert "pendingRecalc: pending," in render corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] - assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body - assert body.index("refreshStaleDesigns") < body.index("ensureCorridor") + assert "await reconcilePagePipeline(projectId)" in body + assert body.index("reconcilePagePipeline") < body.index("ensureCorridor") diff --git a/resources/tester/test_342_1_station_line.py b/resources/tester/test_342_1_station_line.py index 1dfd11def..75c6d63ed 100644 --- a/resources/tester/test_342_1_station_line.py +++ b/resources/tester/test_342_1_station_line.py @@ -99,11 +99,12 @@ def test_국부_연산_바뀐_측점만(out): def test_남은_낡음은_곡선을_그리고_경고(out): - assert out["settleFirst"] is True # 다시 셈 기다림 - assert out["settleSameKey"] is True # 다시 셈이 못 고침 - assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림 + # 387-4 — 미리 셈은 다시 셈을 안 함(묶음 cross 한 곳) · 기다릴 묶음 차례가 없으면 곧바로 곡선 + 경고 + assert out["settleFirst"] is False + assert out["settleSameKey"] is True + assert out["settleAfter"] is False assert out["settleWarns"] == 1 - assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림 + assert out["settleRedraws"] == 0 assert out["settleClear"] is False @@ -116,7 +117,7 @@ def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out) def test_전체_측점_반영은_한_번_선택_안_박음(out): - assert out["applyAllCalls"] == [out["total"]] + assert out["applyAllCalls"] == [] # 387-4 — 화면이 직접 안 셈 · 묶음 cross 가 셈 assert out["applyAllChoiceKeys"] == 0 diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 7bbc1c325..861b0e8ec 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -229,4 +229,5 @@ def test_365_1_초안_동안_세션_상세_안_덮음(swap): assert d["swapped"] and d["cache"] == [0, 20, 30, 40, 60] # 메모리 = 초안 측점 assert d["session"] == [0, 20, 40, 60] # 세션 = 저장 노선 그대로 s = swap["saved"] - assert s["swapped"] and s["session"] == s["cache"] == [0, 20, 50, 60] # 초안 없으면 세션도 + # 387-7 — 초안 없어도 세션 = 저장 정본만(갈아끼움은 메모리 · 새로고침이면 묶음이 다시 갈아끼움) + assert s["swapped"] and s["cache"] == [0, 20, 50, 60] and s["session"] == [0, 20, 40, 60] diff --git a/resources/tester/test_384_4_cross_window.py b/resources/tester/test_384_4_cross_window.py new file mode 100644 index 000000000..28ff5bce5 --- /dev/null +++ b/resources/tester/test_384_4_cross_window.py @@ -0,0 +1,86 @@ +"""387-4(384 단계 4) 횡단 다시 셈 창구 하나 · 면적 얹기 한 곳. + +· 다시 셈(`refreshCrossDesigns`) 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross) — 종단 미리 + 셈 · B06 설계 맞춤 · 3D 업데이트는 묶음에 부탁(`pipelineCross` · `reconcilePagePipeline`) · ▲ 한 번 = cross 한 번 +· 화면(횡단 보기 · 카드 그리기)은 구조물 면적을 안 고침 — 다시 셈 창구 · 상세 가게만 얹음(카드 안 그려도 면적 같음) +· 저장 안 한 조정창 값(세션)은 상세 가게가 읽을 때 얹음 — B05 만 연 페이지도 같은 면적 · 다른 노선 칸은 안 얹음 + +./venv/Scripts/python.exe -m pytest resources/tester/test_384_4_cross_window.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_384_4_adjusts.cjs" + + +def _code(relative: str) -> str: + text = (ROOT / relative).read_text(encoding="utf-8") + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text) + + +def _screen_and_page_ts() -> list[Path]: + files = list((ROOT / "A00_Common" / "design").rglob("*.ts")) + files += (ROOT / "B05_Profile").rglob("B05_Profile_*.ts") + files += (ROOT / "B06_Section").rglob("B06_Section_*.ts") + return files + + +def test_다시_셈_부르는_곳_하나() -> None: + callers = [ + str(p.relative_to(ROOT)) + for p in _screen_and_page_ts() + for _ in re.findall(r"(? None: + hits = [ + str(p.relative_to(ROOT)) + for p in _screen_and_page_ts() + if ("_UI_" in p.name or p.name.startswith("design_view")) + and re.search(r"applyStructureArea\w*\(", _code(str(p.relative_to(ROOT)))) + ] + assert hits == [] + + +def test_화면은_묶음에_부탁() -> None: + sync = _code("B06_Section/B06_Section_UI_Page_Design_Sync.ts") + assert "pipelineCross(projectId)" in sync and "await reconcilePagePipeline(projectId)" in sync + preview = _code("B05_Profile/B05_Profile_UI_Profile_Preview.ts") + assert "pipelineCross(ctx.projectId)" in preview + corridor = _code("B05_Profile/B05_Profile_UI_Corridor.ts") + assert "await reconcilePagePipeline(projectId)" in corridor + # 입력 칸 밖 세션 고침(노견 · 소단 · 조정창)은 「한 번 더」 표시로 같은 입력이어도 cross 다시 + cross = _code("A00_Common/design/design_pipeline_cross.ts") + assert "export const forceCross" in cross and "|${forced}`" in cross + + +@pytest.fixture(scope="module") +def adjusts() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_세션_조정값을_상세_가게가_얹음(adjusts: dict) -> None: + assert adjusts["otherRoute"]["x"] != 3 # 다른 노선 칸은 안 얹음 + assert adjusts["laid"] == { + "revet": {"x": 1.5, "d": None, "h": None, "m": None}, # d 0 = 기본 자리(조정창 읽기와 같음) + "inlet": "L", + "extra": {"outlet": 2, "basin": 0}, + } + # 얹은 값으로 구조물 면적이 바뀜 · 두 번 얹어도 같음 + assert adjusts["after"] != adjusts["before"] + assert adjusts["again"] == adjusts["after"] diff --git a/resources/tester/test_384_7_section_canon.py b/resources/tester/test_384_7_section_canon.py new file mode 100644 index 000000000..957ee2712 --- /dev/null +++ b/resources/tester/test_384_7_section_canon.py @@ -0,0 +1,103 @@ +"""387-7(384 단계 7) 결과 층 — 세션 상세 = 저장 정본만 · 열 때 세션 상세 JSON.parse 한 번. + +· 상세 가게(새로고침)와 묶음 cross 입력이 어느 쪽이 먼저 읽어도 세션 글은 한 번만 풂(`_Section_Canon`) +· 정본 사본은 캐시 객체와 따로 — 다시 셈 · 초안 얹기가 캐시를 고쳐도 묶음 입력의 정본은 그대로 +· 갈아끼움 · 되돌림은 세션 상세를 안 씀(`test_365_page_pipeline` 365-1 · 이 파일은 소스로) + +./venv/Scripts/python.exe -m pytest resources/tester/test_384_7_section_canon.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" + +DRIVER = r""" +const data = new Map(); +const fake = { + get length() { return data.size; }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +globalThis.window = { sessionStorage: fake, localStorage: fake }; +const { loadTs } = require(process.argv[1]); +const canon = loadTs(process.argv[2]); +const state = loadTs(process.argv[3]); +const detail = { + longitudinal: {}, + cross_sections: [ + { chainage_m: 0, design: { ground_type: "soil", cut_area_m2: 1 } }, + { chainage_m: 20, design: { ground_type: "rock", cut_area_m2: 2 } }, + ], +}; +let parses = 0; +const parse = JSON.parse; +JSON.parse = (text, ...rest) => { + if (typeof text === "string" && text.includes("cross_sections")) parses += 1; + return parse(text, ...rest); +}; +const out = {}; +// 묶음 입력이 먼저 → 상세 가게 +state.writeState("section-detail", detail, "p", 1); +const rows = canon.savedDesigns("p", 1); +const shown = canon.sessionDetail("p", 1); +out.cross = { parses, rows: rows.length }; +// 정본 사본 = 캐시 객체와 따로 +shown.cross_sections[0].design.cut_area_m2 = 99; +out.canonKept = canon.savedDesigns("p", 1)[0].design.cut_area_m2; +// 상세 가게가 먼저 → 묶음 입력 +parses = 0; +state.writeState("section-detail", detail, "p", 2); +canon.sessionDetail("p", 2); +canon.savedDesigns("p", 2); +out.storeFirst = parses; +// 버림 → 다시 받기 전엔 정본 없음 +canon.forgetCanon("p"); +state.writeState("section-detail", null, "p", 1); +out.forgot = canon.savedDesigns("p", 1); +process.stdout.write(JSON.stringify(out)); +""" + + +def _run() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + "-e", + DRIVER, + str(LOADER), + str(ROOT / "B06_Section" / "B06_Section_Section_Canon.ts"), + str(ROOT / "A00_Common" / "b_page_state.ts"), + ], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_열_때_세션_상세_한_번만_풂() -> None: + out = _run() + assert out["cross"] == {"parses": 1, "rows": 2} + assert out["storeFirst"] == 1 + assert out["canonKept"] == 1 # 캐시를 고쳐도 정본 사본 그대로 + assert out["forgot"] is None + + +def test_세션_상세_쓰는_곳_받을_때_하나() -> None: + store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8") + code = re.sub(r"/\*.*?\*/|//[^\n]*", "", store, flags=re.S) + assert code.count('writeState("section-detail"') == 1 # 서버에서 받은 정본만 + cross = (ROOT / "A00_Common" / "design" / "design_pipeline_cross.ts").read_text( + encoding="utf-8" + ) + assert "section-detail" not in re.sub(r"/\*.*?\*/|//[^\n]*", "", cross, flags=re.S) diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index d9527e41b..9e95630fa 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -99,7 +99,8 @@ def test_source_shared_rule_and_b05_entry_recompute(): assert "scheduleCrossPreview();" in B05_PANEL # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). - # 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름) - assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE + # 다시 셈은 계산 묶음 cross 한 곳(387-4) — B06 은 판정 · 다시 셈 없이 묶음에 부탁 + assert "pipelineCross(projectId)" in B06_PAGE + assert "staleAgainstScreen" not in B06_PAGE assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" not in B06_PAGE diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index ccc119e31..55f08026f 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -47,8 +47,11 @@ def test_cross_refresh_is_single_entry(): assert "export async function refreshCrossDesigns" in REFRESH assert "readStandardCrossSession(projectId)" in REFRESH assert "readRockBoundarySession(projectId, routeId)" in REFRESH + # 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross · 387-4) — 화면은 묶음에 부탁 + shared = _read("A00_Common/design/design_shared.ts") + assert shared.count("refreshCrossDesigns(") == 1 for source in (B05_PREVIEW, B06_PAGE): - assert "refreshCrossDesigns(" in source + assert "refreshCrossDesigns(" not in source assert "previewCrossDesigns(" not in source @@ -75,3 +78,6 @@ def test_cross_refresh_lays_structure_areas_once(): body = body[: body.index("\n}\n")] assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body assert "applyStructureAreaRows(" not in B05_PREVIEW + # 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4) + assert "applyStructureAreaRows(" not in B06_PAGE + assert "applyStructureAreas" not in _read("B06_Section/B06_Section_UI_Cross_View.ts")