Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 09:53:55 +09:00
15 changed files with 636 additions and 238 deletions
@@ -924,6 +924,17 @@
"required": false, "required": false,
"phase": "detail", "phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" "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, "required": false,
"phase": "detail", "phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" "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, "required": false,
"phase": "detail", "phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" "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, "required": false,
"phase": "detail", "phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" "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`)"
} }
] ]
}, },
+23 -232
View File
@@ -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 { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
@@ -23,36 +24,20 @@ import {
type StandardCrossSection, type StandardCrossSection,
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import { import {
bermSpansFromStructures,
confirmCurrentSections, confirmCurrentSections,
createCutSlopeStore, createCutSlopeStore,
createRockBoundaryStore, createRockBoundaryStore,
readBermSpans,
saveCurrentSections, saveCurrentSections,
writeBermSpans,
type SectionPersistContext, type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist"; } from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; 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 { import {
readStructurePick, readStructurePick,
writeStructurePick, writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick"; import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View"; import { createSectionView } from "./B06_Section_UI_Section_View";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import { import {
applyPipeOptionsToCache, applyPipeOptionsToCache,
@@ -226,142 +211,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm); attachCollapsible(leftForm);
/** // 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), const {
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. handleDesignChange,
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. recomputeIfRock,
*/ reconcileStaleDesigns,
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> { syncBermSpans,
if (!projectId || currentRouteId === null || !sectionDetail) return; applyPanelToAll,
const target = sectionDetail.cross_sections.find( } = createDesignSync({
(section) => Math.abs(section.chainage_m - chainageM) < 0.01, projectId,
); routeId: () => currentRouteId,
if (!target) return; detail: () => sectionDetail,
view: () => sectionView,
// (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<void> {
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<string, number>;
curve_radii?: Record<string, number>;
};
}
| 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<StructureInstance>): 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 });
}
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> { async function refreshDetailForStructures(): Promise<void> {
@@ -418,28 +280,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
showToast(L("B06_View_Apply_Success"), "success"); showToast(L("B06_View_Apply_Success"), "success");
} }
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
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 분리). // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({ const rockStore = createRockBoundaryStore({
sessionKey: () => stateKey("rockb", projectId, currentRouteId), sessionKey: () => stateKey("rockb", projectId, currentRouteId),
@@ -493,62 +333,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
); );
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
/** // 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
* 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 const gradeEditFor = createGradeEdit({
* 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcileStale…`), routeId: () => currentRouteId,
* 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). detail: () => sectionDetail,
*/ view: () => sectionView,
let gradeStore: ProfileEditStore | null = null; reconcile: () => reconcileStaleDesigns({ force: true }),
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<Parameters<typeof sectionView.setGradeEdit>[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();
},
};
};
sectionView.setGradeEdit(gradeEditFor); sectionView.setGradeEdit(gradeEditFor);
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
@@ -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<void>;
recomputeIfRock: (chainageM: number) => void;
reconcileStaleDesigns: (options?: { force?: boolean }) => Promise<void>;
syncBermSpans: (structures: ReadonlyArray<StructureInstance>) => void;
applyPanelToAll: () => Promise<void>;
}
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
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<void> {
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<string, number>;
curve_radii?: Record<string, number>;
};
}
| 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<StructureInstance>): 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<void> {
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,
};
}
@@ -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<Parameters<SectionViewController["setGradeEdit"]>[0]>;
export interface GradeEditContext {
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
view: () => SectionViewController;
/** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */
reconcile: () => Promise<void>;
}
/**
* 계획선 편집(▲/▼) — 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;
}
@@ -279,6 +279,7 @@ export function putStandardSheetSpec(
fill_concrete_mpa: string | null; fill_concrete_mpa: string | null;
thickness_top_m: string | null; thickness_top_m: string | null;
thickness_bottom_m: string | null; thickness_bottom_m: string | null;
blinding_concrete: string | null;
}, },
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, { return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
@@ -37,7 +37,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_text_entity, _text_entity,
polyline_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 ( from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M, WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M, WALL_BLINDING_WIDTH_M,
@@ -223,6 +227,9 @@ def build_figure(
entities.append(base) 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 weep_y = height_m * 0.5
entities.append( entities.append(
_line_entity( _line_entity(
@@ -252,7 +259,7 @@ def build_figure(
"left", "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), (slope * weep_y + top_t + 0.16, weep_y),
"left", "left",
), ),
@@ -35,6 +35,7 @@ EDITABLE_KEYS: tuple[str, ...] = (
"fill_concrete_mpa", "fill_concrete_mpa",
"thickness_top_m", "thickness_top_m",
"thickness_bottom_m", "thickness_bottom_m",
"blinding_concrete",
) )
#: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). #: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`).
@@ -59,6 +60,7 @@ FIELD_LABELS: dict[str, str] = {
"fill_concrete_mpa": "채움 강도", "fill_concrete_mpa": "채움 강도",
"thickness_top_m": "상부 두께", "thickness_top_m": "상부 두께",
"thickness_bottom_m": "하부 두께", "thickness_bottom_m": "하부 두께",
"blinding_concrete": "버림 콘크리트",
} }
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」. #: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
@@ -68,6 +70,10 @@ STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행")
#: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준. #: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준.
FILL_CONCRETE_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]: def _clean_slope(value: Any) -> tuple[float | None, str | None]:
"""전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다.""" """전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다."""
@@ -134,6 +140,7 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
for key, choices in ( for key, choices in (
("stone_coeff_basis", STONE_COEFF_CHOICES), ("stone_coeff_basis", STONE_COEFF_CHOICES),
("fill_concrete_mpa", FILL_CONCRETE_CHOICES), ("fill_concrete_mpa", FILL_CONCRETE_CHOICES),
("blinding_concrete", BLINDING_CHOICES),
): ):
if key not in spec: if key not in spec:
continue continue
@@ -75,6 +75,7 @@ class StandardSheetSpecRequest(BaseModel):
fill_concrete_mpa: str | None = None fill_concrete_mpa: str | None = None
thickness_top_m: float | str | None = None thickness_top_m: float | str | None = None
thickness_bottom_m: float | str | None = None thickness_bottom_m: float | str | None = None
blinding_concrete: str | None = None
@router.put("/{project_id}/standard-sheets/spec") @router.put("/{project_id}/standard-sheets/spec")
+22 -1
View File
@@ -213,13 +213,33 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const infoPanelHost = document.createElement("div"); const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host"; 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<HTMLElement>("select, input")?.focus();
},
});
specJumpButton.title = "이 표의 값을 정하는 제원 칸으로 갑니다.";
specJumpButton.hidden = true;
const showStandardSpec = (drawing: DesignDrawingItem): void => { const showStandardSpec = (drawing: DesignDrawingItem): void => {
const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1; const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1;
const sheet = standardSheets[index]; const sheet = standardSheets[index];
if (!projectId || !sheet) { if (!projectId || !sheet) {
infoPanelHost.replaceChildren(); infoPanelHost.replaceChildren();
specJumpButton.hidden = true;
return; return;
} }
specJumpButton.hidden = false;
// 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다. // 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다.
const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null; const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null;
infoPanelHost.replaceChildren( infoPanelHost.replaceChildren(
@@ -259,6 +279,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
showStandardSpec(drawing); showStandardSpec(drawing);
return; return;
} }
specJumpButton.hidden = true; // 표준도 장이 아니면 갈 곳이 없다
if (drawing.kind !== "cross") { if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren(); infoPanelHost.replaceChildren();
return; return;
@@ -658,7 +679,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
const confirmButtonRow = document.createElement("div"); const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row"; confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.append(frameEditor.button, confirmButton); confirmButtonRow.append(specJumpButton, frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow); confirmActions.append(frameEditor.banner, confirmButtonRow);
drawingPanel.append(infoPanelHost, confirmActions); drawingPanel.append(infoPanelHost, confirmActions);
@@ -21,6 +21,8 @@ const FOUNDATIONS = ["기초유", "기초버림"] as const;
const COEFF_BASES = ["품셈", "실무 관행"] as const; const COEFF_BASES = ["품셈", "실무 관행"] as const;
/** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */ /** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */
const FILL_MPA = ["180", "210"] as const; const FILL_MPA = ["180", "210"] as const;
/** 확정 8-2·14 — 기본은 넣음(KDS 100㎜), 빼고 싶으면 「안 넣음」. 문구가 곧 서버가 읽는 말이다. */
const BLINDINGS = ["넣음", "안 넣음"] as const;
/** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ /** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */
export interface StandardSheetSpec { export interface StandardSheetSpec {
@@ -42,6 +44,7 @@ export interface StandardSpecResult {
fill_concrete_mpa: string | null; fill_concrete_mpa: string | null;
thickness_top_m: string | null; thickness_top_m: string | null;
thickness_bottom_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 foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —");
const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —"); const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —");
const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —"); const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —");
const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —");
const slope = document.createElement("input"); const slope = document.createElement("input");
slope.className = "b07-spec__input"; slope.className = "b07-spec__input";
@@ -156,6 +160,7 @@ export function buildStandardSpecPanel(
), ),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
); );
if (THICKNESS_TYPES.has(sheet.type_id)) { if (THICKNESS_TYPES.has(sheet.type_id)) {
panel.append( panel.append(
@@ -199,6 +204,7 @@ export function buildStandardSpecPanel(
foundation: foundation.value || null, foundation: foundation.value || null,
stone_coeff_basis: coeff.value || null, stone_coeff_basis: coeff.value || null,
fill_concrete_mpa: mpa.value || null, fill_concrete_mpa: mpa.value || null,
blinding_concrete: blinding.value || null,
thickness_top_m: THICKNESS_TYPES.has(sheet.type_id) thickness_top_m: THICKNESS_TYPES.has(sheet.type_id)
? topThickness.value.trim() || null ? topThickness.value.trim() || null
: null, : null,
@@ -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( B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
encoding="utf-8" 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): def _stale(profile_samples, sections, tol=1e-3):
+1 -1
View File
@@ -107,7 +107,7 @@ def test_선택이_저장_patch_까지_실린다() -> None:
for name in ( for name in (
"B06_Section_Cross_Design_Session.ts", # 캐시 칸 "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_Cross_Refresh.ts", # 브라우저 재계산이 읽는 자리
"B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch "B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch
): ):
@@ -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"]
@@ -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
@@ -19,7 +19,10 @@ REPO = _read("B06_Section/B06_Section_Repository.py")
B06_ROUTER = _read("B06_Section/B06_Section_Router.py") B06_ROUTER = _read("B06_Section/B06_Section_Router.py")
REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts") REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts")
B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.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(): def test_workflow_route_context_has_no_confirmed_filter():