diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 0ae205ba6..dd0a7f38c 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -55,6 +55,9 @@ export interface SpecFormHooks { /** 숫자 칸 빈 칸 안내. */ hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; onChange?: (c: SpecContainer, item: SpecItem) => void; + /** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 · + * [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */ + count?: (c: SpecContainer) => number | null | undefined; } export interface SpecForm { @@ -75,6 +78,8 @@ export interface SpecForm { /** 잇기 값이 바뀌었을 때 줄을 다시 그림. */ paintLinks: () => void; refresh: () => void; + /** 밖에서 정한 개수(`hooks.count`)가 바뀌었을 때 — 맞춰 그리고 바뀌었으면 change 알림. */ + recount: () => void; } export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm { @@ -122,11 +127,12 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { - input.type = "text"; - input.readOnly = true; - input.value = hooks.auto?.(c, item, state) || "—"; - input.title = "자동 계산 — 고치지 않음"; - return input; + // 보여 주는 칸(54-1) — 손 입력 칸 아님 + const shown = document.createElement("output"); + shown.className = "b05-structure__auto"; + shown.textContent = hooks.auto?.(c, item, state) || "—"; + shown.title = "자동 계산"; + return shown; } input.value = values[item.id] ?? ""; return stepper(input, item.step ?? 0.1); @@ -198,7 +204,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { rows.push({ key, set: row.set }); box.body.append(row.root); } - if (!c.required) { + if (!c.required && hooks.count?.(c) === undefined) { const remove = document.createElement("button"); remove.type = "button"; remove.className = "b05-route__irregular-btn"; @@ -222,13 +228,25 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return box.root; }; - function paint(): void { + /** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */ + const fit = (c: SpecContainer, list: Instance[]): boolean => { + const n = hooks.count?.(c); + if (c.required || n === undefined || n === null || n === list.length) return false; + for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null); + list.splice(n); + while (list.length < n) list.push({}); + return true; + }; + + function paint(quiet = true): void { rows.length = 0; const boxes: HTMLElement[] = []; + const fitted: SpecContainer[] = []; for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); + if (fit(c, list)) fitted.push(c); list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); - if (c.required || (c.max && list.length >= c.max)) continue; + if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; const add = document.createElement("button"); add.type = "button"; add.className = "b05-route__irregular-btn b05-structure__spec-add"; @@ -243,6 +261,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { boxes.push(add); } root.replaceChildren(...boxes); + // 개수가 밖에서 바뀌었으면 값 변경으로 알림(불러오기 때는 안 함) + if (!quiet) fitted.forEach((c) => hooks.onChange?.(c, c.items[0])); } return { @@ -268,6 +288,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const now = links?.get() ?? {}; rows.forEach((row) => row.set(now[row.key] ?? null)); }, - refresh: paint, + refresh: () => paint(), + recount: () => paint(false), }; } diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 64e398291..37061d3e9 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -2,8 +2,9 @@ * design_structure_pipe.ts * 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로. * - * 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) · - * 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이(선택 · 개수 = 유출 + * 「추가 기슭막이(단)」 · 54-1) · 도수로/돌붙임(선택 · 여러 개). 집수정 = 길이 · 전 · 후 없음(54-1). + * 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」 * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. @@ -40,10 +41,13 @@ function guardItems(keys?: Record>): SpecItem[] { ? { item: "structure", map: Object.fromEntries( - Object.entries(keys).map(([structure, k]) => [ - structure, - { key: k[field], choices: choices?.[structure] }, - ]), + // 키 없는 갈래 = 그 구조에 없는 칸(집수정 길이 · 전 · 후) — 가림 + Object.entries(keys) + .filter(([, k]) => k[field]) + .map(([structure, k]) => [ + structure, + { key: k[field], choices: choices?.[structure] }, + ]), ), } : undefined; @@ -121,13 +125,7 @@ export const PIPE_SPEC: StructureSpec = { ], }, ...guardItems({ - [BASIN]: { - form: "inlet_structure", - length: "inlet_basin_length_m", - height: "inlet_basin_height_m", - before: "inlet_basin_before_m", - after: "inlet_basin_after_m", - }, + [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), ], @@ -150,7 +148,7 @@ export const PIPE_SPEC: StructureSpec = { }, { id: "soil_add", - title: "흙막이 추가", + title: "흙막이", required: false, prefix: "soil_add", link: "gsoiladd", diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index d6432aabe..cf83b8aaa 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -5,6 +5,7 @@ * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). + * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만. * ========================================================================== */ import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; @@ -18,9 +19,16 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu sectionsSource = source; } -/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된 - * `pipe_length_m` · 그것도 없으면 null. */ -export function pipeLengthM(chainageM: number | null): number | null { +/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */ +let extraCountSource: ((section: CrossSection) => number) | null = null; +export function setPipeExtraCountSource(source: (section: CrossSection) => number): void { + extraCountSource = source; +} + +/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ +function ownerSection( + chainageM: number | null, +): { owner: CrossSection; sections: readonly CrossSection[] } | null { const sections = sectionsSource?.() ?? null; if (chainageM === null || !sections?.length) return null; const holders = sections.filter( @@ -30,11 +38,26 @@ export function pipeLengthM(chainageM: number | null): number | null { const owner = holders.reduce((a, b) => Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a, ); + return { owner, sections }; +} + +/** 관 길이(m) — 주인 측점 횡단 기하 값 · 못 세우면 저장된 `pipe_length_m` · 그것도 없으면 null. */ +export function pipeLengthM(chainageM: number | null): number | null { + const found = ownerSection(chainageM); + if (!found) return null; + const { owner, sections } = found; const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM; const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m; return typeof length === "number" && length > 0 ? length : null; } +/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */ +export function pipeExtraCount(chainageM: number | null): number | null { + const owner = ownerSection(chainageM)?.owner; + if (!owner) return null; + return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0; +} + /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index ebb992578..577ce2226 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -73,7 +73,7 @@ import { createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; -import { setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc"; import { bindDesignView, bindPipeOptions, @@ -286,7 +286,10 @@ export async function mountCrossView( ensureSampledWidth, projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), + onExtraCount: () => structuresPanel.facility.recount(), }); + // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 + setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); const sectionView = createSectionView( (chainageM, change) => { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 6cbbbb4d6..39413fa5d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -70,6 +70,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; +import { setPipeSectionsSource } from "./design_structure_pipe_calc"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). @@ -194,6 +195,8 @@ export async function mountProfileView( let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; + // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) + setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null); /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ let corridorVisible = true; let routeReady = false; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a1279c3b3..35ee3c8ea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -55,7 +55,11 @@ import { } from "../A00_Common/design/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; -import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc"; +import { + guardHeightHint, + pipeExtraCount, + pipeLengthM, +} from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, linkFields, @@ -215,6 +219,8 @@ export interface FacilityOptionsForm { paintLinks: () => void; /** 고른 관 측점 — 관 길이(자동) 계산에 씀. */ setChainage: (chainageM: number | null) => void; + /** 횡단 「추가 기슭막이(단)」이 바뀜 — 배관 흙막이 컨테이너 수를 다시 맞춤(54-1 · B06 이 부름). */ + recount: () => void; /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */ readOptions: () => Record; /** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존). @@ -265,8 +271,10 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); + return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); }, + // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) + count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), hint: (c, item, state) => { const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; @@ -475,6 +483,9 @@ export function createFacilityOptionsForm( chainage = chainageM; if (current === "pipe") pipeForm.refresh(); }, + recount() { + if (current === "pipe") pipeForm.recount(); + }, onInletStructureChange(handler) { inletStructureHandler = handler; }, @@ -494,7 +505,11 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; - if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups); + if (facility === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + pipeForm.write(PIPE_SPEC, options, pipeNames, groups); + } if (facility === "box_culvert") { // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index b2bdccdd4..598ab6754 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -26,10 +26,8 @@ export const FORM_OPTION_KEYS: Readonly> = { "pipe_diameter_mm", "inlet_type", "inlet_structure", - "inlet_basin_length_m", + // 집수정 길이 · 전 · 후는 폼에서 빠짐(54-1) — 옛 값 그대로 둠 "inlet_basin_height_m", - "inlet_basin_before_m", - "inlet_basin_after_m", ...revetKeys("inlet"), "outlet_type", ...revetKeys("outlet"), diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index e0913e2e7..895276fd4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -38,6 +38,8 @@ const ROW_GAP_PX = 2; const LANE_PADDING_PX = 2; /** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */ const OVERLAP_PX = 52; +/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ +const ETC_PILL_CHARS = 3; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -115,6 +117,53 @@ function rowAssignments( return rows; } +/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */ +function distinctHeads(names: ReadonlyArray): Map { + const letters = names.map((name) => [...name.replace(/\s+/g, "")]); + return new Map( + names.map((name, index) => { + const own = letters[index]; + let length = ETC_PILL_CHARS; + letters.forEach((other, otherIndex) => { + if (otherIndex === index) return; + let common = 0; + while (common < own.length && own[common] === other[common]) common += 1; + length = Math.max(length, common + 1); + }); + return [name, own.slice(0, length).join("")]; + }), + ); +} + +/** 표시 이름 · 알약 글(구조물 id 별). 표시 이름 = 좌측 목록과 같은 중복 번호(시점에 가까운 순 + * "이름 1/2…", 2026-08-19) — 툴팁 · 벌룬 제목이 씀. 알약 = 약호 + 그 번호. 기타는 약호가 모두 + * 「기타」라 손 이름 머리(distinctHeads) + 번호 · 빈 이름 = 「기타 N」(54-9). */ +export function structurePillTexts( + structures: ReadonlyArray, + typeMap: ReadonlyMap, +): Map { + const sorted = [...structures].sort( + (left, right) => structureAnchorM(left) - structureAnchorM(right), + ); + const rawNames = sorted.map( + (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, + ); + const numbered = numberDuplicateNames(rawNames); + const heads = distinctHeads([ + ...new Set(rawNames.filter((_name, index) => sorted[index].type_id === "etc")), + ]); + return new Map( + sorted.map((entry, index) => { + const name = numbered[index]; + const suffix = name.slice(rawNames[index].length); // " 1" 또는 "" + const head = etcName(entry) + ? heads.get(rawNames[index]) + : typeMap.get(entry.type_id)?.style?.abbr; + return [entry.structure_id ?? "", { name, pill: `${head ?? "?"}${suffix}` }]; + }), + ); +} + /** * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ @@ -127,28 +176,15 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const rows = rowAssignments(options.structures, options.x); - // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순 - // "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다. - const anchorSorted = [...options.structures].sort( - (left, right) => structureAnchorM(left) - structureAnchorM(right), - ); - const numberedNames = numberDuplicateNames( - anchorSorted.map( - (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, - ), - ); - const displayNames = new Map( - anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), - ); + // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 + // 모두 이 번호를 따른다. + const texts = structurePillTexts(options.structures, typeMap); options.structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); - const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게) - const displayName = displayNames.get(id) ?? rawName; - // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). - const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; + const { name: displayName, pill } = texts.get(id) ?? { name: structure.type_id, pill: "?" }; const anchorPx = options.x(structureAnchorM(structure)); const selected = options.selectedId === id; // 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다. @@ -185,8 +221,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement mark.style.top = `${top}px`; mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; - // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호. - mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`; + // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호 · + // 기타는 손 이름 머리(54-9). + mark.textContent = pill; mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index a5cfb5dc1..06ab97d63 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -21,6 +21,7 @@ import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { readGroupItems, readSummaryItem, @@ -110,8 +111,13 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.endFields.write(target.end_m ?? null, step); ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(shown.options); - ctx.setSummary(readSummaryItem(target.summary_item)); - ctx.groupBoxes.set(readGroupItems(target.group_items)); + const links = readGroupItems(target.group_items); + const summary = readSummaryItem(target.summary_item); + // 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6) + const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined; + const moved = !!(first && summary && !links[first]); + ctx.setSummary(moved ? null : summary); + ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links); ctx.groupBoxes.setShapes(target.options); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 7c9b09543..703d97378 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -242,7 +242,8 @@ export function paintStale( ? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p))) : []; box.replaceChildren(); - box.hidden = !stale.length; + // 상자는 모든 종류에서 숨김(54-1) — 값은 파일에 그대로 + box.hidden = true; if (!stale.length) return; const legend = document.createElement("legend"); legend.textContent = "지난 정의 값"; diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index 5f2a06e13..296417088 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -34,7 +34,7 @@ export function buildSummaryRow(): SummaryRow { const pick = document.createElement("button"); pick.type = "button"; pick.className = "b05-route__irregular-btn"; - pick.textContent = "고르기"; + pick.textContent = "선택"; pick.dataset.b05Summary = "pick"; const clear = document.createElement("button"); clear.type = "button"; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 9c32dfbbe..83e8ec038 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -154,7 +154,12 @@ function relocateSideRows(panelRoot: HTMLElement): void { if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot); } else { if (!slotUsable(target)) return; - moveRows(panelRoot, SIDE_ROWS, target); + // 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1) + const rows = + target === sideSlots.outlet + ? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "") + : SIDE_ROWS; + moveRows(panelRoot, rows, target); } // 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3). const group = target.closest("fieldset"); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index f34125645..0b9d4b225 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -61,6 +61,8 @@ export interface StationControlDeps { projectId: () => string | null; /** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */ onSaveError?: (message: string) => void; + /** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */ + onExtraCount?: () => void; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ @@ -366,6 +368,7 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }); refreshWithLinked(chainageM); + deps.onExtraCount?.(); }, syncCount: (chainageM, built, side = "outlet") => { const key = extraKey(chainageM, side); @@ -381,6 +384,14 @@ export function createStationControls(deps: StationControlDeps): StationControls persistRevetShifts(); persistExtraSpans(); persistExtraCounts(); + // 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1) + patchCachedDesign(chainageM, { + extra_wall_counts: { + outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0, + basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0, + }, + }); + deps.onExtraCount?.(); }, }; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index ad08fdb2e..3bda06669 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -141,6 +141,8 @@ export interface B06StructuresPanel { facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void; + /** 횡단 단 수가 바뀜 — 배관 흙막이 컨테이너 수를 맞춤(54-1). */ + recount: () => void; }; } @@ -494,6 +496,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), + recount: () => section.facility.recount(), }, overrideOptions: (chainageM, patch) => { const hit = pipeFacilities.find( diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index e92d13fab..6523e0926 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -19,7 +19,7 @@ from common_util.common_util_summary_item import GROUP_JOIN #: 종류 → 선택 컨테이너 (머리, 잇기, 제목) SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { "pipe": ( - ("soil_add", "gsoiladd", "흙막이 추가"), + ("soil_add", "gsoiladd", "흙막이"), ("chute_add", "gchuteadd", "도수로/돌붙임"), ), "ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),), diff --git a/docs/raw/조사/2026-10-06_54-5_속도_보안.md b/docs/raw/조사/2026-10-06_54-5_속도_보안.md new file mode 100644 index 000000000..0c804388d --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-5_속도_보안.md @@ -0,0 +1,142 @@ +# 54-5 B05 · B06 실시간 조작 속도 · 보안 재확인 (조사 · 보고만) + +- 기준 커밋: `6233daab1` (sub_laptop_8 = origin/main_laptop_1) +- 검증 프로젝트: `sub8_검증` (`c81b21f8…`) · 노선 224 · 저장 · 확정 누르지 않음(캐시만 · 브라우저 문맥 버림) +- 측정 도구: 헤드리스 Chrome(실 GPU — ANGLE D3D11 Intel Iris Xe) · 1509×1069 · 페이지 안 계측(Event Timing · Long Task · fetch/XHR 감쌈 · sessionStorage 쓰기 셈 · DOM 변화 셈 · WebGL clear 셈) + CDP 메인 스레드 시간 +- ⚠ ORCA 내장 탭이 10초마다 강제로 내려감(다른 창 탭 여럿 · 숨은 탭 10초 뒤 버림 · `tab switch --focus` 로도 같음) → 10초 넘는 측정 불가 → 측정 전용 헤드리스 Chrome 으로 대체(공용 브라우저 아님 · 화면 안 띄움 · ORCA 탭은 닫음) +- 통합 전 판 실행 대조는 안 함(워크트리 금지) → 통합 전 실측값은 커밋 글에 남은 수치로 대조(`9da48d5e7` 9-12 · `1abb456ef` 9-06) + +## ① 기준 커밋 + +속도 · 실시간 · 보안 작업 + +| sha | 날짜 | 내용 | +|---|---|---| +| 7fdbb343a | 08-23 | 코리도 편집 즉시 반영 · 프리즈 제거(프레임 갭 3603→100ms) | +| 08c0dc322 | 08-29 | 자동저장 제거 · 초기값 스냅샷 | +| a35c8ff35 | 09-03 | 계획선 편집 중 횡단 재계산을 브라우저로 — 서버 왕복 제거 | +| c2f681f9d · 8dca4bbfb | 09-04 | 휠 · 세로 맞춤 실시간(다시 그리기 대신 변환) | +| 3cd717d8f | 09-06 | [저장]·[확정] 정본 서버 재계산 | +| 9da3bce1b | 09-06 | 카드 버튼 즉시 저장 → 캐시 · `designRequestSeq` 제거(최신만 채택은 `B05_Profile_UI_Profile_Preview.ts:35-57` seq 가 맡음) | +| c0bfcba34 | 09-06 | 유토 배분을 서버 Node 로(사용자 확정 · 400ms 잠잠해지면 · 최신만) | +| df2dedcbc · 8e3c1f91f | 09-06 | 진입 중복 호출 제거 | +| 0b30f5b03 · 1abb456ef · 4df5d0a7e | 09-06 | 지형 색인 · 카드 강제 레이아웃 67→1회 · B06 진입 2.3~2.9s → 2.0s | +| 674777626 | 09-06 | 세션 확인 활동시각 쓰기 1분 1회 | +| **9da48d5e7** | 09-12 | **B06 ▲▼ — `refreshCards` 묶음 + 120ms 디바운스 · 한 번 누름 920→17~23ms · 최장 멈춤 180~229ms · 1.5초 누름 누적 멈춤 2,577→473ms** | + +좌측 패널 통합(통합 직전 기준 `cd26699d2` 09-29) + +| sha | 날짜 | 내용 | +|---|---|---| +| 439f605ba · 7760dcd77 · 2c520537b | 09-29 | 좌측 칸 등록부 · 설계 가게 한 곳 · 저장 한 줄기 | +| 38aae89b2 | 09-29 | 종단도 한 벌(`design_profile.ts`) — B06 위 종단도도 B05 것으로 | +| 8a9456579 | 09-29 | 고름 한 곳 | +| **dd8066275** | 09-29 | **옛 B06 계획선 편집 껍데기 삭제 — 120ms 디바운스 같이 사라짐** · 유토 뱃지 한 계산(전 측점 `structureAreaRows`) | +| 90a2cb9a1 | 09-29 | 한 페이지 뼈대 · 숨은 3D 그리기 멈춤 | +| **473d959d6** | 09-29 | **가운데 보기 전환 — 폼 한 벌 · `pipeReloadTimer` 추가** | +| 7f04bf961 · 767adc4a4 | 09-29~30 | 관 · 옹벽 칸 한 곳 | +| fc54a385d | 10-05 | 구조물 배치 칸을 정의대로 | +| 0a095c14b … 1a9055444 | 10-05~06 | 52장 명세 공용 틀 + 구조물별 폼 | + +## ② 수치 (지금 판) + +열: 입력→그림 = Event Timing 최대(ms · 괄호 = 그중 JS) · 긴 작업 = 50ms 넘는 멈춤(ms) · 요청 = 조작 하나에 나간 요청(올림/받음) · 캐시 쓰기 = sessionStorage setItem 수 · 좌측 = 좌측 패널 DOM 갈아 끼운 노드 수 · DOM = 전체 · 오버레이 = 잠금 오버레이 뜸 + +### B05 (종단 보기) + +| 조작 | 입력→그림 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM | 오버레이 | +|---|---|---|---|---|---|---|---| +| 쉼 3초(3D 켜짐) | — | 0 | 0 | 0 | 0 | 1 | 0 | +| 종단 패널 펼침 | 16~24 | 0 | 0~1(설정 PUT) | 0 | 0 | 0~17 | 0 | +| 구조물 고르기(목록 클릭) | 72~136 (38~54) | 0~57 | 0 | 1 | 95~114 | 106~147 | 0 | +| 좌측 칸 — 돌쌓기 높이 2→1.5 | 16 | 53 · 88 | 0 | 1 (2KB) | 68 | 84 | 0 | +| 좌측 칸 — 돌쌓기 측점 +m | 80~128 | 52~76 · 88 | 0 | 1 (2KB) | 72 | 88 | 0 | +| 좌측 칸 — 메모 6자 타자 | ≤24 | 0 | 0 | 0 | 0 | 0 | 0 | +| 좌측 칸 — 배관 관경 바꿈 | — | 59~64 · 68~93 | **1 · POST detail-basins 1KB/96KB · 211~478ms** | 1~2 | 52~119 | 143~245 | 0 | +| 측점 끌기(구조물 알약 40px) | **160~240 (57~87)** | 59~95 · 62~95 | 0 | 1~2 | 136~158 | 172~182 | 0 | +| 종단 점 ▲ 한 번 | 56~72 (12~22) | 53~78 | 1 · haul-plan 22KB/0.6KB(400ms 뒤) | 2 | 0 | 36 | 0 | +| 종단 점 ▲ 2.5초 길게(약 20회) | 32~56 | 최대 64 | 2 · haul-plan | **42** | 0 | 558 | 0 | + +- 쉼 3초 메인 스레드 시간 **1,484~1,600ms (≈50%)** · WebGL clear 700회(초당 ≈230) — 3D 가 가만히 있어도 매 프레임 그림 → 위 모든 B05 조작에 깔리는 바닥 부하 +- B05 진입: 5.9초 · 요청 21 · 받음 등고선 geojson **36.7MB** + 지형 미리보기 7.8MB + 등고선 7.0MB + 횡단 상세 2.6MB · 진입 때 `POST route/structures/migrate`(쓰기) · 긴 작업 합 832ms + +### B05 ↔ B06 넘김 + +| 조작 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM | +|---|---|---|---|---|---| +| B05 → B06 (첫 번째) | **752** · 214 · 403 · 72 · 57 · 154 (합 1,652) | 6 · pipe-points 96KB 다시 받음(1,036ms) · missing-stations 541ms · haul-plan | 10~11 | 392~525 | 2,402~2,560 | +| B06 → B05 | 198~231 · 104~114 | 1 (structures) | 2 | 207~390 | 863~1,094 | +| B05 → B06 (두 번째) | **1,231~1,242** · 76~98 · 81~125 · 137~148 | 2 (structures · pipe-points — 메인 스레드 막혀 1,308~1,341ms 대기) | 1 | 260~371 | 1,474~1,611 | + +- B06 쉼 3초 메인 스레드 5ms · WebGL 0 (숨은 3D 멈춤은 정상) +- 통합 전 B06 페이지 진입 긴 작업 합 1,604~1,728ms(1abb456ef) → 지금은 **같은 페이지 안 보기 바꿈인데도** 1.5~1.7초를 매번 다시 씀 + +### B06 (횡단 보기) + +| 조작 | 입력→그림 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM | +|---|---|---|---|---|---|---| +| 종단 점 ▲ 한 번 | 40 | **587** | 1 · haul-plan | 2 | 0 | 900 | +| 종단 점 ▲ 2.5초 길게 | pointerup **344** | **663 · 558 · 545 · 500** · 95 · 162 · 161 (합 2,684) | 4 · haul-plan(325~851ms) | 8 | 0 | 3,564 | +| 횡단 조정 줄 — 카드 암 경계 ▲ | 96 (63) | 68 | 0 | 1 | 67 | 118 | +| 구조물 고르기(돌쌓기) | 128 (74) | 80 | 0 | 2 | **247** | 279 | +| 구조물 고르기(배관) | 120 (75) | 82 | 0 | 2 | **286** | 342 | +| 좌측 칸 — 배관 관경 바꿈 | — | 109 · 54 | **2 · POST detail-basins + haul-plan** | 3 | 52 | 717 | +| 좌측 칸 — 돌쌓기 높이 2→1.5 | 16 | 0 | 0 | **0** | **0** | **0** | + +- **B06 ▲ 통합 전 대비(9da48d5e7 실측)**: 한 번 누름 최장 멈춤 180~229 → **587ms** · 길게 누름 누적 멈춤 473ms(1.5초) → **2,684ms(2.5초)** · 9-12 에 고친 것이 9-29 통합에서 되돌아감 +- B06 보기에서 돌쌓기 높이 칸을 바꿔도 캐시 쓰기 · 화면 변화 0 (B05 보기에선 즉시 반영) — 속도와 별개 흠 의심 · 확인 필요 +- 잠금 오버레이: 모든 조작에서 0 (규칙 맞음) + +## ③ 느린 원인 후보 (6) + +| # | 자리 | 왜 | 실측 근거 | 생긴 때 | +|---|---|---|---|---| +| S1 | `A00_Common/design/design_profile.ts:125` (`CROSS_PREVIEW_DEBOUNCE_MS = 16`) · `:367-392` · `B06_Section/B06_Section_UI_Section_View.ts:393-397` · `:287-291` · `B06_Section/B06_Section_Cross_Refresh.ts:129-140` | B06 ▲ 마다(초당 10회) 전 측점 재계산 → 바뀐 측점 카드 전부 다시 만들기 → 종단도 두 번 그림(`draw()` + `refreshCards`→`redrawProfile`) → 전 측점 `structureAreaRows` · 옛 B06 120ms 디바운스 사라짐 | B06 ▲ 587ms · 길게 500ms+ ×4 | dd8066275 · 38aae89b2 (통합) | +| S2 | `A00_Common/design/design_view_cross.ts:493-497` (`host.onShow`) | 횡단 보기 보일 때마다 구조물 다시 읽기 · 상세 다시 받기 · `reconcileStaleDesigns` 전 측점 재계산 · 카드 전부 다시 — 바뀐 것 없어도 | B05→B06 1,231ms 한 덩어리 · pipe-points 96KB 매번 | 473d959d6 (통합) | +| S3 | `B05_Profile/B05_Profile_UI_Viewer.ts:357-376` (`animate`) | 3D 가 쉬는 중에도 매 프레임 `renderer.render` — 메인 스레드 ≈50% 상시 점유 → 모든 B05 조작이 그 위에 얹힘 | 쉼 3초 CPU 1.5초 · clear 230/초 | 통합 전부터(90a2cb9a1 은 숨길 때만 멈춤) | +| S4 | `A00_Common/design/design_view_cross.ts:140-143` (`pipeReloadTimer`) → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:551-553` · `:402-407` · B05 쪽 `:612-613` | 관 옵션 하나 바꿀 때마다 서버 `detail-basins` 왕복(96KB 받음) → 배수유역 목록 다시 그림 → 관 변경 알림 다시 퍼뜨림 — 5장 「조작 중 네트워크 왕복 금지」 어긋남 | 관경 바꿈 POST 163~478ms · B06 DOM 717 | B05 쪽 통합 전부터 · B06 쪽 473d959d6 | +| S5 | `B06_Section/B06_Section_UI_Page_Pipe_Options.ts:144-148` | 관 옵션 바꿀 때 덮는 범위 측점마다 `refreshCard` → 측점마다 공용 종단도 전체 `draw()` — 9-12 에 고친 꼴(측점 수만큼 다시 그림)이 다시 생김 | 코드 읽기 · 관경 바꿈 긴 작업 109ms | 통합 때 커짐(무거운 공용 종단도로 바뀜) | +| S6 | `B05_Profile/B05_Profile_UI_Structures_Panel.ts:393-400` (`liveCommit`) → `B05_Profile_UI_Structures_Panel_Commit.ts:216-219` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:167-172` · `A00_Common/design/design_structure_form.ts:158-159` · `:214-234` · B06 쪽 `design_view_cross.ts:207-224` · `:447-462` | 고르기 · 칸 하나 · 알약 끌기마다 좌측 목록 통째 + 명세 폼 다시 만들기 · B06 은 목록을 두 번 다시 그리고 카드 전체 · 공용 종단도까지 | 고름 B05 95~114 · B06 247~286 노드 · 알약 끌기 160~240ms | 통합 · 52장 폼으로 무거워짐 | + +덧(조작 아님): B05 진입 등고선 geojson 36.7MB · 진입 때 `structures/migrate` 쓰기 · 유토 배분 `haul-plan` 은 조작마다 22KB 올림이나 사용자 확정 설계(서버 Node · 400ms 잠잠 · 최신만) + +## ④ 보안 (흠 11) + +인증 · 권한 공통: 모든 B04/B05/B06 라우터가 `protected_with_company`(`main.py:613-637`) → 세션 + 회사 + 프로젝트가 같은 회사인지(`common_util/common_util_auth.py:105` · `:179` · `:187`) · `project_id` UUID 형 · `route_id` 는 프로젝트와 대조 · 조작 중 자동저장 PUT 없음 + +| # | 무게 | 자리 | 흠 | +|---|---|---|---| +| H1 | **치명** | `B05_Profile/B05_Profile_Structures_Schema.py:175` · `B05_Profile/B05_Profile_Structures_Repository.py:123` · `M02_MasterTemplete/M02_Item_Snapshot.py:67-69` · `:155-165` · `:212-223` | 구조물 저장 때 `structure_id` 아무 글자 그대로 저장 → 저장 뒤 `safe_sync` 가 `base / structure_id` 폴더를 `shutil.rmtree` · 그 자리에 씀 → `"../../.."` · 절대경로로 서버 디스크 폴더 지우기 가능(회사 사용자 누구나) · 안전 이름 `FOLDER_PATTERN`(`:53`) 이 있는데 여기선 안 씀 (코드로 확인함) | +| H2 | 높음 | `B05_Profile/B05_Profile_Schema.py:66-67` · `:272` → `B05_Profile/B05_Profile_Engine_Solver.py:49-155` · `common_util/common_util_surface_sampler.py:134` · `B05_Profile/B05_Profile_Engine_Skeleton.py:284` | `filter_key` · `method` 자유 글자가 파일 이름에 들어감 → 경로 넘기(다른 프로젝트 지형 읽기 · 캐시 파일 쓰기) · confirm 은 값을 저장까지 | +| H3 | 중간 | `B05_Profile/B05_Profile_Structures_Repository.py:200-202` · `common_util/common_util_drainage_pipes.py:345-361` | **52장 명세 칸 서버 검사 없음** — 정의에 없는 키(동적 `{머리}_{n}_{항목}` 포함)는 `continue` 로 통과 · 관 `options` 그대로 저장 · 명세 규칙은 브라우저(`A00_Common/design/design_structure_spec.ts`)에만 · 파이썬 짝 없음 · 자유 글자 길이 제한 없음 | +| H4 | 중간 | `A00_Common/design/design_structure_ford_calc.ts:10` | 세월교 폭→최소 높이 규칙이 브라우저에만 · 서버는 받은 `ford_height_m` 그대로 — 5장 ⛔(브라우저만 계산) | +| H5 | 중간 | `B05_Profile/B05_Profile_Router_Corridor.py:131-160` · 호출 `B05_Profile_UI_Corridor.ts:208` · `:228` | 코리도 PUT 이 브라우저가 만든 기하 · hash 를 검사 없이 저장 · 첫 빌드 때 자동 PUT — 5장 ⛔ · 서버가 만들 수 있음(`B05_Profile_Corridor_Prebuild.py`) | +| H6 | 중간 | `B06_Section/B06_Section_Schema.py:198` · `B06_Section/B06_Section_Router_Confirm.py:124-169` | B06 저장 · 확정이 브라우저 `mass_haul` · 면적 · 관 길이를 먼저 적고 서버 재계산이 덮음 — 재계산 실패하면 로그만 · 브라우저 값 남음 · `standard_cross_section` 검사 없는 dict | +| H7 | 낮음 | `B06_Section/B06_Section_Schema.py:174-177` (대 `:44-47`) | `CrossSectionPatch` 의 `ground_type` 등 자유 글자 · 사전 키 · 목록 길이 제한 없음 | +| H8 | 낮음 | `B06_Section/B06_Section_Router.py:599` | 안 쓰는 `POST cross-design` 이 DB 에 바로 씀(저장 밖 쓰기 길) | +| H9 | 낮음 | `design_view_profile.ts:503` · `:459` · `B05_Profile_UI_Corridor.ts:208` | [저장] 밖 쓰기 — 등고선 간격 PUT · 진입 때 structures/migrate · 코리도 첫 PUT — 5장 어긋남 | +| H10 | 낮음 | `B06_Section_Router.py:431` · replan · cross-preview | `cross_half_width_m` 상한 없음 · `vertices` 길이 제한 없음 · 코리도 크기 검사가 전체 파싱 뒤 | +| H11 | 낮음 | `station_offsets` · `curve_radii` · 관 `chainage_m` · `B05_Profile_Repository.py:256` | NaN/Infinity 통과 · 관 측점이 노선 길이 검사 없음 · `surface_model_id` 조회에 프로젝트 조건 없음(좌표계 코드만 샘) | + +- 참고: 프로젝트 권한이 회사 단위뿐(멤버 · 읽기 전용 역할 없음) — 정책 결정 사항 +- 서버 정본 재계산: 계획선 저장(`B05_Profile_Router.py:387-402`) · B06 저장(`recompute_server_side`) 맞음 · 구조물은 등록부 대조만(재계산 아님) + +## ⑤ 고칠 갈래와 차례 (8 · 작은 것부터) + +| 차례 | 갈래 | 크기 | 예상 효과 | +|---|---|---|---| +| 1 | H1 — `structure_id` 를 `FOLDER_PATTERN` 으로 스키마 검사 + `sync_snapshots` 에서 한 번 더 | 몇 줄 | 서버 폴더 지우기 막음(최우선) | +| 2 | H2 — `filter_key` · `method` 를 Literal 화이트리스트 | 몇 줄 | 경로 넘기 막음 | +| 3 | S1 — 횡단 쪽 재계산 120ms 디바운스 되살림 · `onCrossDesignsUpdated` 받는 쪽에서 종단도 두 번째 그리기 뺌 · 숨은 보기 건너뜀 | 작음 | B06 ▲ 587ms → 9-12 수준(최장 ≈200ms · 길게 누적 ≈0.5초) | +| 4 | S5 · S4 — 관 옵션 측점 루프를 `refreshCards` 한 번으로 · `detail-basins` 서버 왕복을 [저장] 때로(조작 중은 로컬) | 작음~중간 | 관 옵션 조작 왕복 0 · 다시 그림 1회 | +| 5 | S2 — 횡단 보기 다시 보일 때 가게 판번호가 같으면 다시 읽기 · 전 측점 재계산 건너뜀 | 중간 | 보기 넘김 1.2~1.7초 → 바뀐 것만 | +| 6 | S3 — 3D 그리기를 「바뀔 때만」(카메라 움직임 · 장면 변경 때 render 요청) | 중간 | 쉬는 중 메인 스레드 ≈50% → ≈0 · B05 조작 전반 체감 | +| 7 | S6 — 고름 · 칸 바꿈 때 좌측 목록 통째 대신 바뀐 줄만 · 폼 다시 만들기 줄임 · B06 목록 두 번 그리기 없앰 | 중간 | 고름 B06 120ms → B05 수준 이하 | +| 8 | H3~H11 — 52장 명세 서버 clean(파이썬 짝 + 거울 시험) · 세월교 높이 서버 검산 · 코리도 서버 생성 · B06 재계산 실패 시 거절 · 안 쓰는 POST 삭제 · [저장] 밖 쓰기 정리 · 상한 · NaN 막기 | 큼 | 5장 「서버 정본」 · 입력 검사 채움 | + +## 남은 확인 + +- B06 보기 돌쌓기 높이 칸 바꿈 반영 0 (캐시 · 화면 모두) — 별도 흠 의심 +- B05 배관 관경 바꿈 뒤 고름이 풀린 일 1회(재현 불확실) +- 통합 전 판 실행 대조가 필요하면 브레인 결정(워크트리 필요) +- ORCA 내장 탭 10초 강제 내림 — 다른 창 탭 정리 또는 탭 보이게 두면 풀릴 수 있음 diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 2088220e5..687660ac0 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 12, + "판": 13, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -3517,6 +3517,145 @@ "옵션": [], "형상": {} } + ], + "retaining_wall": [ + { + "id": "gretaindetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "form", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2" + ], + "형상": {} + } + ], + "masonry_wet": [ + { + "id": "gmasonwetdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "masonry_dry": [ + { + "id": "gmasondrydetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "soil_guard": [ + { + "id": "gsoilgdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "form", + "side", + "tiers", + "lift_m", + "shift_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "weep_hole_diameter_mm", + "weep_hole_area_m2" + ], + "형상": {} + } + ], + "boulder_masonry": [ + { + "id": "gboulderdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "stone_cm", + "bond", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "berm": [ + { + "id": "gbermdetail", + "이름": "상세 내용", + "옵션": [ + "length_m", + "before_m", + "after_m", + "width_m", + "interval_m", + "slope_deg" + ], + "형상": {} + } ] }, "배치": {}, diff --git a/resources/master_template/구조물정의_이력/판_12.json b/resources/master_template/구조물정의_이력/판_12.json new file mode 100644 index 000000000..2088220e5 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_12.json @@ -0,0 +1,3524 @@ +{ + "판": 12, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "유입 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "유출 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "cross_underdrain", + "group": "A", + "name": "맹암거", + "placement": "point", + "style": { + "color": "#3aa0a8", + "abbr": "횡맹" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "culvert_height_m", + "label": "암거 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "evac_road", + "group": "E", + "name": "대피로", + "placement": "point", + "style": { + "color": "#2fa84f", + "abbr": "대피로" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "detail", + "label": "상세", + "input": "select", + "choices": [ + "대피소", + "정차/작업장", + "차돌림곳" + ], + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 상세 미정 — 대피로 자리만 잡음(대피소 · 정차/작업장 · 차돌림곳은 뒤에 고름)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "curb", + "group": "E", + "name": "경계석", + "placement": "point", + "style": { + "color": "#4fae5c", + "abbr": "경계석" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "hazard_sign", + "group": "E", + "name": "위험표시", + "placement": "point", + "style": { + "color": "#5fb83a", + "abbr": "위험" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "text", + "default": null, + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 범위를 못 셈 — 길이를 적어야 기준 측점 앞뒤로 구간이 섬" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 0 — 기준 측점이 곧 시작(길이 = 전 + 후 맞물림)" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "사토장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 B06 사토 배분이 이 사토장을 건너뜀" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "토취장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 자리만 잡음 — 부피는 설계자가 현장 조사로 손 입력" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "상세 종류 이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ], + "이름": { + "body.size": "본체 규격", + "body.size=2.0×2.0": "2.0 × 2.0", + "body.size=3.0×3.0": "3.0 × 3.0", + "body.size=사용자 입력": "사용자 입력", + "body.width": "가로 (m)", + "body.height": "세로 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "ford.count": "수량 (련)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_54_9_etc_pill.cjs b/resources/tester/helper_54_9_etc_pill.cjs new file mode 100644 index 000000000..4cb1a00d6 --- /dev/null +++ b/resources/tester/helper_54_9_etc_pill.cjs @@ -0,0 +1,95 @@ +/* 54-9 종단 알약 글 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소만 흉내). + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const marks = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts")); + +const typeMap = new Map([ + ["etc", { type_id: "etc", name: "기타", style: { abbr: "기타" }, options: [] }], + [ + "retaining_wall", + { type_id: "retaining_wall", name: "옹벽", style: { abbr: "옹" }, options: [] }, + ], +]); +const etc = (id, chainage, name) => ({ + structure_id: id, + type_id: "etc", + placement: "point", + chainage_m: chainage, + options: name === undefined ? {} : { name }, +}); +const wall = (id, chainage) => ({ + structure_id: id, + type_id: "retaining_wall", + placement: "point", + chainage_m: chainage, + options: {}, +}); +const run = (structures) => + Object.fromEntries( + [...marks.structurePillTexts(structures, typeMap)].map(([id, text]) => [id, text]), + ); + +const out = { + // 53-2 시나리오 — 같은 이름 둘 · 다른 이름 · 빈 이름 둘(빈칸 이름 = 빈 이름) + scenario: run([ + etc("e1", 40, "배수로정비"), + etc("e2", 60, "울타리"), + etc("e3", 80, ""), + etc("e4", 100, "배수로정비"), + etc("e5", 120, " "), + ]), + // 머리가 같은 다른 이름 — 갈릴 때까지 늘림 · 이름이 남의 머리면 그 이름 그대로 + prefix: run([ + etc("p1", 10, "콘크리트 블록"), + etc("p2", 20, "콘크리트 옹벽"), + etc("p3", 30, "배수로"), + etc("p4", 40, "배수로공"), + etc("p5", 50, "기타 시설"), + etc("p6", 60), + ]), + // 다른 종류 규칙은 그대로 — 약호 + 중복 번호 · 기타 하나 빈 이름 = 「기타」 + others: run([wall("w1", 10), wall("w2", 30), etc("x1", 20, "가로등"), etc("x2", 40)]), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py index c0cc03f99..87ce7e553 100644 --- a/resources/tester/test_52_1b_pipe_links.py +++ b/resources/tester/test_52_1b_pipe_links.py @@ -39,7 +39,7 @@ SOIL = { def test_컨테이너마다_한_건_잇기와_그_값(): item = {"type_id": "pipe", "options": {"pipe_diameter_mm": 1000, **SOIL}, "복사본": "pipe@40.0"} rows = container_rows(item, {"gsoiladd1": RV, "gchuteadd1": MS}) - assert [r["그룹"] for r in rows] == ["흙막이 추가 1", "흙막이 추가 2", "도수로/돌붙임 1"] + assert [r["그룹"] for r in rows] == ["흙막이 1", "흙막이 2", "도수로/돌붙임 1"] assert [r["복사본"] for r in rows] == [ "pipe@40.0__gsoiladd1", "pipe@40.0__gsoiladd2", diff --git a/resources/tester/test_52_structure_spec_pipe.py b/resources/tester/test_52_structure_spec_pipe.py index 3f9abdf5c..229b448b2 100644 --- a/resources/tester/test_52_structure_spec_pipe.py +++ b/resources/tester/test_52_structure_spec_pipe.py @@ -36,7 +36,7 @@ def test_컨테이너_다섯_필수_셋_선택_둘(out): ["pipe", "관", True], ["inlet", "관 보호공 (유입)", True], ["outlet", "관 보호공 (유출)", True], - ["soil_add", "흙막이 추가", False], + ["soil_add", "흙막이", False], ["chute_add", "도수로/돌붙임", False], ] @@ -58,7 +58,8 @@ def test_형태_선택지는_구조를_따름(out): def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out): back = out["back"] assert back["pipe_diameter_mm"] == 1000 - assert back["inlet_structure"] == "L" and back["inlet_basin_length_m"] == 2 + # 집수정 = 길이 · 전 · 후 칸 없음(54-1) — 폼이 안 적음(병합이 옛 값을 지킴) + assert back["inlet_structure"] == "L" and "inlet_basin_length_m" not in back assert back["outlet_revet_height_m"] == 2.5 # 표 01 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) assert "pipe_kind" not in back and "wing_wall_type" not in back diff --git a/resources/tester/test_54_6_slope_container.py b/resources/tester/test_54_6_slope_container.py new file mode 100644 index 000000000..81a7c8e93 --- /dev/null +++ b/resources/tester/test_54_6_slope_container.py @@ -0,0 +1,58 @@ +"""PLAN 54-6 — 사면안정(C군) 종류마다 컨테이너 「상세 내용」(정의 그룹) 하나. + +· 기준 측점(측점 범위) 밖 옵션 전부를 담음 · 옵션 키 · 차례 그대로 +· 잇기 = 그룹 id(`group_items`) — 종류 단위 잇기 줄은 가림(hidesTypeSummary = 그룹 있음) +""" + +import json +from pathlib import Path + +from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, load_structure_types +from M02_MasterTemplete.M02_Table_Fill import _with_group_items +from M02_MasterTemplete.StructOptions import M02_SO_Definition +from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of + +KINDS = { + "retaining_wall": "gretaindetail", + "masonry_wet": "gmasonwetdetail", + "masonry_dry": "gmasondrydetail", + "soil_guard": "gsoilgdetail", + "boulder_masonry": "gboulderdetail", + "berm": "gbermdetail", +} + + +def test_C군_종류마다_컨테이너_하나_옵션_전부(): + types = [t for t in load_structure_types() if t.group == "C"] + assert sorted(t.type_id for t in types) == sorted(KINDS) + groups = groups_of() + for item in types: + assert groups[item.type_id] == [ + { + "id": KINDS[item.type_id], + "name": "상세 내용", + "options": [o.key for o in item.options], + } + ] + + +def test_정의_검사_통과_판_이력(): + doc = json.loads(Path(DEFINITION_PATH).read_text("utf-8")) + assert M02_SO_Definition.check(doc) == [] + assert M02_SO_Definition.history_path(doc["판"] - 1).is_file() + + +def test_잇기는_컨테이너마다_집계_한_줄(): + link = {"layer": "system", "owner": None, "key": "SS000001", "name": "옹벽"} + rows = _with_group_items( + [ + { + "type_id": "retaining_wall", + "복사본": "s1", + "summary_item": None, + "group_items": {"gretaindetail": link}, + } + ] + ) + assert [r["복사본"] for r in rows] == ["s1", "s1__gretaindetail"] + assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용" diff --git a/resources/tester/test_54_9_etc_pill.py b/resources/tester/test_54_9_etc_pill.py new file mode 100644 index 000000000..0adec35b0 --- /dev/null +++ b/resources/tester/test_54_9_etc_pill.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨. + +• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」 +• 머리가 같은 다른 이름은 갈릴 때까지 늘림 +• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로 +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`). +B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_9_etc_pill.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_53_2_기타_다섯_알약이_서로_다르다(out): + pills = {key: value["pill"] for key, value in out["scenario"].items()} + assert pills == { + "e1": "배수로 1", + "e2": "울타리", + "e3": "기타 1", + "e4": "배수로 2", + "e5": "기타 2", + } + assert len(set(pills.values())) == 5 + + +def test_툴팁_이름은_손_이름_그대로(out): + names = [value["name"] for value in out["scenario"].values()] + assert names == ["배수로정비 1", "울타리", "기타 1", "배수로정비 2", "기타 2"] + + +def test_머리가_같은_다른_이름은_갈릴_때까지_늘린다(out): + pills = {key: value["pill"] for key, value in out["prefix"].items()} + assert pills == { + "p1": "콘크리트블", + "p2": "콘크리트옹", + "p3": "배수로", + "p4": "배수로공", + "p5": "기타시", + "p6": "기타", + } + + +def test_다른_종류_알약은_약호와_번호_그대로(out): + pills = {key: value["pill"] for key, value in out["others"].items()} + assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"} + + +def test_알약_레인이_글을_한_곳에서_받는다(): + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"), encoding="utf-8" + ) as handle: + text = handle.read() + assert "mark.textContent = pill;" in text + assert text.count("structurePillTexts(") == 2 # 정의 하나 · 레인 하나 diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py index 3370decec..c1a5b5c8b 100644 --- a/resources/tester/test_b05_facility_options_merge.py +++ b/resources/tester/test_b05_facility_options_merge.py @@ -94,7 +94,7 @@ def merged(tmp_path_factory) -> dict: def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None: options = merged["out"][0]["options"] assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음 - assert "inlet_basin_before_m" not in options # 폼 칸(24-4a) — 집수정을 걷으면 함께 지움 + assert options["inlet_basin_before_m"] == 1.5 # 집수정 전 — 폼에서 빠짐(54-1) · 옛 값 남음 assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김 # 표 01 명세(PLAN 52-1)가 뺀 옛 칸 = 폼이 모르는 칸 → 남음(지난 정의 값) assert options["inlet_basin_form"] == "□형(기본형)" diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 8dbe89ae8..eca79410f 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -80,7 +80,7 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "고르기", "해제") + assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "선택", "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] diff --git a/resources/tester/ui_checks/52-13_예외.js b/resources/tester/ui_checks/52-13_예외.js index f3c60e262..c027a8b5d 100644 --- a/resources/tester/ui_checks/52-13_예외.js +++ b/resources/tester/ui_checks/52-13_예외.js @@ -2,13 +2,13 @@ * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「배관 1」 「세월교 1」 · 로그인 상태 * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5213 = "단계";` 를 붙여 * cache → 저장 안 하고 B05 → B06 → B05 오가기(캐시 값 그대로) · 종류 바꾸기(배관 → BOX → 배관) - * pipe → 배관 길이 · 전 · 후 비움 + 흙막이 추가 셋 + 도수로/돌붙임 하나 → [저장] - * (orca reload) pipe_v → 다시 열기 · 흙막이 수 3 · 비운 칸 그대로 + * pipe → 배관 길이 · 전 · 후 비움 + 도수로/돌붙임 하나 → [저장](흙막이 = 유출 단 수 · [추가] 없음 · 54-1) + * (orca reload) pipe_v → 다시 열기 · 흙막이 수 = 저장 전 그대로 · 비운 칸 그대로 * bf → 258.12 를 BOX암거로(PUT) · 가로 비움 · 세월교 1 월류 폭 비움 → [저장] * (orca reload) bf_v → 다시 열기 · 오류 없음 * restore → 원래 관 지점으로 되돌림(PUT) · 초안 비움 * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) - * 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount 3 · inletBlank 빈 칸 셋 · restored true + * 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount pipe = pipe_v · soilAdd 0 · inletBlank 빈 칸 셋 · restored true * (배관 도수로/돌붙임 [추가] 는 하나 뒤에도 보임 — 「하나만」은 물넘이 포장 · 기타만 · 52-3 · 52-10) */ (async () => { @@ -66,7 +66,8 @@ await w(500); return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); }; - const items = () => [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem); + const items = () => + [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem); const point = async (lo, hi) => (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( (p) => p.chainage_m > lo && p.chainage_m < hi, @@ -89,21 +90,17 @@ } if (MODE === "pipe") { - sessionStorage.setItem("__5213_original", JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points)); + sessionStorage.setItem( + "__5213_original", + JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points), + ); await pick(/배관 1$/); await set("inlet", "structure", "기슭막이"); await set("inlet", "form", "돌망태"); await set("inlet", "length", ""); await set("inlet", "before", ""); await set("inlet", "after", ""); - for (let i = 0; i < 3; i++) { - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - } - for (let i = 0; i < 3; i++) { - await set("soil_add", "structure", "기슭막이", i); - await set("soil_add", "form", "콘크리트", i); - } + out.soilAdd = spec().querySelectorAll('[data-spec-add="soil_add"]').length; spec().querySelector('[data-spec-add="chute_add"]').click(); await w(200); await set("chute_add", "structure", "돌붙임"); @@ -180,7 +177,8 @@ clearDrafts(); sessionStorage.removeItem("__5213_original"); const now = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points; - out.restored = JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) === + out.restored = + JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) === JSON.stringify(original.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])); } diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index f0f72fee0..4153fc273 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -6,8 +6,8 @@ * orca eval --expression "window.__m521='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 · 관 길이 * orca eval --expression "window.__m521='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) - * 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 · - * soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 + * 기대 수치(edit): kinds 4(흙막이 = 유출 단 수 · [추가] 없음 · 54-1) · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · + * formSoil 6 · split 길이 7 · soilAddButtons 0 · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06) * 52-1b: edit 가 흙막이 추가 · 도수로/돌붙임 컨테이너도 이음 → verify 집계표 줄 = 처음 + 3 * (유입 잇기 1 · 컨테이너마다 1) @@ -120,33 +120,16 @@ out.splitAfter = ctl("inlet", "after").value; await set("inlet", "after", "4"); out.splitLength = ctl("inlet", "length").value; - // 선택 컨테이너 — 흙막이 추가 둘 → 첫째 뺌 · 도수로/돌붙임 하나 - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - spec().querySelector('[data-spec-add="soil_add"]').click(); - await w(200); - await set("soil_add", "structure", "기슭막이", 0); - await set("soil_add", "form", "바자", 0); - await set("soil_add", "structure", "기슭막이", 1); - await set("soil_add", "form", "콘크리트", 1); - out.soilAddTwo = spec().querySelectorAll('[data-spec-container="soil_add"]').length; - out.soilFormsSoilAdd = labels("soil_add", "form").length; - box("soil_add", 0).querySelector('[data-spec-remove="soil_add"]').click(); - await w(300); - out.soilAddAfterRemove = [...spec().querySelectorAll('[data-spec-container="soil_add"]')].map( - (b, i) => [ - b.querySelector("legend").firstChild.textContent, - ctl("soil_add", "form", i).value, - ], - ); + // 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나 + out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length; + out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length; spec().querySelector('[data-spec-add="chute_add"]').click(); await w(200); await set("chute_add", "structure", "돌붙임"); out.chuteChoices = labels("chute_add", "structure"); // 52-1b — 컨테이너마다 구조물 조합 잇기(집계표 한 줄씩) - await pickLink("soil_add"); await pickLink("chute_add"); - out.linkContainers = [linkName("soil_add"), linkName("chute_add")]; + out.linkContainers = [linkName("chute_add")]; // 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂 await pickLink("inlet"); out.linkInlet = linkName("inlet"); @@ -179,7 +162,7 @@ linkOutlet: linkName("outlet"), }; const b05 = shape(); - out.b05Length = ctl("pipe", "length").value; + out.b05Length = box("pipe").querySelector("output")?.textContent; // 보여 주는 칸(54-1) location.hash = "#/b06-section"; await until(() => location.hash.includes("b06"), 5000); await w(6000); @@ -187,7 +170,8 @@ const b06 = shape(); out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); if (!out.sameDrawing) out.diff = { b05, b06 }; - out.b06Length = ctl("pipe", "length").value; + out.b06Length = box("pipe").querySelector("output")?.textContent; + out.lengthInputs = box("pipe").querySelectorAll('[data-spec-item="pipe.length"] input').length; out.baseRows = Number(sessionStorage.getItem("__521_baseRows")); out.rows = await filledRows(); } diff --git a/resources/tester/ui_checks/54-1_배관_좌측패널.js b/resources/tester/ui_checks/54-1_배관_좌측패널.js new file mode 100644 index 000000000..5d303019e --- /dev/null +++ b/resources/tester/ui_checks/54-1_배관_좌측패널.js @@ -0,0 +1,134 @@ +/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) · + * 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 · + * 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N + * 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5) + * 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)" + * look → 지금 화면 배관 폼 수치(아무것도 안 바꿈) + * basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함) + * tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수 + * 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 · + * 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after · + * soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수 + */ +(async () => { + const MODE = window.__m541 || "look"; + // 고를 관(목록 이름) — 기본 「배관 1」 · 단이 서는 지형이 아니면 다른 관(window.__m541pipe) + const PIPE = window.__m541pipe || "배관 1"; + let station = ""; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c, i = 0) => spec()?.querySelectorAll(`[data-spec-container="${c}"]`)[i]; + const items = (c) => + [...(box(c)?.querySelectorAll("[data-spec-item]") ?? [])].map( + (n) => n.dataset.specItem.split(".")[1], + ); + const set = async (c, item, v) => { + const el = box(c).querySelector(`[data-spec-item="${c}.${item}"]`).querySelector("select"); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const pickPipe = async () => { + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + l.textContent.trim().endsWith(PIPE), + ); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(500); + }; + const visible = (n) => n.offsetParent !== null && !n.closest("[hidden]"); + const tierRow = () => + box("outlet")?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const look = () => { + const lengthField = box("pipe")?.querySelector('[data-spec-item="pipe.length"]'); + const picks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter(visible); + return { + view: location.hash, + pickTexts: [...new Set(picks.map((b) => b.textContent))], + pickCount: picks.length, + gorugi: [...document.querySelectorAll("button")].filter( + (b) => visible(b) && b.textContent.trim() === "고르기", + ).length, + staleShown: [...document.querySelectorAll(".b05-structure__stale")].filter(visible).length, + staleLegend: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.includes("지난 정의 값"), + ).length, + lengthInputs: lengthField?.querySelectorAll("input").length, + lengthText: lengthField?.querySelector("output")?.textContent, + inletStructure: box("inlet")?.querySelector('[data-spec-item="inlet.structure"] select') + ?.value, + inletItems: items("inlet"), + soilAddButton: spec()?.querySelectorAll('[data-spec-add="soil_add"]').length, + soilRemove: spec()?.querySelectorAll('[data-spec-remove="soil_add"]').length, + chuteAddButton: spec()?.querySelectorAll('[data-spec-add="chute_add"]').length, + soilBoxes: [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])].map( + (b) => ({ + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map( + (n) => n.dataset.specItem.split(".")[1], + ), + link: !!b.querySelector('[data-b05-summary="pick"]'), + }), + ), + outletReadout: box("outlet")?.querySelectorAll(".b06-structure-panel__struct--readout") + .length, + outletText: box("outlet")?.textContent.includes("자동 자리"), + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + extraGroupShown: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + ).length, + }; + }; + await pickPipe(); + station = [...document.querySelectorAll(".b05-route__irregular-list li")] + .find((l) => l.textContent.trim().endsWith(PIPE)) + .textContent.trim() + .split(PIPE)[0]; + const out = { mode: MODE, pipe: PIPE, station }; + if (MODE === "look") Object.assign(out, look()); + if (MODE === "basin") { + const was = box("inlet").querySelector('[data-spec-item="inlet.structure"] select').value; + await set("inlet", "structure", "집수정"); + out.basinItems = items("inlet"); + await set("inlet", "structure", "기슭막이"); + out.soilItems = items("inlet"); + await set("inlet", "structure", was); + out.back = items("inlet"); + out.was = was; + } + if (MODE === "tier+" || MODE === "tier-") { + // 유출 벽 고르기(이미 골라져 단 수 줄이 서 있으면 그대로) + if (!tierRow()) { + // 판정면(관 위로 올린 것) — 4+5 카드의 유출 벽 + const wall = [...document.querySelectorAll(".b06-chart__culvert-revet-hit")].find( + (e) => + e.closest(".b06-cross-card")?.textContent.startsWith(station) && + e.querySelector("title")?.textContent.startsWith("유출"), + ); + out.wallFound = !!wall; + wall?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await until(() => tierRow(), 5000); + await w(500); + } + out.before = tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent; + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + (MODE === "tier+" + ? btns.find((b) => b.textContent === "+") + : btns.find((b) => b.textContent === "-") + )?.click(); + await w(1500); + Object.assign(out, look()); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/54-6_사면안정_컨테이너.js b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js new file mode 100644 index 000000000..08d22c00c --- /dev/null +++ b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js @@ -0,0 +1,198 @@ +/* 54-6 — B05 · B06 좌측 「구조물 배치」 사면안정(C군) 종류마다 컨테이너 「상세 내용」 하나 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m546 = "단계";` 를 붙여 + * seed → 원래 구조물 목록을 sessionStorage 에 두고 흙막이 · 소단을 심고 옹벽에 옛 종류 단위 잇기를 얹음(PUT) · 그 뒤 orca reload + * check → C군 종류마다 컨테이너 1 · 그 안 칸 수 = 그리는 칸 수 · 밖 칸 0 · 잇기 1 · 종류 단위 줄 가림 · + * 옹벽 컨테이너 잇기 = 옛 값 — B05 에서 한 번(값을 sessionStorage 에 둠) · B06(#/b06-section) 에서 한 번(B05 = B06) + * save → 옹벽 [수정] · [저장] — 옛 값이 컨테이너 잇기(group_items)로 옮겨 저장 · 그 뒤 orca reload + * verify → 다시 연 옹벽 컨테이너 잇기 = 옛 값 · 파일 = group_items 만 + * restore → 원래 구조물 목록으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 값으로 다시 씀) + */ +(async () => { + const MODE = window.__m546 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const OLD = { layer: "system", owner: null, key: "SS000001", name: "54-6 옛 잇기" }; + const KINDS = { + retaining_wall: ["gretaindetail", /^10\+0~10\+10옹벽/], + masonry_wet: ["gmasonwetdetail", /^4\+0~4\+10돌쌓기\(찰\)/], + masonry_dry: ["gmasondrydetail", /^7\+0~7\+10돌쌓기\(메\)/], + soil_guard: ["gsoilgdetail", /흙막이/], + boulder_masonry: ["gboulderdetail", /^16\+0~16\+10큰돌쌓기/], + berm: ["gbermdetail", /소단/], + }; + const section = () => document.querySelector(".b05-structure-section"); + const selects = () => section().querySelectorAll(".b05-structure__grid select"); + const visible = (n) => n.offsetParent !== null; + const listItem = (re) => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + re.test(l.textContent.trim()), + ); + const open = async (re) => { + await until(() => listItem(re)); + const li = listItem(re); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!selects()[1].value) (li.querySelector("button") ?? li).click(); // 재선택 = 해제 — 한 번 더 + await until(() => !!selects()[1].value); + await w(400); + }; + const reset = async () => { + section().querySelector(".b05-route__irregular-actions button:last-child").click(); + await w(400); + }; + const b06 = location.hash.includes("b06"); + const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t])); + /** 그리는 칸 = 정의 옵션 중 이 화면 몫(B05 = b05 · B06 = 상세까지) · 켬 */ + const expected = (type) => + types[type].options + .filter((o) => o.visible !== false && o.enabled !== false) + .filter((o) => b06 || api.isB05Option(o)) + .map((o) => o.key); + const shape = (type) => { + const boxes = [...section().querySelectorAll("[data-b05-group]")].filter(visible); + const box = boxes[0]; + const inside = box ? [...box.querySelectorAll("[data-option-keys]")] : []; + return { + boxes: boxes.map((b) => `${b.dataset.b05Group}:${b.querySelector("legend")?.textContent}`), + fields: inside.map((n) => n.dataset.optionKeys), + want: expected(type), + loose: [...section().querySelectorAll("[data-option-keys]")].filter( + (n) => !n.closest("[data-b05-group]") && visible(n), + ).length, + links: box ? box.querySelectorAll(".b05-structure__summary").length : 0, + typeRows: [...section().querySelectorAll(".b05-structure__summary")].filter( + (n) => !n.closest("[data-b05-group]") && visible(n), + ).length, + link: box?.querySelector(".b05-structure__summary-name")?.textContent.trim(), + }; + }; + const ok = (type, s) => + s.boxes.length === 1 && + s.boxes[0] === `${KINDS[type][0]}:상세 내용` && + JSON.stringify(s.fields) === JSON.stringify(s.want) && + s.loose === 0 && + s.links === 1 && + s.typeRows === 0; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const out = { mode: MODE, page: location.hash }; + + if (MODE === "seed") { + const now = await api.fetchStructures(projectId); + sessionStorage.setItem("__546_original", JSON.stringify(now.structures)); + const add = (type_id, at, options) => ({ + structure_id: null, + type_id, + placement: "interval", + chainage_m: at, + start_m: at, + end_m: at + 10, + options: { length_m: 10, before_m: 0, after_m: 10, ...options }, + memo: "54-6", + }); + const next = now.structures.map((s) => + s.type_id === "retaining_wall" ? { ...s, summary_item: OLD } : s, + ); + next.push(add("soil_guard", 500, { height_m: 1.5 }), add("berm", 560, { width_m: 1 })); + await api.saveStructures(projectId, now.revision, next); + for (const k of Object.keys(sessionStorage)) + if (k.includes("draft:structures")) sessionStorage.removeItem(k); + const after = (await api.fetchStructures(projectId)).structures; + out.seeded = after.filter((s) => s.type_id in KINDS).map((s) => [s.type_id, !!s.summary_item]); + } + + if (MODE === "check") { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const r = {}; + for (const [type, [, re]] of Object.entries(KINDS)) { + await open(re); + r[type] = shape(type); + r[type].ok = ok(type, r[type]); + } + await reset(); + out.allOk = Object.values(r).every((s) => s.ok); + out.oldLink = r.retaining_wall.link; + out.read = r; + if (!b06) sessionStorage.setItem("__546_b05", JSON.stringify(r)); + else { + // B06 은 상세 칸까지 그림 — 같은 그리기 판정은 컨테이너 · 잇기 · 밖 칸 · 옛 값 + const strip = (x) => + JSON.stringify( + Object.fromEntries( + Object.entries(x).map(([k, s]) => [ + k, + [s.boxes, s.loose, s.links, s.typeRows, s.link, s.ok], + ]), + ), + ); + out.sameDrawing = strip(JSON.parse(sessionStorage.getItem("__546_b05") || "{}")) === strip(r); + } + } + + if (MODE === "save") { + await open(KINDS.retaining_wall[1]); + out.before = shape("retaining_wall").link; + section().querySelector(".b05-route__irregular-actions .is-primary").click(); // [수정] + await w(800); + out.saved = await save(); + const wall = (await api.fetchStructures(projectId)).structures.find( + (s) => s.type_id === "retaining_wall", + ); + out.file = { summary_item: wall.summary_item ?? null, group_items: wall.group_items ?? null }; + } + + if (MODE === "verify") { + await open(KINDS.retaining_wall[1]); + out.link = shape("retaining_wall").link; + await reset(); + const wall = (await api.fetchStructures(projectId)).structures.find( + (s) => s.type_id === "retaining_wall", + ); + out.fileOk = + !wall.summary_item && + JSON.stringify(wall.group_items) === JSON.stringify({ gretaindetail: OLD }); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__546_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const now = await api.fetchStructures(projectId); + await api.saveStructures(projectId, now.revision, original); + for (const k of Object.keys(sessionStorage)) + if (k.includes("draft:structures") || k.startsWith("__546_")) sessionStorage.removeItem(k); + const after = await api.fetchStructures(projectId); + const key = (list) => + JSON.stringify( + list.map((s) => [ + s.type_id, + s.start_m, + s.chainage_m, + s.end_m, + s.options, + s.summary_item ?? null, + s.group_items ?? null, + ]), + ); + out.restored = key(after.structures) === key(original); + } + return JSON.stringify(out); +})();