diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 6f76acd7..820ef552 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -924,6 +924,17 @@ "required": false, "phase": "detail", "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": ["넣음", "안 넣음"], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" } ] }, @@ -1086,6 +1097,17 @@ "required": false, "phase": "detail", "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": ["넣음", "안 넣음"], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" } ] }, @@ -1441,6 +1463,17 @@ "required": false, "phase": "detail", "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": ["넣음", "안 넣음"], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" } ] }, @@ -1991,6 +2024,17 @@ "required": false, "phase": "detail", "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": ["넣음", "안 넣음"], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" } ] }, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index ab248927..621d7c6f 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,4 +1,5 @@ -import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; +import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync"; +import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; @@ -23,36 +24,20 @@ import { type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; -import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { - bermSpansFromStructures, confirmCurrentSections, createCutSlopeStore, createRockBoundaryStore, - readBermSpans, saveCurrentSections, - writeBermSpans, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; -import { - createProfileEditStore, - readAlignmentDraft, - type ProfileEditStore, -} from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; -import { - adjustStation, - buildAlignment, - toAlignmentBase, -} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readStructurePick, writeStructurePick, } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick"; -import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View"; -import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; +import { createSectionView } from "./B06_Section_UI_Section_View"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, @@ -226,142 +211,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); - /** - * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), - * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. - * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. - */ - async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { - if (!projectId || currentRouteId === null || !sectionDetail) return; - const target = sectionDetail.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < 0.01, - ); - if (!target) return; - - // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. - if (target.design) { - target.design = { - ...target.design, - ground_type: change.ground_type, - section_mode: change.section_mode, - ditch_side: change.ditch_side ?? target.design.ditch_side, - ditch_type: change.ditch_type, - paved: change.paved, - two_stage_slope: change.two_stage_slope, - ditch_choice: change.ditch_choice, - }; - sectionView.refreshCard(chainageM); - } - - // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, - // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 - // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. - writeCrossDesignChoice(projectId, currentRouteId, chainageM, { - ground_type: change.ground_type, - section_mode: change.section_mode, - ditch_side: change.ditch_side ?? null, - ditch_type: change.ditch_type, - paved: change.paved, - two_stage_slope: change.two_stage_slope, - ditch_choice: change.ditch_choice, - }); - // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. - await reconcileStaleDesigns({ force: true }); - } - - /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ - function changeFromDesign(chainageM: number): CrossDesignChange | null { - const target = sectionDetail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < 0.01, - ); - const design = target?.design; - if (!design) return null; - return { - ground_type: design.ground_type, - section_mode: design.section_mode, - ditch_side: design.ditch_side ?? null, - ditch_type: design.ditch_type ?? "standard", - paved: design.paved, - two_stage_slope: design.two_stage_slope ?? true, - ditch_choice: design.ditch_choice ?? null, - }; - } - - /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */ - function recomputeIfRock(chainageM: number): void { - const change = changeFromDesign(chainageM); - if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); - } - - /** - * 로드 시 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)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· - * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. - */ - async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { - 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 실측). 재계산은 브라우저 안에서 끝나 값싸다. - if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) 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). - sectionView.refreshCards(updated); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Design_Failed")}${detail}`, "error"); - } - } - - /** - * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. - * - * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 - * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. - */ - function syncBermSpans(structures: ReadonlyArray): void { - if (!projectId || currentRouteId === null) return; - const next = bermSpansFromStructures(structures); - if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; - writeBermSpans(projectId, currentRouteId, next); - void reconcileStaleDesigns({ force: true }); - } + // 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리). + const { + handleDesignChange, + recomputeIfRock, + reconcileStaleDesigns, + syncBermSpans, + applyPanelToAll, + } = createDesignSync({ + projectId, + routeId: () => currentRouteId, + detail: () => sectionDetail, + view: () => sectionView, + }); /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { @@ -418,28 +280,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { showToast(L("B06_View_Apply_Success"), "success"); } - /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. - * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 - * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ - let applyingAll = false; - async function applyPanelToAll(): Promise { - if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return; - const targets = sectionDetail.cross_sections.filter((section) => section.design); - if (!targets.length) return; - // 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장). - // 대신 도는 동안 다시 누르는 것만 막는다. - applyingAll = true; - try { - for (const section of targets) { - const change = changeFromDesign(section.chainage_m); - if (change) await handleDesignChange(section.chainage_m, change); - } - showToast(L("B06_Std_ApplyAll_Success"), "success"); - } finally { - applyingAll = false; - } - } - // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ sessionKey: () => stateKey("rockb", projectId, currentRouteId), @@ -493,62 +333,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); - /** - * 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 - * 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcileStale…`), - * 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). - */ - let gradeStore: ProfileEditStore | null = null; - let gradeRouteId: number | null = null; - /** ▲▼ 길게 누르기는 초당 10회(`HOLD_INTERVAL_MS`) 들어온다 — 그보다 길게 잡아 - * 누르는 동안은 선만 움직이고, 손을 뗀 뒤 재계산이 한 번 돈다. */ - const GRADE_RECONCILE_DEBOUNCE_MS = 120; - let gradeReconcileTimer = 0; - const scheduleGradeReconcile = (): void => { - window.clearTimeout(gradeReconcileTimer); - gradeReconcileTimer = window.setTimeout(() => { - void reconcileStaleDesigns({ force: true }).then(() => - sectionView.setGradeEdit(gradeEditFor), - ); - }, GRADE_RECONCILE_DEBOUNCE_MS); - }; - const gradeEditFor = (): ReturnType< - NonNullable[0]> - > => { - const detail = sectionDetail; - if (!detail || currentRouteId === null) return null; - const stored = readAlignment(detail.longitudinal); - if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다. - if (!gradeStore || gradeRouteId !== currentRouteId) { - gradeRouteId = currentRouteId; - gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined); - } - const store = gradeStore; - const base = toAlignmentBase(stored); - const alignment = buildAlignment(base, store.edits()); - return { - alignment, - stepM: alignment.policy.edit_step_m, - onStation: (chainageM, delta) => { - store.replace(adjustStation(base, store.edits(), chainageM, delta)); - // ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만 - // 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다). - const detail = sectionDetail; - if (detail) { - detail.longitudinal.design_profiles = [ - toDesignProfile( - buildAlignment(base, store.edits()), - detail.longitudinal.design_profiles?.[0], - ), - ]; - } - sectionView.setGradeEdit(gradeEditFor); - // ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙). - // 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다. - scheduleGradeReconcile(); - }, - }; - }; + // 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리). + const gradeEditFor = createGradeEdit({ + routeId: () => currentRouteId, + detail: () => sectionDetail, + view: () => sectionView, + reconcile: () => reconcileStaleDesigns({ force: true }), + }); sectionView.setGradeEdit(gradeEditFor); // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts new file mode 100644 index 00000000..d6325546 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -0,0 +1,218 @@ +/* ============================================================================= + * B06_Section_UI_Page_Design_Sync.ts + * 측점 설계 선택 반영·재계산 창구. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며 + * 동작은 그대로다(2026-09-13 분리). + * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 + * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 + * - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) + * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 + * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] + * ========================================================================== */ + +import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; +import { showToast } from "@ui/ui_template_elements"; +import { refreshCrossDesigns } 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 { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; +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"; + +export interface DesignSyncContext { + projectId: string | null; + routeId: () => number | null; + detail: () => SectionDetailResponse | null; + /** 뷰는 이 모듈보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. */ + view: () => SectionViewController; +} + +export interface DesignSyncController { + handleDesignChange: (chainageM: number, change: CrossDesignChange) => Promise; + recomputeIfRock: (chainageM: number) => void; + reconcileStaleDesigns: (options?: { force?: boolean }) => Promise; + syncBermSpans: (structures: ReadonlyArray) => void; + applyPanelToAll: () => Promise; +} + +export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { + const { projectId } = ctx; + + /** + * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), + * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. + * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. + */ + async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { + const sectionDetail = ctx.detail(); + const currentRouteId = ctx.routeId(); + if (!projectId || currentRouteId === null || !sectionDetail) return; + const target = sectionDetail.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < 0.01, + ); + if (!target) return; + + // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. + if (target.design) { + target.design = { + ...target.design, + ground_type: change.ground_type, + section_mode: change.section_mode, + ditch_side: change.ditch_side ?? target.design.ditch_side, + ditch_type: change.ditch_type, + paved: change.paved, + two_stage_slope: change.two_stage_slope, + ditch_choice: change.ditch_choice, + }; + ctx.view().refreshCard(chainageM); + } + + // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, + // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 + // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. + writeCrossDesignChoice(projectId, currentRouteId, chainageM, { + ground_type: change.ground_type, + section_mode: change.section_mode, + ditch_side: change.ditch_side ?? null, + ditch_type: change.ditch_type, + paved: change.paved, + two_stage_slope: change.two_stage_slope, + ditch_choice: change.ditch_choice, + }); + // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. + await reconcileStaleDesigns({ force: true }); + } + + /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ + function changeFromDesign(chainageM: number): CrossDesignChange | null { + const target = ctx + .detail() + ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); + const design = target?.design; + if (!design) return null; + return { + ground_type: design.ground_type, + section_mode: design.section_mode, + ditch_side: design.ditch_side ?? null, + ditch_type: design.ditch_type ?? "standard", + paved: design.paved, + two_stage_slope: design.two_stage_slope ?? true, + ditch_choice: design.ditch_choice ?? null, + }; + } + + /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */ + function recomputeIfRock(chainageM: number): void { + const change = changeFromDesign(chainageM); + if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); + } + + /** + * 로드 시 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)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· + * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. + */ + 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 실측). 재계산은 브라우저 안에서 끝나 값싸다. + if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) 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"); + } + } + + /** + * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. + * + * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 + * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. + */ + function syncBermSpans(structures: ReadonlyArray): void { + const currentRouteId = ctx.routeId(); + if (!projectId || currentRouteId === null) return; + const next = bermSpansFromStructures(structures); + if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; + writeBermSpans(projectId, currentRouteId, next); + void reconcileStaleDesigns({ force: true }); + } + + /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. + * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 + * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + let applyingAll = false; + async function applyPanelToAll(): Promise { + const sectionDetail = ctx.detail(); + if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return; + const targets = sectionDetail.cross_sections.filter((section) => section.design); + if (!targets.length) return; + // 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장). + // 대신 도는 동안 다시 누르는 것만 막는다. + applyingAll = true; + try { + for (const section of targets) { + const change = changeFromDesign(section.chainage_m); + if (change) await handleDesignChange(section.chainage_m, change); + } + showToast(L("B06_Std_ApplyAll_Success"), "success"); + } finally { + applyingAll = false; + } + } + + return { + handleDesignChange, + recomputeIfRock, + reconcileStaleDesigns, + syncBermSpans, + applyPanelToAll, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Grade_Edit.ts b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts new file mode 100644 index 00000000..8c5c87c5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B06_Section_UI_Page_Grade_Edit.ts + * 종단 계획선 편집(▲/▼) 제공자. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며 + * 동작은 그대로다(2026-09-13 분리). + * ========================================================================== */ + +import { + createProfileEditStore, + type ProfileEditStore, +} from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { + adjustStation, + buildAlignment, + toAlignmentBase, +} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { SectionViewController } from "./B06_Section_UI_Section_View"; + +type GradeEditProvider = NonNullable[0]>; + +export interface GradeEditContext { + routeId: () => number | null; + detail: () => SectionDetailResponse | null; + view: () => SectionViewController; + /** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */ + reconcile: () => Promise; +} + +/** + * 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 + * 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`), + * 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). + */ +export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider { + let gradeStore: ProfileEditStore | null = null; + let gradeRouteId: number | null = null; + /** ▲▼ 길게 누르기는 초당 10회(`HOLD_INTERVAL_MS`) 들어온다 — 그보다 길게 잡아 + * 누르는 동안은 선만 움직이고, 손을 뗀 뒤 재계산이 한 번 돈다. */ + const GRADE_RECONCILE_DEBOUNCE_MS = 120; + let gradeReconcileTimer = 0; + const scheduleGradeReconcile = (): void => { + window.clearTimeout(gradeReconcileTimer); + gradeReconcileTimer = window.setTimeout(() => { + void ctx.reconcile().then(() => ctx.view().setGradeEdit(gradeEditFor)); + }, GRADE_RECONCILE_DEBOUNCE_MS); + }; + const gradeEditFor: GradeEditProvider = () => { + const detail = ctx.detail(); + const currentRouteId = ctx.routeId(); + if (!detail || currentRouteId === null) return null; + const stored = readAlignment(detail.longitudinal); + if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다. + if (!gradeStore || gradeRouteId !== currentRouteId) { + gradeRouteId = currentRouteId; + gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined); + } + const store = gradeStore; + const base = toAlignmentBase(stored); + const alignment = buildAlignment(base, store.edits()); + return { + alignment, + stepM: alignment.policy.edit_step_m, + onStation: (chainageM, delta) => { + store.replace(adjustStation(base, store.edits(), chainageM, delta)); + // ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만 + // 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다). + const current = ctx.detail(); + if (current) { + current.longitudinal.design_profiles = [ + toDesignProfile( + buildAlignment(base, store.edits()), + current.longitudinal.design_profiles?.[0], + ), + ]; + } + ctx.view().setGradeEdit(gradeEditFor); + // ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙). + // 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다. + scheduleGradeReconcile(); + }, + }; + }; + return gradeEditFor; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 8fba8d63..f9b391fc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -279,6 +279,7 @@ export function putStandardSheetSpec( fill_concrete_mpa: string | null; thickness_top_m: string | null; thickness_bottom_m: string | null; + blinding_concrete: string | null; }, ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { return requestJson(`/projects/${projectId}/standard-sheets/spec`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py index bf871ce8..77b5ec01 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py @@ -37,7 +37,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( + STONE_MASONRY, + face_slope_ratio, + weep_hole_spec, +) from common_util.common_util_excavation import ( WALL_BLINDING_DEPTH_M, WALL_BLINDING_WIDTH_M, @@ -223,6 +227,9 @@ def build_figure( entities.append(base) # 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀). + # ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를 + # 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다. + weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {}) weep_y = height_m * 0.5 entities.append( _line_entity( @@ -252,7 +259,7 @@ def build_figure( "left", ), ( - f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소", + f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소", (slope * weep_y + top_t + 0.16, weep_y), "left", ), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py index 78958f67..8d89a8c7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py @@ -35,6 +35,7 @@ EDITABLE_KEYS: tuple[str, ...] = ( "fill_concrete_mpa", "thickness_top_m", "thickness_bottom_m", + "blinding_concrete", ) #: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). @@ -59,6 +60,7 @@ FIELD_LABELS: dict[str, str] = { "fill_concrete_mpa": "채움 강도", "thickness_top_m": "상부 두께", "thickness_bottom_m": "하부 두께", + "blinding_concrete": "버림 콘크리트", } #: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」. @@ -68,6 +70,10 @@ STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행") #: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준. FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210") +#: 버림 콘크리트 넣음/안 넣음 — 확정 ⑭ 「기본은 넣고, 빼고 싶으면 뺀다」(두께는 KDS 100㎜). +#: ⚠ 문구는 B08 `wants_blinding` 이 「빼기」로 읽는 말과 같아야 한다 — 다르면 골라도 안 빠진다. +BLINDING_CHOICES: tuple[str, ...] = ("넣음", "안 넣음") + def _clean_slope(value: Any) -> tuple[float | None, str | None]: """전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다.""" @@ -134,6 +140,7 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: for key, choices in ( ("stone_coeff_basis", STONE_COEFF_CHOICES), ("fill_concrete_mpa", FILL_CONCRETE_CHOICES), + ("blinding_concrete", BLINDING_CHOICES), ): if key not in spec: continue diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py index ce777eea..8eca6dcc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py @@ -75,6 +75,7 @@ class StandardSheetSpecRequest(BaseModel): fill_concrete_mpa: str | None = None thickness_top_m: float | str | None = None thickness_bottom_m: float | str | None = None + blinding_concrete: str | None = None @router.put("/{project_id}/standard-sheets/spec") diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 2d926f7f..41f27a58 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -213,13 +213,33 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const infoPanelHost = document.createElement("div"); infoPanelHost.className = "b07-info-host"; + /** + * [여기서 고치기] — 도면 하단표에서 **값을 정하는 자리**로 데려간다(확정 6 ㉮ + * 「한 곳에서 정하고 도면은 비추기만」). 표는 CAD 안에 그려져 단추를 못 달고, 제원 폼은 + * 도면 목록 아래라 장이 많으면 화면 밖으로 밀린다 — 그래서 **고정 액션 줄**에 둔다 + * (떠 있는 띠는 CAD 리본과 겹쳐 2026-09-02 에 걷어낸 자리라 다시 띄우지 않는다). + * ⚠ 값을 여기로 복사하지 않는다 — 폼 한 벌이 그대로 정본 창구다. + */ + const specJumpButton = createButton({ + label: "여기서 고치기", + variant: "ghost", + onClick: () => { + infoPanelHost.scrollIntoView({ behavior: "smooth", block: "nearest" }); + infoPanelHost.querySelector("select, input")?.focus(); + }, + }); + specJumpButton.title = "이 표의 값을 정하는 제원 칸으로 갑니다."; + specJumpButton.hidden = true; + const showStandardSpec = (drawing: DesignDrawingItem): void => { const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1; const sheet = standardSheets[index]; if (!projectId || !sheet) { infoPanelHost.replaceChildren(); + specJumpButton.hidden = true; return; } + specJumpButton.hidden = false; // 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다. const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null; infoPanelHost.replaceChildren( @@ -259,6 +279,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { showStandardSpec(drawing); return; } + specJumpButton.hidden = true; // 표준도 장이 아니면 갈 곳이 없다 if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; @@ -658,7 +679,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). const confirmButtonRow = document.createElement("div"); confirmButtonRow.className = "b07-drawing-actions__row"; - confirmButtonRow.append(frameEditor.button, confirmButton); + confirmButtonRow.append(specJumpButton, frameEditor.button, confirmButton); confirmActions.append(frameEditor.banner, confirmButtonRow); drawingPanel.append(infoPanelHost, confirmActions); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts index 1d0e761a..11dd93f7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts @@ -21,6 +21,8 @@ const FOUNDATIONS = ["기초유", "기초버림"] as const; const COEFF_BASES = ["품셈", "실무 관행"] as const; /** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */ const FILL_MPA = ["180", "210"] as const; +/** 확정 8-2·14 — 기본은 넣음(KDS 100㎜), 빼고 싶으면 「안 넣음」. 문구가 곧 서버가 읽는 말이다. */ +const BLINDINGS = ["넣음", "안 넣음"] as const; /** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ export interface StandardSheetSpec { @@ -42,6 +44,7 @@ export interface StandardSpecResult { fill_concrete_mpa: string | null; thickness_top_m: string | null; thickness_bottom_m: string | null; + blinding_concrete: string | null; } /** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ @@ -123,6 +126,7 @@ export function buildStandardSpecPanel( const foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —"); const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —"); const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —"); + const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —"); const slope = document.createElement("input"); slope.className = "b07-spec__input"; @@ -156,6 +160,7 @@ export function buildStandardSpecPanel( ), field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), + field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."), ); if (THICKNESS_TYPES.has(sheet.type_id)) { panel.append( @@ -199,6 +204,7 @@ export function buildStandardSpecPanel( foundation: foundation.value || null, stone_coeff_basis: coeff.value || null, fill_concrete_mpa: mpa.value || null, + blinding_concrete: blinding.value || null, thickness_top_m: THICKNESS_TYPES.has(sheet.type_id) ? topThickness.value.trim() || null : null, diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index 14a442d9..fdc3d1d6 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -21,7 +21,10 @@ COMMON = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").rea B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text( encoding="utf-8" ) -B06_PAGE = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8") +# 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). +B06_PAGE = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8") + ( + PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts" +).read_text(encoding="utf-8") def _stale(profile_samples, sections, tol=1e-3): diff --git a/resources/tester/test_b06_ditch_choice.py b/resources/tester/test_b06_ditch_choice.py index cedbfe6a..fa0edf20 100644 --- a/resources/tester/test_b06_ditch_choice.py +++ b/resources/tester/test_b06_ditch_choice.py @@ -107,7 +107,7 @@ def test_선택이_저장_patch_까지_실린다() -> None: for name in ( "B06_Section_Cross_Design_Session.ts", # 캐시 칸 - "B06_Section_UI_Page.ts", # 캐시에 쓰는 자리 + "B06_Section_UI_Page_Design_Sync.ts", # 캐시에 쓰는 자리 "B06_Section_Cross_Refresh.ts", # 브라우저 재계산이 읽는 자리 "B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch ): diff --git a/resources/tester/test_b07_standard_sheet_rows.py b/resources/tester/test_b07_standard_sheet_rows.py new file mode 100644 index 00000000..be3176af --- /dev/null +++ b/resources/tester/test_b07_standard_sheet_rows.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""표준도(구조물도) 하단표에 **칸이 서는지** — 날개벽·집수정·옹벽 (계획서 4-13). + +4-13 표의 「⚠ 칸만 오면 됨」 줄을 닫는 자리다. 하단표는 B08 전개를 접기만 하므로 +(`build_standard_sheets`), **관측 원단위가 들어온 종류는 저절로 선다**. 반대로 자료가 +없는 규격은 줄이 0개이고 **사유만** 뜬다 — 값을 지어내지 않는 것이 확정이다(확정 5차 3번). + +⚠ 이 시험은 수량값을 검산하지 않는다(그것은 `test_b08_wing_wall` · `test_b08_observed_unit` + 몫이다). 여기서 보는 것은 **표준도 하단표까지 값이 실려 오는가**다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import ( # noqa: E402 + build_standard_sheets, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _sheets(structures: list[dict], names: dict[str, str]) -> dict[str, dict]: + payload = build_standard_sheets(build_table(structures, names, {}), {}) + return {sheet["type_id"]: sheet for sheet in payload["sheets"]} + + +def _배수관(**options) -> dict: + return { + "structure_id": "p1", + "type_id": "pipe", + "name": "배수관 1", + "start_m": 60.0, + "end_m": 68.0, + "length_m": 8.0, + "options": {"pipe_diameter_mm": "800", "station": 60.0, **options}, + } + + +def _옹벽(form: str, height_m: float) -> dict: + return { + "structure_id": "w1", + "type_id": "retaining_wall", + "name": "옹벽", + "start_m": 100.0, + "end_m": 110.0, + "length_m": 10.0, + "height_m": height_m, + "options": {"form": form, "height_m": height_m, "length_m": 10.0, "station": 100.0}, + } + + +def test_날개벽_집수정_칸이_선다() -> None: + """관에 형식을 채우면 **딸린 두 장**이 저절로 서고 줄마다 단위당 값이 붙는다.""" + sheets = _sheets( + [ + _배수관( + inlet_basin_form="돌집수정 ㄷ형", + inlet_basin_material="콘크리트", + wing_wall_type="C-TYPE", + ) + ], + {"pipe": "배수관"}, + ) + for type_id in ("pipe_inlet_basin", "pipe_wing_wall"): + sheet = sheets.get(type_id) + assert sheet is not None, f"{type_id} 장이 안 섰다" + # 개소당 — 관측 원단위가 개소 기준이라 연장으로 접지 않는다(4-1: 단위를 통일하지 않는다). + assert sheet["billing_unit"] == "개소" + assert sheet["rows"], f"{type_id} 하단표가 비었다" + # 「값이 없다」를 0 으로 때우지 않는다 — 단위당을 못 낸 줄이 있으면 이름으로 드러난다. + assert sheet["unpriced_rows"] == [] + assert all(row["amount"] > 0 for row in sheet["rows"]) + + +def test_형식을_안_고르면_장이_안_선다() -> None: + """안 놓은 것과 같다 — 빈 장을 만들지 않는다(`ATTACHMENTS` 문턱).""" + sheets = _sheets([_배수관()], {"pipe": "배수관"}) + assert "pipe_inlet_basin" not in sheets + assert "pipe_wing_wall" not in sheets + + +def test_옹벽은_자료가_있는_규격만_선다() -> None: + """반중력식 H=2.0 은 소광리 「옹벽2.0」에서 왔고, 식생옹벽블럭은 **자료가 없다**.""" + 있음 = _sheets([_옹벽("반중력식", 2.0)], {"retaining_wall": "옹벽"})["retaining_wall"] + assert 있음["rows"], "관측 원단위가 있는데 하단표가 비었다" + assert 있음["unpriced_rows"] == [] + + 없음 = _sheets([_옹벽("식생옹벽블럭", 2.0)], {"retaining_wall": "옹벽"})["retaining_wall"] + assert 없음["rows"] == [], "자료 없는 규격에 값이 지어졌다" + assert any("자료에 없습니다" in note for note in 없음["notes"]), 없음["notes"] diff --git a/resources/tester/test_b07_standard_spec_blinding_weep.py b/resources/tester/test_b07_standard_spec_blinding_weep.py new file mode 100644 index 00000000..7712e818 --- /dev/null +++ b/resources/tester/test_b07_standard_spec_blinding_weep.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""표준도 제원 — 버림 콘크리트 칸(확정 8-2·14)과 물빼기 그림 주석(확정 5차 작은 것 2). + +**버림 콘크리트** — 셈은 이미 「기본은 넣고, 「안 넣음」이면 뺀다」로 서 있었는데 +(`wants_blinding`) **끄는 칸이 화면에 없었다**. 등록부 · 표준도 입력 · 폼 세 자리를 함께 +이어 놓은 것을 굳힌다. +⚠ **문구가 폴리시다** — 폼이 보내는 말이 `wants_blinding` 이 「빼기」로 읽는 말과 한 글자라도 + 다르면 골라도 안 빠진다. 그래서 선택지 문구를 **엔진에 직접 물어** 확인한다. + +**물빼기 주석** — 그림이 기본 상수를 적고 있어, 구조물별로 지름·면적을 고쳐도 **표는 바뀌고 +그림 글자만 옛 값**으로 남았다(확정 10 「구조물별로 다르게도 가능」과 어긋남). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 + build_figure, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import ( # noqa: E402 + BLINDING_CHOICES, + EDITABLE_KEYS, + clean_spec, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wants_blinding # noqa: E402 + +#: 버림이 서는 종류 — 돌쌓기 식(`stone_masonry`)·큰돌쌓기를 타는 것들. +BLINDING_TYPES = ("masonry_wet", "masonry_dry", "boulder_masonry", "revetment") + + +def _registry() -> dict[str, dict]: + payload = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + return {item["type_id"]: item for item in payload["types"]} + + +def test_등록부에_버림_칸이_있다() -> None: + """칸이 없으면 저장소가 그 제원을 거절한다(`drop_unregistered` 의 그 자리).""" + registry = _registry() + for type_id in BLINDING_TYPES: + options = {option["key"]: option for option in registry[type_id]["options"]} + field = options.get("blinding_concrete") + assert field is not None, f"{type_id} 등록부에 버림 칸이 없다" + assert field["choices"] == list(BLINDING_CHOICES) + # 기본은 **넣음** — 확정 ⑭. 빈 칸도 넣음이지만 화면에 보여야 한다. + assert field["default"] == "넣음" + assert field["phase"] == "detail" + + +def test_표준도가_버림_칸을_받는다() -> None: + assert "blinding_concrete" in EDITABLE_KEYS + spec, notes = clean_spec({"blinding_concrete": "안 넣음"}) + assert spec == {"blinding_concrete": "안 넣음"} and notes == [] + # 빈 값은 「정한 적 없음」 — 키를 지운다(그러면 기본인 「넣음」으로 돈다). + spec, _ = clean_spec({"blinding_concrete": ""}) + assert spec == {"blinding_concrete": None} + # 없는 갈래는 막지 않고 **알린다**(표준도 입력의 규칙). + _spec, notes = clean_spec({"blinding_concrete": "빼기도"}) + assert notes and "버림 콘크리트" in notes[0] + + +def test_선택지_문구가_엔진이_읽는_말과_같다() -> None: + """⚠ 이 시험이 없으면 「제외」처럼 다른 말로 바뀌어도 아무 데서도 안 걸린다.""" + 넣음, 안넣음 = BLINDING_CHOICES + assert wants_blinding({"blinding_concrete": 넣음}) is True + assert wants_blinding({"blinding_concrete": 안넣음}) is False + assert wants_blinding({}) is True # 정한 적 없음 = 넣음 + + +def _figure_labels(**options) -> list[str]: + sheet = { + "key": "masonry_wet", + "title": "돌쌓기(찰)", + "type_id": "masonry_wet", + "height_m": 2.0, + "options": {"height_m": 2.0, "back_len_cm": 45, **options}, + } + entities, _height = build_figure( + "d1", sheet, "layer", (0.0, 0.0), "#000000", "#111111", "#222222" + ) + return [str((item.get("shapeData") or {}).get("label") or "") for item in entities] + + +def test_물빼기_주석이_구조물_값을_쓴다() -> None: + labels = _figure_labels(weep_hole_diameter_mm=75, weep_hole_area_m2=3) + 물구멍 = [text for text in labels if "물구멍" in text] + assert 물구멍, labels + assert "Ø75" in 물구멍[0] and "3㎡당" in 물구멍[0], 물구멍[0] + + +def test_안_정하면_국가기준_기본이_뜬다() -> None: + 물구멍 = [text for text in _figure_labels() if "물구멍" in text] + assert 물구멍 and "Ø50" in 물구멍[0] and "2㎡당" in 물구멍[0], 물구멍 + + +def test_여기서_고치기_단추가_고정_액션_줄에_있다() -> None: + """확정 6 ㉮ — 표는 CAD 안이라 단추를 못 달아 **고정 액션 줄**에 둔다(값 복사본 없음).""" + page = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8") + assert '"여기서 고치기"' in page + assert "confirmButtonRow.append(specJumpButton" in page + # 표준도 장에서만 뜬다 — 다른 도면에는 갈 곳이 없다. + assert "specJumpButton.hidden = false" in page + assert "specJumpButton.hidden = true" in page + # 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2). + form = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_StandardSpec.ts").read_text( + encoding="utf-8" + ) + assert "blinding_concrete" in form diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index ed13f663..53c8bbe5 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -19,7 +19,10 @@ REPO = _read("B06_Section/B06_Section_Repository.py") B06_ROUTER = _read("B06_Section/B06_Section_Router.py") REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts") B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts") -B06_PAGE = _read("B06_Section/B06_Section_UI_Page.ts") +# 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). +B06_PAGE = _read("B06_Section/B06_Section_UI_Page.ts") + _read( + "B06_Section/B06_Section_UI_Page_Design_Sync.ts" +) def test_workflow_route_context_has_no_confirmed_filter():