Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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`)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user