diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index d330bf546..050c3c7db 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -65,6 +65,7 @@ export function createDesignDock( divider.className = "b05-structure__divider"; const row = document.createElement("div"); row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions"; + row.dataset.helpRow = ""; const press = (name: keyof DesignDockActions) => (): void => { void Promise.resolve(actions[name].run()); diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 04ef5de0f..def0e97d1 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, - structureIdAtStation as structureIdAtStationOf, + structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { @@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => - stationId !== null && stationIdAtStructure(selectedStructureId) === stationId - ? selectedStructureId - : structureIdAtStationOf(stationId, irregularStations, structures); + structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); const stationIdAtStructure = (structureId: string | null): string | null => stationIdAtStructureOf(structureId, irregularStations, structures); @@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ setSelectedStructure(structureId: string | null) { + // 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2) selectedStructureId = structureId; - selectedStationId = stationIdAtStructure(structureId); + const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; + selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId); draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 4e7884a1d..d4c7a3d17 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -136,8 +136,9 @@ export function subscribeSelection(projectId: string | null, fn: SelectionListen } /** - * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로 - * 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 — + * 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1). * `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다. * 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지). */ @@ -157,7 +158,17 @@ export function selectDesign( before.structureId === selection.structureId && (options.partKey === undefined || options.partKey === before.partKey); if (same) return; - const key = options.partKey ?? before?.partKey; + // 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1) + const sameTarget = + before !== null && + before.chainageM !== null && + isNear(before.chainageM, selection.chainageM) && + before.structureId === selection.structureId; + // 빈 부재키("") = 부재를 풂 + const key = + options.partKey === "" + ? undefined + : (options.partKey ?? (sameTarget ? before?.partKey : undefined)); writeState( "structure-pick", { at: selection.chainageM, key, sid: selection.structureId ?? undefined }, diff --git a/A00_Common/design/design_structure_box.ts b/A00_Common/design/design_structure_box.ts new file mode 100644 index 000000000..c76585021 --- /dev/null +++ b/A00_Common/design/design_structure_box.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * design_structure_box.ts + * 횡단배수 › 박스암거 명세 — 사용자 구조물 표 02 고친 판(`docs/raw/구조물정의_표/02_횡단배수_박스암거.md`) 그대로. + * + * 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리): + * · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`) + * · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만 + * · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽) + * — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝) + * 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값). + * import 없음(꼴만) — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec"; + +export const BOX_CUSTOM = "사용자 입력"; +/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */ +export const BOX_CUSTOM_KEY = "body_custom"; +/** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */ +const BOX_PRESETS: Record = { + "2.0×2.0": [2, 2], + "3.0×3.0": [3, 3], +}; + +/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름. */ +const structureItem = (key: string): SpecItem => ({ + id: "structure", + label: "구조", + kind: "select", + key, + choices: [ + { value: "없음", label: "없음" }, + { value: "날개벽", label: "날개벽" }, + { value: "흙막이", label: "흙막이" }, + ], +}); + +/** 사용자 입력일 때만 서는 숫자 칸. */ +const customSide = (id: string, label: string, key: string): SpecItem => ({ + id, + label, + kind: "number", + step: 0.1, + by: { item: "size", map: { [BOX_CUSTOM]: { key } } }, +}); + +export const BOX_SPEC: StructureSpec = { + typeId: "box_culvert", + containers: [ + { + id: "body", + title: "본체", + required: true, + link: "gboxbody", + items: [ + { + id: "size", + label: "본체 규격", + kind: "select", + choices: [ + ...Object.keys(BOX_PRESETS).map((value) => ({ + value, + label: value.replace("×", " × "), + })), + { value: BOX_CUSTOM, label: BOX_CUSTOM }, + ], + }, + customSide("width", "가로 (m)", "body_width_m"), + customSide("height", "세로 (m)", "body_height_m"), + ], + }, + { + id: "inlet", + title: "유입 구조", + required: true, + link: "gboxin", + items: [structureItem("wing_in")], + }, + { + id: "outlet", + title: "유출 구조", + required: true, + link: "gboxout", + items: [structureItem("wing_out")], + }, + ], + // 표 02 에 없는 옛 날개벽 치수 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: ["in", "out"].flatMap((side) => + ["height_m", "length_m", "angle_deg"].map((field) => `wing_${side}_${field}`), + ), +}; + +/** 옛 저장 → 폼에 줄 옵션 — 설치 「있음」 = 날개벽(같은 뜻 · 저장하면 새 값으로 굳음). */ +export function boxOptionsIn( + options: Record, +): Record { + const out = { ...options }; + for (const key of ["wing_in", "wing_out"]) if (out[key] === "있음") out[key] = "날개벽"; + return out; +} + +/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면 + * 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */ +export function bodyOf(options: Record): Instance { + const width = Number(options.body_width_m); + const height = Number(options.body_height_m); + const preset = Object.entries(BOX_PRESETS).find( + ([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05, + ); + if (preset) return { size: preset[0] }; + if (!(width > 0) && !(height > 0) && options[BOX_CUSTOM_KEY] !== BOX_CUSTOM) return { size: "" }; + const side = (n: number): string => (n > 0 ? String(n) : ""); + return { size: BOX_CUSTOM, width: side(width), height: side(height) }; +} + +/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다 + * 비면 표시 키만(다시 열 때 「사용자 입력」). */ +export function bodyOptions(body: Instance | undefined): Record { + const preset = BOX_PRESETS[body?.size ?? ""]; + if (preset) return { body_width_m: preset[0], body_height_m: preset[1] }; + const blank = !(body?.width ?? "").trim() && !(body?.height ?? "").trim(); + return body?.size === BOX_CUSTOM && blank ? { [BOX_CUSTOM_KEY]: BOX_CUSTOM } : {}; +} diff --git a/A00_Common/design/design_structure_ford.ts b/A00_Common/design/design_structure_ford.ts new file mode 100644 index 000000000..55bb897e0 --- /dev/null +++ b/A00_Common/design/design_structure_ford.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * design_structure_ford.ts + * 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로. + * + * 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조 + * (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음. + * 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */ +const guard = (side: "in" | "out"): SpecChoice[] => [ + { value: "없음", label: "없음", also: { [`wing_${side}`]: "없음" } }, + { value: "날개벽", label: "날개벽", also: { [`wing_${side}`]: "있음" } }, + { value: "흙막이", label: "흙막이", also: { [`wing_${side}`]: "없음" } }, +]; + +const guardContainer = (id: "inlet" | "outlet", title: string, link: string): SpecContainer => ({ + id, + title, + required: true, + link, + items: [ + { + id: "structure", + label: "구조", + kind: "select", + key: `${id}_type`, + choices: guard(id === "inlet" ? "in" : "out"), + }, + ], +}); + +export const FORD_SPEC: StructureSpec = { + typeId: "ford_bridge", + containers: [ + { + id: "ford", + title: "세월교", + required: true, + link: "gford", + items: [ + { + id: "diameter", + label: "관경", + kind: "select", + key: "pipe_diameter_mm", + asNumber: true, + choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })), + }, + { id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 }, + { id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 }, + { id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 }, + ], + }, + guardContainer("inlet", "유입 구조", "gfordin"), + guardContainer("outlet", "유출 구조", "gfordout"), + ], + // 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "pipe_kind", + "wing_in_height_m", + "wing_in_length_m", + "wing_in_angle_deg", + "wing_out_height_m", + "wing_out_length_m", + "wing_out_angle_deg", + ], +}; + +/** 구조를 날개벽 설치 칸(옛 저장 · B06 조정창이 고치는 칸)에 맞춤 — 있음 = 날개벽 · 없음 = 날개벽이면 없음 + * (흙막이는 그대로) · 설치 칸이 없으면 구조 그대로. */ +export function withLegacyGuards( + options: Record, +): Record { + const out = { ...options }; + for (const [type, wing] of [ + ["inlet_type", "wing_in"], + ["outlet_type", "wing_out"], + ]) { + if (out[wing] === "있음") out[type] = "날개벽"; + else if (out[wing] === "없음" && (out[type] ?? "날개벽") === "날개벽") out[type] = "없음"; + } + return out; +} diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts new file mode 100644 index 000000000..a9ec865a0 --- /dev/null +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -0,0 +1,27 @@ +/* ============================================================================= + * design_structure_ford_calc.ts + * 세월교 명세 계산 — 월류 폭 ↔ 월류 높이 연동(PLAN 52-4). + * + * 높이 = 담당 유역 설계유량을 그 월류 폭으로 넘기는 필요 수심(Manning 개수로 · `fordSection` 그대로). + * 근거: Manning 공식 `resources/knowledge/technical_info/01_임도/03_계산정보/통수단면.md:23~33` + * (KDS 44 40 00 2.7.1~2.7.2) · 조도계수 0.017 = 같은 파일 :59(콘크리트 수로 보통 · 울진 물넘이) · + * 경사 10 % = 실무 수리계산서 역산값(`config_frontend.FORD_SLOPE` · 지식DB 수치 없음). + * 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음. + * 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함. + * 설계유량이 없으면 null = 손 입력. + * ========================================================================== */ + +import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; + +/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ +export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { + const section = fordSection(designFlowM3s, widthM); + return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; +} + +/** 높이 빈 칸 안내. */ +export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { + const need = fordHeightM(designFlowM3s, widthM); + if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; + return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력"; +} diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts new file mode 100644 index 000000000..c69e89719 --- /dev/null +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * design_structure_ford_pavement.ts + * 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로. + * + * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임). + * 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure`. + * 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { StructureSpec } from "./design_structure_spec"; + +export const FORD_PAVEMENT_SPEC: StructureSpec = { + typeId: "ford_pavement", + containers: [ + { + id: "slope", + title: "경사각도", + required: true, + link: "gfordpaveslope", + items: [ + { id: "angle", label: "경사각도 (°)", kind: "number", key: "ford_slope_deg", step: 0.1 }, + ], + }, + { + id: "chute", + title: "도수로/돌붙임", + required: false, + prefix: "ford_chute", + link: "gfordpavechute", + max: 1, + items: [ + { + id: "structure", + label: "구조", + kind: "select", + choices: [ + { value: "도수로", label: "도수로" }, + { value: "돌붙임", label: "돌붙임" }, + ], + }, + ], + }, + ], + // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], +}; diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index e6df8ec91..0ae205ba6 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -2,8 +2,9 @@ * design_structure_form.ts * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. * - * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] - * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만) + * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] · + * `max` 에 차면 [추가] 숨김 + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 @@ -19,6 +20,7 @@ import { linkOf, readState, solveSplit, + TEXT_MAX, writeOptions, type SpecContainer, type SpecItem, @@ -111,6 +113,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = now; return select; } + if (item.kind === "text") { + const input = document.createElement("input"); + input.type = "text"; + input.maxLength = TEXT_MAX; + input.value = values[item.id] ?? ""; + return input; + } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { input.type = "text"; @@ -167,7 +176,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return wrap; }); box.body.append(grid(...fields)); - if (c.required) box.body.append(slotOf(c.id)); + if (c.required) { + const slot = slotOf(c.id); + // B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1) + const prev = slot.closest("fieldset"); + if (prev?.classList.contains("is-active")) { + const legend = box.root.querySelector("legend")!; + legend.dataset.baseLabel = legend.textContent ?? ""; + legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent; + box.root.classList.add("is-active"); + } + box.body.append(slot); + } if (links) { const key = linkOf(c, index + 1); const row = buildSummaryRow(); @@ -208,7 +228,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); - if (c.required) continue; + if (c.required || (c.max && list.length >= c.max)) continue; const add = document.createElement("button"); add.type = "button"; add.className = "b05-route__irregular-btn b05-structure__spec-add"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index c72f84c0b..64e398291 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -8,10 +8,14 @@ * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. * 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`. - * import 없음 — 시험이 이 파일만 옮겨 돌림. + * import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림. * ========================================================================== */ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; +import { BOX_SPEC } from "./design_structure_box"; +import { FORD_SPEC } from "./design_structure_ford"; +import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement"; +import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -184,4 +188,11 @@ export const PIPE_SPEC: StructureSpec = { }; /** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ -export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; +export const STRUCTURE_SPECS: Readonly> = { + pipe: PIPE_SPEC, + box_culvert: BOX_SPEC, + ford_bridge: FORD_SPEC, + ford_pavement: FORD_PAVEMENT_SPEC, + pavement_concrete: PAVEMENT_SPEC, + etc: ETC_SPEC, +}; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts new file mode 100644 index 000000000..a3520dbf2 --- /dev/null +++ b/A00_Common/design/design_structure_road.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * design_structure_road.ts + * 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`). + * + * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. + * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. + * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) · + * 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */ +export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }]; + +export const PAVEMENT_SPEC: StructureSpec = { + typeId: "pavement_concrete", + containers: [ + { + id: "detail", + title: "상세 내용", + required: true, + link: "gpavedetail", + items: [ + { + id: "spec", + label: "포장사양", + kind: "select", + key: "pave_spec", + choices: PAVE_SPEC_CHOICES, + }, + { id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 }, + ], + }, + ], + // 표 08 에 없는 옛 상세 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + ], +}; + +export const ETC_SPEC: StructureSpec = { + typeId: "etc", + containers: [ + { + id: "detail", + title: "상세 내용", + required: false, + prefix: "etc_detail", + link: "getcdetail", + max: 1, + items: [{ id: "note", label: "비고", kind: "text" }], + }, + ], + retired: [], +}; diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 506858418..c5a99a1b5 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -3,8 +3,9 @@ * 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌. * * · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[] - * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개) - * · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후) + * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개 · `max` 면 그 수까지) + * · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) · + * 맞물림(길이 = 전 + 후) * · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래) * · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` · * 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터) @@ -31,7 +32,7 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - kind: "select" | "number" | "auto"; + kind: "select" | "number" | "text" | "auto"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; @@ -52,6 +53,8 @@ export interface SpecContainer { required: boolean; /** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */ prefix?: string; + /** 선택 컨테이너 개수 상한 — 차면 [추가] 숨김(옛 저장이 넘쳐도 다 보임). 없으면 여러 개. */ + max?: number; /** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */ link: string; items: SpecItem[]; @@ -64,6 +67,9 @@ export interface StructureSpec { retired: string[]; } +/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */ +export const TEXT_MAX = 100; + /** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */ export type Instance = Record; export type SpecState = Record; @@ -135,7 +141,8 @@ export function readState( return state; } -/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */ +/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김 · + * 다 빈 선택 인스턴스는 첫 칸을 빈 글로 남겨 자리를 지킴 — 잇기 · 집계 줄 · 다시 열기). */ export function writeOptions( spec: StructureSpec, state: SpecState, @@ -143,6 +150,7 @@ export function writeOptions( const out: Record = {}; for (const c of spec.containers) { (state[c.id] ?? []).forEach((values, index) => { + const before = Object.keys(out).length; for (const item of c.items) { const key = keyOf(c, item, index + 1, values); const raw = (values[item.id] ?? "").trim(); @@ -152,10 +160,13 @@ export function writeOptions( if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100; continue; } - out[key] = raw; + out[key] = item.kind === "text" ? raw.slice(0, TEXT_MAX) : raw; if (c.required) Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also); } + const first = c.items.find((item) => item.kind !== "auto"); + if (!c.required && first && Object.keys(out).length === before) + out[instanceKey(c, index + 1, first)] = ""; }); } return out; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index a2174fee4..49ec05934 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -179,6 +179,7 @@ export async function mountCrossView( focusChainage: (chainageM) => { if (!isWallSelecting()) focusStationAt(chainageM); }, + markStructure: (structureId) => sectionView.selectMark(structureId), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); @@ -202,11 +203,15 @@ export async function mountCrossView( view: () => sectionView, }); + let structuresRefreshSeq = 0; /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; + const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) try { - sectionDetail = await loadSectionDetail(projectId, currentRouteId); + const fresh = await loadSectionDetail(projectId, currentRouteId); + if (seq !== structuresRefreshSeq) return; + sectionDetail = fresh; // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { @@ -316,6 +321,7 @@ export async function mountCrossView( moveStructure: (structureId, toChainageM) => { structuresPanel.moveStructureTo(structureId, toChainageM); }, + selectStructure: (structureId) => structuresPanel.selectStructure(structureId), }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { @@ -353,6 +359,11 @@ export async function mountCrossView( const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); + // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 + // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) + const pipeAt = structuresPanel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + if (mark) sectionView.selectMark(mark); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -426,7 +437,16 @@ export async function mountCrossView( handoff, sectionDetail, { - focusStation: (stationId) => sectionView.focusStation(stationId), + // 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15). + focusStation: (stationId) => { + structuresPanel.prefer(handoff?.structureId ?? null); + sectionView.focusStation(stationId); + structuresPanel.prefer(null); + // 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1) + const sid = handoff?.structureId; + if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid)) + structuresPanel.selectStructure(sid); + }, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), @@ -485,6 +505,8 @@ export async function mountCrossView( // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. host.onShow(() => { if (currentRouteId === null) return; + // 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1) + appliedPick = null; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 8ca8ae223..6cbbbb4d6 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; +import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -123,6 +123,15 @@ export async function mountProfileView( profilePanel.setMinCoverPipes(pipes); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; + /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */ + const selectGraphAt = (id: string | null, chainageM: number | null): void => { + const bare = + id === null && chainageM !== null + ? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M) + : undefined; + if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`); + else profilePanel.setSelectedStation(id); + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -205,7 +214,7 @@ export async function mountProfileView( selectionSyncing = value; }, selectMarker: (id) => viewer.markers.selectStation(id), - selectGraph: (id) => profilePanel.setSelectedStation(id), + selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), @@ -299,10 +308,11 @@ export async function mountProfileView( : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); - profilePanel.setSelectedStation(id); + selectGraphAt(id, chainage); syncBasinHighlight(id); // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). profilePanel.drainage.selectPipeAtChainage(chainage); + viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) } finally { selectionSyncing = false; } @@ -315,6 +325,7 @@ export async function mountProfileView( selectionSyncing = true; try { profilePanel.setSelectedStructure(structure?.structure_id ?? null); + viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); } finally { selectionSyncing = false; } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 68faf6897..eb8496792 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -20,6 +20,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createHelpButton } from "@ui/ui_template_help"; import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; @@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. const actions = document.createElement("div"); actions.className = "ui-general-block__actions b02-proj__actions"; - actions.append(cancelBtn, submitBtn); + // 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞 + actions.append(createHelpButton(), cancelBtn, submitBtn); const card = createCard({ body: [grid, actions], raised: true }); const layout = createGeneralLayout({ pageClass: "b02-proj", diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts index f0e408303..062f21290 100644 --- a/B03_FileInput/B03_FileInput_UI_Guide.ts +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -1,77 +1,18 @@ /* ============================================================================= * B03_FileInput_UI_Guide.ts - * 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시). + * 파일 입력 좌측 패널 (2026-09-03 사용자 지시). * - * 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어 - * 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와 - * 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로 - * 옮기고, 본문은 고르는 자리만 남긴다. - * - * 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시): - * 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에 - * `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다. + * 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로 + * 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김. * ========================================================================== */ -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */ -function section( - titleKey: keyof typeof ui_locales, - itemKeys: (keyof typeof ui_locales)[], -): HTMLElement { - const group = document.createElement("section"); - group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section"; - - const title = document.createElement("h3"); - title.className = "b03-file__guide-legend ui-collapsible__title"; - title.textContent = L(titleKey); - - const list = document.createElement("ul"); - list.className = "b03-file__guide-list"; - for (const key of itemKeys) { - const item = document.createElement("li"); - item.textContent = L(key); - list.append(item); - } - - group.append(title, list); - return group; -} - -/** - * 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것. - * - * `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라 - * 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다 - * (2026-09-03 사용자 지시). - */ +/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */ export function createInputGuide(statusNote?: HTMLElement): HTMLElement { const guide = document.createElement("div"); guide.className = "b03-file__form"; - const howTo = section("B03_Guide_How_Title", [ - "B03_Guide_How_Drop", - "B03_Guide_How_Card", - "B03_Guide_How_Temp", - ]); - if (statusNote) howTo.append(statusNote); - guide.append( - section("B03_Guide_Files_Title", [ - "B03_Guide_Files_Route", - "B03_Guide_Files_Terrain", - "B03_Guide_Files_Optional", - ]), - howTo, - section("B03_Guide_Notes_Title", [ - "B03_Guide_Notes_Replace", - "B03_Guide_Notes_Crs", - "B03_Guide_Notes_LasFree", - ]), - ); - attachCollapsible(guide); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + if (statusNote) guide.append(statusNote); + guide.append(actions); return guide; } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 2f4b0d405..be437104d 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -310,10 +310,7 @@ } } -/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리. - 양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은 - `ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은 - `__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */ +/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */ .b03-file__form { display: flex; flex-direction: column; @@ -321,36 +318,6 @@ padding: var(--spacing-16); } -.b03-file__guide-group { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - margin: 0; - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} - -.b03-file__guide-legend { - margin: 0; - padding: 0 var(--spacing-8); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} - -/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은 - `--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */ -.b03-file__guide-list { - margin: 0; - padding-left: var(--spacing-16); - display: flex; - flex-direction: column; - gap: var(--spacing-4); - font-size: var(--text-body-sm); - color: var(--color-text-body); -} - .b03-file__group { display: flex; flex-direction: column; diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index f804f7e13..c2c8c88c8 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB import json import logging +import re from dataclasses import replace from pathlib import Path from typing import Any @@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_REVET, PipePoint, load_pipe_points_file, + parse_pipe_points, pipe_anchor_clearances, route_signature, ) @@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P return points +def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: + """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" + if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + return None + if pipe.facility == PIPE_FACILITY_PIPE: + values = pipe.options or {} + kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) + diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) + return f"{kind} D{diameter}" + definition = types.get(pipe.facility) + return definition.name if definition is not None else None + + +def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None: + """횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4). + + 라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은 + 다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인 + 측점만 고치고 다른 구조물 라벨은 건드리지 않는다. + """ + path = ( + project_root + / "B04_PreProcess" + / DRAINAGE_CACHE_DIRNAME + / DRAINAGE_EDITS_DIRNAME + / DRAINAGE_PIPE_POINTS_FILENAME + ) + try: + points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points")) + except (OSError, ValueError, AttributeError): + return + types = structure_type_map() + pipe_names = {definition.name for definition in types.values() if definition.managed_by} + for section in cross_sections: + label = section.get("structure") + if not isinstance(label, str) or not ( + label in pipe_names or re.fullmatch(r"\S+ D\d+", label) + ): + continue + chainage = float(section.get("chainage_m") or 0.0) + pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None) + if pipe is None: + continue + fresh = pipe_station_label(pipe, types) + if fresh is None: + section.pop("structure", None) + else: + section["structure"] = fresh + + def resolve_extra_stations( project_root: Path, pipes: list[PipePoint] ) -> tuple[tuple[float, str], ...]: @@ -224,18 +276,9 @@ def resolve_extra_stations( types = structure_type_map() derived: list[tuple[float, str]] = [] for pipe in pipes: - if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + label = pipe_station_label(pipe, types) + if label is None: continue - if pipe.facility == PIPE_FACILITY_PIPE: - values = pipe.options or {} - kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) - diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) - label = f"{kind} D{diameter}" - else: - definition = types.get(pipe.facility) - if definition is None: - continue - label = definition.name # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 # 기준 한 곳(관이 노선을 가로지르는 점형). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 8569f1579..fa25e4f4d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array new Option(`Ø${size}`, size)), - ); - const pipeRow = grid( - labeled("관종", pipeMaterial, "pipe_kind"), - labeled("관경", pipeDiameter, "pipe_diameter_mm"), - ); - // ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ── let linkSource: SpecLinks | null = null; let chainage: number | null = null; - const pipeForm = createSpecForm( - { - get: () => linkSource?.get() ?? {}, - pick: (key) => linkSource?.pick(key), - clear: (key) => linkSource?.clear(key), - set: (key, item) => linkSource?.set(key, item), + const specLinks: SpecLinks = { + get: () => linkSource?.get() ?? {}, + pick: (key) => linkSource?.pick(key), + clear: (key) => linkSource?.clear(key), + set: (key, item) => linkSource?.set(key, item), + }; + const pipeForm = createSpecForm(specLinks, { + auto: (_c, item) => { + if (item.id !== "length") return ""; + const length = pipeLengthM(chainage); + return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); }, - { - auto: (_c, item) => { - if (item.id !== "length") return ""; - const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); - }, - hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; - return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; - }, - onChange: (c, item) => { - if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) - inletStructureHandler?.(inletStructureOf()); - emit(); - }, + hint: (c, item, state) => { + const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, - ); + onChange: (c, item) => { + if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) + inletStructureHandler?.(inletStructureOf()); + emit(); + }, + }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) const inletSlot = pipeForm.slot("inlet"); const outletSlot = pipeForm.slot("outlet"); @@ -301,6 +289,26 @@ export function createFacilityOptionsForm( : "auto"; }; + // ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ── + // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) + let fordFlow: number | null = null; + const fordNeed = (): number | null => + fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); + const fordForm = createSpecForm(specLinks, { + hint: (c, item, state) => + c.id === "ford" && item.id === "height" + ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) + : "", + onChange: (c, item) => { + const need = c.id === "ford" ? fordNeed() : null; + const height = fordForm.state().ford?.[0]?.height ?? ""; + const low = height !== "" && !(Number(height) >= (need ?? 0)); + if (need !== null && (item.id === "width" || (item.id === "height" && low))) + fordForm.setValue("ford", "height", need.toFixed(2)); + emit(); + }, + }); + // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). @@ -351,65 +359,33 @@ export function createFacilityOptionsForm( return merged; }; - // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── - // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). - const boxSize = optionalSelect("— 안 정함 —", [ - ...BOX_SIZE_PRESETS.map(([label]) => label), - BOX_SIZE_CUSTOM, - ]); - const boxWidth = numberInput("0.1", "0"); - const boxHeight = numberInput("0.1", "0"); - const boxCustomRow = grid( - labeled("본체 폭 (m)", boxWidth, "body_width_m"), - labeled("본체 높이 (m)", boxHeight, "body_height_m"), - ); - const boxWrap = document.createElement("div"); - boxWrap.append( - grid(labeled("본체 규격 (m)", boxSize, ["body_width_m", "body_height_m"])), - boxCustomRow, - ); + // ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ── + const boxForm = createSpecForm(specLinks, { onChange: () => emit() }); + // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); + const wingInHome = wingInFields.slot.parentElement!; + const wingOutHome = wingOutFields.slot.parentElement!; const hiddenOptions = createHiddenOptions(root); - /** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의 - * 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */ - function syncBoxSize(): void { - const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value); - if (preset) { - boxWidth.value = String(preset[1]); - boxHeight.value = String(preset[2]); - } - // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로. - boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; - } + // ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도 · 도수로/돌붙임 · + // 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── + const paveForm = createSpecForm(specLinks, { onChange: () => emit() }); - // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`). - // 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7). - const ford = createFordFields(() => emit()); - - // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). - const saveNote = document.createElement("p"); - saveNote.className = "b05-structure__owner-note"; - saveNote.textContent = - "[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀"; + // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( - saveNote, - pipeRow, pipeForm.root, - ford.widthRow, - ford.slopeRow, - ford.countRow, - ford.summary, + fordForm.root, + paveForm.root, extraGroup.root, revetTopRow, revetInlet.root, revetOutlet.root, - boxWrap, + boxForm.root, wingInFields.root, wingOutFields.root, ); @@ -421,19 +397,17 @@ export function createFacilityOptionsForm( if (current === null) return; const isPipe = current === "pipe"; const isBox = current === "box_culvert"; - pipeRow.hidden = current !== "ford_bridge"; + const isBridge = current === "ford_bridge"; pipeForm.root.hidden = !isPipe; - boxWrap.hidden = !isBox; - // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). - const hasWing = isBox || current === "ford_bridge"; - wingInFields.root.hidden = !hasWing; - wingOutFields.root.hidden = !hasWing; - const isFord = current === "ford_pavement" || current === "ford_bridge"; - ford.countRow.hidden = current !== "ford_bridge"; - ford.widthRow.hidden = !isFord; - // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. - ford.slopeRow.hidden = current !== "ford_pavement"; - ford.summary.hidden = !isFord; + boxForm.root.hidden = !isBox; + fordForm.root.hidden = !isBridge; + // 날개벽 칸 = 옛 자리 — BOX(52-2) · 세월교(52-4)는 명세 유입 · 유출 구조 컨테이너 · B06 조정 행 자리만 그 컨테이너 끝으로 + const spec = isBox ? boxForm : isBridge ? fordForm : null; + wingInFields.root.hidden = true; + wingOutFields.root.hidden = true; + (spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot); + (spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot); + paveForm.root.hidden = current !== "ford_pavement"; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). @@ -445,12 +419,6 @@ export function createFacilityOptionsForm( // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; - if (isFord) ford.sync(); - if (isBox) syncBoxSize(); - if (hasWing) { - wingInFields.syncVisibility(); - wingOutFields.syncVisibility(); - } } function emit(): void { @@ -461,16 +429,9 @@ export function createFacilityOptionsForm( revetInlet.fields.onChange(emit); revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); - [ - pipeMaterial, - pipeDiameter, - boxSize, - boxWidth, - boxHeight, - ...wingInFields.inputs, - ...wingOutFields.inputs, - ...ford.inputs, - ].forEach((input) => input.addEventListener("change", emit)); + [...wingInFields.inputs, ...wingOutFields.inputs].forEach((input) => + input.addEventListener("change", emit), + ); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; @@ -500,8 +461,16 @@ export function createFacilityOptionsForm( bindLinks(links) { linkSource = links; pipeForm.paintLinks(); + boxForm.paintLinks(); + fordForm.paintLinks(); + paveForm.paintLinks(); + }, + paintLinks: () => { + pipeForm.paintLinks(); + boxForm.paintLinks(); + fordForm.paintLinks(); + paveForm.paintLinks(); }, - paintLinks: () => pipeForm.paintLinks(), setChainage(chainageM) { chainage = chainageM; if (current === "pipe") pipeForm.refresh(); @@ -515,7 +484,7 @@ export function createFacilityOptionsForm( setFacility(facility, options = {}, designFlow = null, definition = null) { current = facility; hiddenOptions.remember(options); - ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; if (facility === null) { syncVisibility(); return; @@ -523,33 +492,20 @@ export function createFacilityOptionsForm( const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). - pipeMaterial.value = text("pipe_kind"); - pipeDiameter.value = text("pipe_diameter_mm"); - if (facility === "pipe") { - const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); - pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); + 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 === "box_culvert") { + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 + boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups); + for (const [id, value] of Object.entries(bodyOf(options))) + boxForm.setValue("body", id, value); } - // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. - // 값이 없으면 「안 정함」. - const savedWidth = Number.parseFloat(text("body_width_m")); - const savedHeight = Number.parseFloat(text("body_height_m")); - const hasSaved = - Number.isFinite(savedWidth) && - savedWidth > 0 && - Number.isFinite(savedHeight) && - savedHeight > 0; - const preset = hasSaved - ? BOX_SIZE_PRESETS.find( - ([, width, height]) => - Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05, - ) - : undefined; - boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : ""; - boxWidth.value = hasSaved ? String(savedWidth) : ""; - boxHeight.value = hasSaved ? String(savedHeight) : ""; + if (facility === "ford_bridge") + fordForm.write(FORD_SPEC, withLegacyGuards(options), names, groups); + if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups); wingInFields.write(options); wingOutFields.write(options); - if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -557,31 +513,22 @@ export function createFacilityOptionsForm( syncVisibility(); }, setDesignFlow(designFlow) { - ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; + if (current === "ford_bridge") fordForm.refresh(); // 높이 안내 syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). - const putPipe = (): void => { - if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); - if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; - }; if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) } else if (current === "box_culvert") { - // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. - putNumber(options, "body_width_m", boxWidth.value); - putNumber(options, "body_height_m", boxHeight.value); - wingInFields.read(options); - wingOutFields.read(options); + // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) + Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); } else if (current === "ford_pavement") { - ford.read(current, options); + Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3) } else if (current === "ford_bridge") { - putPipe(); - ford.read(current, options); - wingInFields.read(options); - wingOutFields.read(options); + Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) } else if (current === "revetment") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 69906964b..42654c90d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -9,7 +9,6 @@ * ========================================================================== */ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend"; -import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json"; /* ── 유입구 구조 ──────────────────────────────────────────────────────────── */ @@ -245,39 +244,18 @@ export interface RevetSideGroup { slot: HTMLElement; } -/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호). - * ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */ -function sabangBasisLines(): string[] { - const { items, scope } = revetmentSabang; - const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`; - return [ - `높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`, - `계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`, - `둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`, - `뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`, - scope, - ]; -} - export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup { const box = group("기슭막이"); const kind = document.createElement("select"); kind.replaceChildren(new Option("기슭막이", "기슭막이")); // 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). const fields = createRevetmentFields(keys, { height: "" }); - const basis = document.createElement("p"); - basis.className = "b05-route__note"; - basis.replaceChildren( - ...sabangBasisLines().flatMap((line, index) => - index ? [document.createElement("br"), line] : [line], - ), - ); + // 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2) const slot = document.createElement("div"); slot.className = "b05-structure__adjust-slot"; box.body.append( grid(labeled("구조", kind), fields.formField), ...fields.rows, - basis, // 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과 // 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자). slot, @@ -461,18 +439,6 @@ export function createBasinSplitFields(length: HTMLInputElement): { }; } -/* ── BOX암거 ────────────────────────────────────────────────────────────── */ - -/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은 - * "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든 - * body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야 - * 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */ -export const BOX_SIZE_PRESETS: ReadonlyArray = [ - ["2.0×2.0", 2, 2], - ["3.0×3.0", 3, 3], -]; -export const BOX_SIZE_CUSTOM = "사용자 지정"; - /** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */ interface WingKeys { install: string; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 4e6afd657..b2bdccdd4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -17,12 +17,6 @@ const revetKeys = (side: "inlet" | "outlet"): string[] => ["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`); -const wingKeys = (side: "in" | "out"): string[] => [ - `wing_${side}`, - `wing_${side}_height_m`, - `wing_${side}_length_m`, - `wing_${side}_angle_deg`, -]; /** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */ export const FORM_OPTION_KEYS: Readonly> = { @@ -40,16 +34,21 @@ export const FORM_OPTION_KEYS: Readonly> = { "outlet_type", ...revetKeys("outlet"), ], - box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], - ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + // BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값) + box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"], + // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는 + // 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` + ford_pavement: ["ford_slope_deg"], + // 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임 ford_bridge: [ - "pipe_kind", "pipe_diameter_mm", "pipe_count", "ford_width_m", "ford_height_m", - ...wingKeys("in"), - ...wingKeys("out"), + "inlet_type", + "outlet_type", + "wing_in", + "wing_out", ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; @@ -57,6 +56,7 @@ export const FORM_OPTION_KEYS: Readonly> = { /** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */ export const FORM_OPTION_PREFIXES: Readonly> = { pipe: ["soil_add_", "chute_add_"], + ford_pavement: ["ford_chute_"], }; /** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */ diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 49cc98731..ef571f0bc 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -313,15 +313,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { async function loadStructures(): Promise { try { // 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07). - // · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군). - // · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서 - // 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다. + // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). + // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 + // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), ]); - const menuTypes = allTypes.filter((type) => type.enabled); - deps.panel().structures.setTypes(menuTypes); + deps.panel().structures.setTypes(allTypes, (type) => type.enabled); deps.profilePanel().setStructureTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index c22646ad4..eef902955 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -17,6 +17,7 @@ import { } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; +import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types"; /** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ export interface StructureMenuType { @@ -32,7 +33,9 @@ export function structureGroupMenuItems( types: ReadonlyArray, onPick: (typeId: string) => void, ): MapContextMenuItem[] { - const groups = [...new Set(types.map((type) => type.group))]; + const groups = [...new Set(types.map((type) => type.group))].filter( + (group) => !HIDDEN_PICK_GROUPS.has(group), + ); return groups.map((group) => ({ label: GROUP_LABELS[group] ?? group, children: types @@ -142,12 +145,39 @@ export function structureIdAtStation( if (!stationId.startsWith(prefix)) return null; const station = stations.find((entry) => irregularStationId(entry.id) === stationId); if (!station) return null; - const hit = structures.find( + const near = structures.filter( (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, ); + // 관 측점선의 짝은 관 알약(`pipe-*`)이다 — 같은 측점 다른 구조물(포장 등)을 켜면 그 알약을 + // 누를 때 해제로 읽혀 안 골라졌다(52-15). + const hit = near.find((item) => String(item.structure_id ?? "").startsWith("pipe-")) ?? near[0]; return hit?.structure_id ?? null; } +/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점 + * 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며 + * 고른 구조물 알약이 꺼졌다(53-2). */ +export function structureIdKeeping( + stationId: string | null, + selectedId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], + detail: { + longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> }; + } | null, +): string | null { + const selected = structures.find((item) => item.structure_id === selectedId); + const at = detail?.longitudinal.stations.find( + (entry) => entry.station_id === stationId, + )?.chainage_m; + const keeps = + stationId !== null && + !!selected && + (stationIdAtStructure(selectedId, stations, structures) === stationId || + (at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M)); + return keeps ? selectedId : structureIdAtStation(stationId, stations, structures); +} + /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ export function stationIdAtStructure( structureId: string | null, diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index c5fd228ec..d28add866 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -23,8 +23,8 @@ export interface SelectionSyncPorts { setSyncing: (value: boolean) => void; /** 3D 뷰어 마커 선택. */ selectMarker: (stationId: string | null) => void; - /** 종단 그래프 세로선 선택. */ - selectGraph: (stationId: string | null) => void; + /** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */ + selectGraph: (stationId: string | null, chainageM?: number | null) => void; /** 좌측 구조물 폼 선택(누가거리 기준). */ selectSidebar: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ @@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { ports.setSyncing(true); try { ports.selectMarker(id); - ports.selectGraph(id); + ports.selectGraph(id, chainageM); // 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로 // 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다 // (2026-08-18 임시 배치 자기 삭제 버그). diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index d4de1c8c0..585175a53 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -88,7 +88,8 @@ export function restoreStructurePick( selectStation(handoff.at); // 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을 // 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다). - if (!structures.hasSelection() && handoff.structureId) { + // id 가 있으면 측점이 연 관보다 먼저다(resolveSelection ① · 53-1) + if (handoff.structureId) { structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId }); } return structures.hasSelection(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts index 4c5ecf599..b3ef3f123 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -215,3 +215,25 @@ export function withDefinition< }; }); } + +/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음. + * 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */ +export const ROAD_TYPE = "evac_road"; +export const ROAD_DETAILS: Readonly> = { + refuge: "대피소", + work_yard: "정차/작업장", + turnaround: "차돌림곳", +}; + +/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */ +export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id]; + +/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */ +export function asRoad }>( + record: T, +): T { + const detail = ROAD_DETAILS[record.type_id]; + return detail + ? { ...record, type_id: ROAD_TYPE, options: { detail, ...record.options } } + : record; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts index e3e44d790..bce554803 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts @@ -48,7 +48,8 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition // 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로) const have = new Set([...control.options].map((item) => item.value)); for (const choice of state.choices) - if (!have.has(choice)) control.add(new Option(choice, choice)); + if (!have.has(choice)) + control.add(new Option(type.spec_names?.[`${option.key}=${choice}`] ?? choice, choice)); for (const item of control.options) { item.hidden = !!item.value && !state.choices.includes(item.value) && item.value !== control.value; @@ -78,7 +79,9 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition root .querySelectorAll(".is-rule-hidden") .forEach((node) => node.classList.remove("is-rule-hidden")); - root.querySelectorAll("option[hidden]").forEach((o) => (o.hidden = false)); + root + .querySelectorAll("option[hidden]:not([data-pick-hidden])") + .forEach((o) => (o.hidden = false)); paint(); }, }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index ed31f4ce4..3ea90331b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -9,23 +9,13 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; -export function field( - labelText: string, - input: HTMLElement, - basis?: string | null, -): HTMLLabelElement { +export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); caption.textContent = labelText; + // 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2) wrapper.append(caption, input); - // 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정). - if (basis) { - const note = document.createElement("small"); - note.className = "b05-structure__basis"; - note.textContent = basis; - wrapper.append(note); - } return wrapper; } @@ -68,6 +58,8 @@ interface OptionShape { export function optionControl( option: OptionShape, stored: string | number | undefined, + /** 선택지 보일 글(정의 이름 표 · 52-12) — 값은 그대로 */ + label: (choice: string) => string = (c) => c, ): HTMLInputElement | HTMLSelectElement { const value = stored === undefined || stored === null ? "" : String(stored); // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. @@ -78,7 +70,7 @@ export function optionControl( const kept = value && !option.choices.includes(value) ? [value] : []; const element = select([ ["", blank], - ...[...option.choices, ...kept].map((c) => [c, c] as [string, string]), + ...[...option.choices, ...kept].map((c) => [c, label(c)] as [string, string]), ]); element.value = value; if (tip) element.title = tip; diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index c0e28f3fc..7aa35eb32 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,14 +28,13 @@ import { createFacilityDefinition, type FacilityDefinition, } from "./B05_Profile_UI_Structures_Define_Form"; +import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec"; export interface StructuresFormElements { root: HTMLElement; body: HTMLElement; groupSelect: HTMLSelectElement; typeSelect: HTMLSelectElement; - /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ - ownerNote: HTMLElement; startFields: StationFields; anchorFields: StationFields; endFields: StationFields; @@ -112,13 +111,6 @@ export function buildStructuresForm(options: { typeRow.className = "b05-structure__grid"; typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); - // 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라 - // (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자: - // 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다. - const ownerNote = document.createElement("p"); - ownerNote.className = "b05-structure__owner-note"; - ownerNote.hidden = true; - // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-structure__grid"; @@ -164,17 +156,24 @@ export function buildStructuresForm(options: { set: groupBoxes.setOne, }); groupBoxes.watch(facilityOptions.paintLinks); + // 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음 + const structureSpec = createStructureSpecForm( + optionRow, + { get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne }, + options.onFacilityChange, + ); + groupBoxes.watch(structureSpec.paintLinks); const staleBox = document.createElement("fieldset"); staleBox.className = "b05-structure__group b05-structure__stale"; staleBox.hidden = true; body.append( typeRow, - ownerNote, positionRow, summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류) positionDivider, optionRow, + structureSpec.root, facilityOptions.root, staleBox, groupBoxes.root, @@ -186,7 +185,6 @@ export function buildStructuresForm(options: { body, groupSelect, typeSelect, - ownerNote, startFields, anchorFields, endFields, diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index f41119d99..71bdf7beb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -21,7 +21,8 @@ import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; -import { field, numberInput } from "./B05_Profile_UI_Structures_Fields"; +import { numberInput } from "./B05_Profile_UI_Structures_Fields"; +import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { GROUP_JOIN, @@ -126,19 +127,20 @@ export function createGroupBoxes(): GroupBoxes { ); for (const group of groups) { const keys = hiddenFormKeys(facility, group.options); - const box = document.createElement("fieldset"); - box.className = "b05-structure__group b05-structure__optgroup"; - box.dataset.b05Group = group.id; - const legend = document.createElement("legend"); - legend.textContent = group.name; - const body = document.createElement("div"); - body.className = "b05-structure__grid"; + // 상자 꼴 = 명세 컨테이너(`design_structure_form`)와 같은 조각 — 제목 · 몸통 · 격자 · 잇기 줄(52-18) + const box = boxOf(group.name); + box.root.classList.add("b05-structure__optgroup"); + box.root.dataset.b05Group = group.id; + const body = grid(); for (const scope of scopes) { const marked = [...scope.querySelectorAll("[data-option-keys]")]; // 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게 + // 자리 자체가 가려졌으면(안 쓰는 시설 폼) 그 칸도 숨은 것 — 같은 키 칸이 딸려 오지 않게(52-7) const shown = (node: HTMLElement): boolean => { - for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement) + for (let at: HTMLElement | null = node; at; at = at.parentElement) { if (at.hidden) return false; + if (at === scope) break; + } return true; }; const fits = marked.filter( @@ -177,23 +179,24 @@ export function createGroupBoxes(): GroupBoxes { handlers?.change?.(); }); shapeInputs.set(key, input); - body.append(field(shape.label, input)); + body.append(labeled(shape.label, stepper(input, 0.1))); } if (!body.childElementCount) { const none = document.createElement("p"); none.className = "b05-structure__owner-note"; - none.textContent = "옵션 없음 — 집계 항목만 지정"; + none.textContent = "옵션 없음 — 구조물 조합만 지정"; body.append(none); } const row = buildSummaryRow(); + row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합"; row.set(items[group.id] ?? null); row.bind({ pick: () => handlers?.pick(group.id), clear: () => handlers?.clear(group.id), }); rows.set(group.id, row); - box.append(legend, body, row.root); - boxes.push(box); + box.body.append(body, row.root); + boxes.push(box.root); bodies.push(body); } const stale = diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 6f3d43b25..ee699ec95 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -56,6 +56,12 @@ export function numberDuplicateNames(names: ReadonlyArray): string[] { }); } +/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */ +export function etcName(structure: StructureInstance): string | null { + const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : ""; + return name || null; +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -89,7 +95,9 @@ export function renderStructureList(params: StructureListParams): void { const displayNames = numberDuplicateNames( rows.map((row) => row.kind === "structure" - ? (typeMap.get(row.structure.type_id)?.name ?? row.structure.type_id) + ? (etcName(row.structure) ?? + typeMap.get(row.structure.type_id)?.name ?? + row.structure.type_id) : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), ), ); diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 6b96079a5..e0913e2e7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -19,7 +19,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; -import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const numberedNames = numberDuplicateNames( - anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id), + 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]]), @@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); - const rawName = type?.name ?? 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) : ""; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7f1c2ffe0..c09b500c0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -24,7 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { - hasSpecContainers, + hidesTypeSummary, paintStale, renderOptionFields as renderOptionFieldsInto, } from "./B05_Profile_UI_Structures_Panel_Options"; @@ -42,7 +42,7 @@ import { loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { showsAnchor } from "./B05_Profile_UI_Structures_Define"; +import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; @@ -53,8 +53,9 @@ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import { DEFAULT_INTERVAL_LENGTH_M, - GROUP_LABELS, + groupOptions, hasComputedRange, + pickOption, readBeforeOption, readLengthOption, type PipeFacilityItem, @@ -77,7 +78,6 @@ export function createStructuresSection( body, groupSelect, typeSelect, - ownerNote, startFields, anchorFields, endFields, @@ -164,6 +164,7 @@ export function createStructuresSection( } let types: StructureType[] = []; + let canPick: (type: StructureType) => boolean = () => true; // 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다. const structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; @@ -227,16 +228,12 @@ export function createStructuresSection( !!type && !managed && type.placement === "interval" && !hasComputedRange(type); positionRow.hidden = !type; positionDivider.hidden = !type; - summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1) + summary.root.hidden = !type || hidesTypeSummary(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1) startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; - // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 - // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). - ownerNote.textContent = type?.design_owner - ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` - : ""; - ownerNote.hidden = !type?.design_owner; + // [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김) + root.dataset.helpTopic = type ? `구조물_${type.name}` : ""; // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); anchorFields.wrap.querySelector("span")!.textContent = isInterval @@ -324,6 +321,8 @@ export function createStructuresSection( commitTempPipe(); syncOptionLock(); syncRangeDisplay(); + // 고르던 항목의 측점도 값 — 다른 칸처럼 바로 반영(53-1) + if (positionConfirmed) liveCommit(); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ @@ -344,10 +343,10 @@ export function createStructuresSection( * 초기·리셋 상태를 "안 고름"으로 드러낸다. */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; - const candidates = group ? types.filter((type) => type.group === group) : []; + const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : []; typeSelect.replaceChildren( new Option("— 선택 —", ""), - ...candidates.map((type) => new Option(type.name, type.type_id)), + ...candidates.map((type) => pickOption(type.name, type.type_id, !canPick(type))), ); typeSelect.value = keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; @@ -538,14 +537,11 @@ export function createStructuresSection( facility: facilityOptions, listRoot: list, hasSelection: () => editingId !== null || selectedPipeChainage !== null, - setTypes(next) { + setTypes(next, pick = () => true) { types = next; - const groups = new Map(next.map((t) => [t.group, t.group_name] as const)); - // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저. - groupSelect.replaceChildren( - new Option("— 선택 —", ""), - ...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)), - ); + canPick = pick; + // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. + groupSelect.replaceChildren(new Option("— 선택 —", ""), ...groupOptions(next, pick)); groupSelect.value = ""; syncTypeOptions(); }, @@ -557,11 +553,10 @@ export function createStructuresSection( // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - if (applyingLive) renderList(); - else { - loadForm(null); - renderList(); - } + // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; + if (!applyingLive) loadForm(editingId === null ? null : kept); + renderList(); }, getStructures: () => [...structures], setPipeFacilities(pipes) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index e24155203..a5cfb5dc1 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,8 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; 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 { readGroupItems, @@ -40,6 +42,8 @@ export interface StructuresCommitContext { startFields: StationFields; endFields: StationFields; facilityOptions: FacilityOptionsForm; + /** 옵션 칸 — 짝 명세 폼(52-10)을 찾는 열쇠. */ + optionRow: HTMLElement; optionInputs: Array<{ key: string; required: boolean; @@ -95,16 +99,17 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.setPositionConfirmed(target !== null); const step = ctx.interval(); if (target) { - const type = ctx.typeMap().get(target.type_id); + const shown = asRoad(target); // 옛 대피 종류 = 대피로 + 상세(52-7) + const type = ctx.typeMap().get(shown.type_id); if (type) { ctx.groupSelect.value = type.group; - ctx.syncTypeOptions(target.type_id); + ctx.syncTypeOptions(shown.type_id); } ctx.anchorFields.write(structureAnchorM(target), step); ctx.startFields.write(target.start_m ?? null, step); ctx.endFields.write(target.end_m ?? null, step); ctx.memoField.value = target.memo ?? ""; - ctx.renderOptionFields(target.options); + ctx.renderOptionFields(shown.options); ctx.setSummary(readSummaryItem(target.summary_item)); ctx.groupBoxes.set(readGroupItems(target.group_items)); ctx.groupBoxes.setShapes(target.options); @@ -128,7 +133,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance afterEntry.input.value = after.toFixed(1); } } - ctx.syncFacilityForm(target.options); + ctx.syncFacilityForm(shown.options); } else { ctx.anchorFields.write(null, step); ctx.startFields.write(null, step); @@ -205,7 +210,7 @@ export function readOptions(ctx: StructuresCommitContext): Record entry.key), ...ctx.groupBoxes.shapeKeys()], + [ + ...ctx.optionInputs.map((entry) => entry.key), + ...ctx.groupBoxes.shapeKeys(), + ...specRenderedKeys(type, previous.options), // 명세 폼 키(52-10) — 비운 칸 · 뺀 컨테이너는 지움 + ], records[0].options, ), }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 163e45ed3..140499f9b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void { // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). ctx.syncFacilityForm(); + // 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴 + if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit(); if (ctx.editingId()) { ctx.setEditingId(null); ctx.syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index c92270ffc..7c9b09543 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -20,13 +20,23 @@ import { staleValues, } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; +import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; -import { specKeys } from "../A00_Common/design/design_structure_spec"; +import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; +import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec"; -/** 컨테이너(구조물 명세 · 정의 그룹 상자)가 있는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 - * 가림(PLAN 52-1 · 52-9 = 컨테이너마다 잇기 · 없으면 종류 단위). */ -export const hasSpecContainers = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length; +/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로 + * 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */ +export const hidesTypeSummary = (type: StructureType): boolean => + !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B"; + +/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일). */ +const SOLVE_SPLIT = new Set(["guardrail"]); +const SPLIT_ROLE: Record = { + length_m: "length", + before_m: "before", + after_m: "after", +}; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { @@ -62,10 +72,15 @@ export function renderOptionFields( optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). + // 명세 종류(52-10)는 명세 키 · 명세가 뺀 옛 키를 칸으로 안 그림 — 명세 폼이 옵션 칸 아래에 그림 + const owned = specOwnedKeys(type); const shown = type && !ctx.facilityFormKind(type) - ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) + ? orderedOptions(type).filter( + (o) => (ctx.includeDetail || isB05Option(o)) && !owned.has(o.key), + ) : []; + paintStructureSpec(optionRow, type, values); optionRow.style.gridTemplateColumns = ""; if (!type || !shown.length) { optionRow.hidden = true; @@ -80,9 +95,14 @@ export function renderOptionFields( shown.forEach((option) => { // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). // 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘. - const input = optionControl(option, values[option.key]); + const name = (c: string): string => type.spec_names?.[`${option.key}=${c}`] ?? c; + const input = optionControl(option, values[option.key], name); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; - const wrap = field(label, input, option.default_basis); + // 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18) + const grouped = + option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key)); + const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input); + if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게) wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 const place = gridPlace(type.spots?.[option.key], columns); @@ -128,12 +148,31 @@ export function renderOptionFields( "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); - ctx.syncSplitInputs(source, normalize); + if (SOLVE_SPLIT.has(type!.type_id)) { + solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게 + ctx.syncRangeDisplay(); + } else ctx.syncSplitInputs(source, normalize); }; input.addEventListener("input", () => refresh(false)); input.addEventListener("change", () => refresh(true)); } }); + /** 길이 · 전 · 후 맞물림 — 고친 차례(최근이 앞)를 쥐고 나머지 하나를 셈. */ + const recent: SplitRole[] = []; + function solveInto(key: string): void { + const role = SPLIT_ROLE[key]; + recent.splice(0, recent.length, role, ...recent.filter((r) => r !== role)); + const inputOf = (r: SplitRole) => + all.find(({ entry }) => SPLIT_ROLE[entry.key] === r)?.entry.input; + const values = Object.fromEntries( + (["length", "before", "after"] as SplitRole[]).map((r) => [r, inputOf(r)?.value ?? ""]), + ); + const solved = solveSplit(values, recent); + for (const r of ["length", "before", "after"] as SplitRole[]) { + const input = inputOf(r); + if (input) input.value = solved[r] ?? ""; + } + } /** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */ function applyRules(parent: string | null): void { const now = (): Record => @@ -152,7 +191,11 @@ export function renderOptionFields( wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식 if (!off) optionInputs.push(entry); if (state && entry.input.tagName.toLowerCase() === "select") - syncChoices(entry.input as HTMLSelectElement, state.choices); + syncChoices( + entry.input as HTMLSelectElement, + state.choices, + (c) => type!.spec_names?.[`${entry.key}=${c}`] ?? c, + ); } } applyRules(null); @@ -161,14 +204,18 @@ export function renderOptionFields( } /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ -function syncChoices(element: HTMLSelectElement, choices: string[]): void { +function syncChoices( + element: HTMLSelectElement, + choices: string[], + label: (choice: string) => string, +): void { const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value); const keep = element.value && !choices.includes(element.value) ? [element.value] : []; const want = [...choices, ...keep]; if (now.length === want.length && now.every((v, i) => v === want[i])) return; const value = element.value; const blank = element.children[0]; - element.replaceChildren(blank, ...want.map((c) => new Option(c, c))); + element.replaceChildren(blank, ...want.map((c) => new Option(label(c), c))); element.value = value; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index ff1daad7a..86e12199c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -34,6 +34,33 @@ export const GROUP_LABELS: Record = { 호환: "기타", }; +/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07). + * 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */ +export const HIDDEN_PICK_GROUPS: ReadonlySet = new Set(["B", "D"]); + +/** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */ +export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement { + const option = new Option(text, value); + option.hidden = hidden; + if (hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게 + return option; +} + +/** 군 드롭다운 항목들 — 이름은 정의(48) 먼저 · 뺄 군과 고를 종류가 없는 군은 가림. */ +export function groupOptions( + types: StructureType[], + pick: (type: StructureType) => boolean, +): HTMLOptionElement[] { + const groups = new Map(types.map((t) => [t.group, t.group_name] as const)); + return [...groups].map(([g, name]) => + pickOption( + name ?? GROUP_LABELS[g] ?? g, + g, + HIDDEN_PICK_GROUPS.has(g) || !types.some((t) => t.group === g && pick(t)), + ), + ); +} + /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ export const DEFAULT_INTERVAL_LENGTH_M = 15; @@ -83,8 +110,9 @@ export interface StructuresSection { /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ listRoot: HTMLElement; - /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ - setTypes: (types: StructureType[]) => void; + /** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고, + * 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */ + setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void; /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ setStructures: (structures: StructureInstance[]) => void; getStructures: () => StructureInstance[]; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts new file mode 100644 index 000000000..d63ecab14 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Spec.ts + * 일반 구조물(structures.json) 명세 컨테이너 폼(PLAN 52-10) — 계곡 통과 시설 밖 종류가 명세 + * (`STRUCTURE_SPECS`)를 가지면 옵션 칸 아래에 같은 공용 폼(`design_structure_form`)을 그림. + * + * · 폼은 옵션 칸(`optionRow`) 짝으로 하나 — 조립기가 세워 걸고, 옵션 그리기 · 저장이 꺼내 씀 + * · 명세 키 · 명세가 뺀 옛 키(retired)는 정의 옵션 칸으로 그리지 않음(옛 값은 병합이 지킴) + * · 잠금은 옵션 칸을 따름(측점 확정 전 흐림 · 조작 막음) + * ========================================================================== */ + +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { + createSpecForm, + type SpecForm, + type SpecLinks, +} from "../A00_Common/design/design_structure_form"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; + +const forms = new WeakMap(); + +/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ +export const structureSpecOf = (type: StructureType | null): StructureSpec | null => + (type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null; + +/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ +export function createStructureSpecForm( + optionRow: HTMLElement, + links: SpecLinks, + onChange: () => void, +): SpecForm { + const form = createSpecForm(links, { onChange }); + form.root.hidden = true; + forms.set(optionRow, form); + const lock = (): void => { + const locked = optionRow.classList.contains("is-locked"); + form.root.inert = locked; + form.root.classList.toggle("is-locked", locked); + }; + if (typeof MutationObserver !== "undefined") + new MutationObserver(lock).observe(optionRow, { + attributes: true, + attributeFilter: ["class"], + }); + return form; +} + +/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ +export function specOwnedKeys(type: StructureType | null): Set { + const spec = structureSpecOf(type); + return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []); +} + +/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ +export function paintStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, + values: Record, +): void { + const form = forms.get(optionRow); + const spec = structureSpecOf(type); + if (!form) return; + form.root.hidden = !spec; + if (!spec) return; + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + form.write(spec, values, type?.spec_names ?? {}, groups); +} + +/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ +export function readStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, +): Record { + const form = forms.get(optionRow); + return form && structureSpecOf(type) ? form.read() : {}; +} + +/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */ +export function specRenderedKeys( + type: StructureType | null, + previous: Record | undefined, +): string[] { + const spec = structureSpecOf(type); + if (!spec) return []; + const { keys, prefixes } = specKeys(spec); + const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); + return [...keys, ...own]; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index d0b76f534..4ae803d58 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -395,6 +395,10 @@ cursor: pointer; } +.b05-route__irregular-item:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + .b05-route__irregular-item.is-selected { border-color: var(--color-royal-amethyst, rgb(109 40 217)); background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index abc73231c..65ba8eb49 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -190,6 +190,7 @@ /* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시). 조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */ .b05-structure__grid.is-locked, +.b05-structure__spec.is-locked, .b05-drainage__facility.is-locked { opacity: 0.5; } @@ -214,13 +215,6 @@ line-height: 1.5; } -/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */ -.b05-structure__basis { - color: var(--color-text-muted, #9aa1ad); - font-size: var(--text-caption, 12px); - line-height: 1.3; -} - /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { @@ -498,25 +492,13 @@ } .b05-structure__optgroup { - display: flex; - flex-direction: column; - gap: 6px; min-width: 0; /* 긴 집계 항목 이름이 상자를 패널 밖으로 밀지 않게(fieldset 최소 폭 · 52-9) */ } -.b05-structure__optgroup > legend { - font-weight: 600; - opacity: 1; -} - .b05-structure__optgroup .is-option-hidden { display: none; } -.b05-structure__optgroup > .b05-structure__grid.is-locked { - opacity: 0.5; -} - /* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */ .b05-structure-section .is-rule-hidden { display: none; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index d0a4b7d47..174801264 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -207,6 +207,8 @@ export interface CulvertSideSpec { /** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */ basin_before_m?: number | null; basin_after_m?: number | null; + /** 집수정 높이(m) — 좌측 폼 「높이」. 없으면 횡단도 자동(I형 노견 접점 · ㄴ·ㄷ형 내공 높이). */ + basin_height_m?: number | null; /** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */ face_slope?: number; /** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 87fb59003..a9660dc40 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -185,6 +185,8 @@ def _side_spec( options.get("inlet_basin_after_m"), _number(defaults.get("inlet_basin_after_m"), None), ) + # 집수정 높이(좌측 폼 · 비면 횡단도 자동) — 횡단도 집수정 벽 높이. + spec["basin_height_m"] = _number(options.get("inlet_basin_height_m"), None) return spec # 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음). diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 526138242..5190e78c2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( prune_stale_cross_files, + relabel_pipe_stations, run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions @@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) + relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4) # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 41ce275ee..2eb75a1db 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -22,6 +22,13 @@ import { fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, +} from "@util/common_util_culvert_sets"; import { attachWallSpecs, wallSpecsFrom, @@ -55,6 +62,7 @@ async function withDraftWalls( // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). + await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); const types = await fetchStructureTypes().catch(() => []); const names = new Map( @@ -77,6 +85,66 @@ async function withDraftWalls( return withStructureAreas(detail); } +/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ +const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; +const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => + facility === "ford_bridge" + ? "ford" + : facility === "box_culvert" + ? "box" + : facility === "ford_pavement" + ? "ford_pavement" + : "culvert"; + +/** + * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. + * 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식 + * (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다. + */ +async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { + const pipes = readPendingPipes(projectId); + if (!pipes?.length) return; + const sections = detail.cross_sections as unknown as Array>; + const near = (spec: Record, section: Record, at: number) => + Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; + /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ + const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => + key === facilityKey(pipe.facility ?? "pipe") && + (key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment")); + const stale = pipes.filter((pipe) => { + const here = sections.flatMap((section) => + FACILITY_KEYS.flatMap((key) => { + const spec = section[key] as Record | undefined; + return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; + }), + ); + return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec)); + }); + if (!stale.length) return; + const types = await fetchStructureTypes().catch(() => []); + const registry = registryDefaults(types); + for (const section of sections) { + for (const key of FACILITY_KEYS) { + const spec = section[key] as Record | undefined; + if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; + } + // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음) + // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때) + const pipe = stale.find((entry) => { + const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); + return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; + }); + if (!pipe) continue; + const facility = pipe.facility ?? "pipe"; + const values = { ...registry.pipe, ...pipe.options }; + if (facility === "ford_pavement") delete section.structure; + else if (facility === "pipe") + section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; + else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility; + } + attachCulvertSets(sections, buildCulvertSets(stale, registry)); +} + /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 9df78bb84..9c32dfbbe 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -57,11 +57,9 @@ const SIDE_ROWS = [ ".b06-structure-panel__struct--wall-height", ].join(", "); -/** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */ +/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { - if (!target) return false; - const group = target.closest("fieldset"); - return !!group && !group.hidden; + return !!target?.closest("fieldset") && !target.closest("[hidden]"); } /** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 — @@ -111,6 +109,13 @@ function clearSideSlots(): void { // 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다. if (target === sideSlots?.extra) group.hidden = true; } + // 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1) + document + .querySelectorAll("fieldset.is-active > legend[data-base-label]") + .forEach((legend) => { + legend.parentElement!.classList.remove("is-active"); + setGroupLabel(legend.parentElement!, ""); + }); } /** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */ @@ -158,8 +163,8 @@ function relocateSideRows(panelRoot: HTMLElement): void { // 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))". // 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1). setGroupLabel(group, panelRoot.dataset.panelTitle ?? ""); - // 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2). - if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1). + group.scrollIntoView({ block: "nearest" }); } function ensureDock(): void { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 744c21cb3..ef11f4d65 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -94,6 +94,8 @@ export interface BasinBuildInput { * (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다). */ leanRatio?: number; + /** 벽 높이(m) — 좌측 폼 집수정 「높이」. 없으면 I형 노견 접점 · ㄴ·ㄷ형 내공 높이. */ + heightM?: number | null; } /** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */ @@ -143,10 +145,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M; const floorThickness = input.floorThicknessM ?? memberT; // 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값). + // 폼 「높이」를 적으면 세 형태 모두 그 높이. const wallHeight = - shape === "I" + input.heightM ?? + (shape === "I" ? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation) - : adjust.innerHeightM; + : adjust.innerHeightM); // 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20). // bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용). const defaultBottom = anchor.elevation - floorThickness; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 059d186f5..69fc5d3ec 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -2,7 +2,7 @@ * B06_Section_UI_Cross_Culvert_Geom.ts * 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면 - * 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 + * 1:n 평행사변형(n = 품셈 표준경사 · `outlineLeanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ @@ -24,7 +24,7 @@ import { revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; -import { leanOf } from "./B06_Section_UI_Cross_Lean"; +import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, @@ -110,6 +110,8 @@ export function computeCulvertLayout( }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); + const basinHeightM = + (culvert.inlet.basin_height_m ?? 0) > 0 ? culvert.inlet.basin_height_m : null; // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; @@ -157,7 +159,7 @@ export function computeCulvertLayout( const basinReason = culvert.hidden_pipe ? null : choiceReason; // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다. if (basinReason && basinShape !== "I") { - inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM; + inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM); } else if (basinReason) { // I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내). const startOffset = @@ -172,10 +174,10 @@ export function computeCulvertLayout( const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); - // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5). + // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음). const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); - const lean = leanOf({ + const lean = outlineLeanOf({ form: wall.form, height_m: wall.pureHeight, section_mode: section.design?.section_mode, @@ -343,6 +345,7 @@ export function computeCulvertLayout( groundAt, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, adjust: basinAdjust, + heightM: basinHeightM, }); basin = built.basin; basinPipeEnd = built.pipeEnd; @@ -624,9 +627,9 @@ export function computeCulvertLayout( ) : null; - // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈). + // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈). const extraLean = (form: string, pureHeightM: number): number => - leanOf({ + outlineLeanOf({ form, height_m: pureHeightM, section_mode: section.design?.section_mode, diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts index e6daa9d8d..5607cab81 100644 --- a/B06_Section/B06_Section_UI_Cross_Lean.ts +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -11,8 +11,14 @@ import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope"; import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json"; +import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; /** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ export function leanOf(input: WallLeanInput): number { return wallLeanRatio(masonrySlope, input); } + +/** 배관 흙막이 외곽 기울기 — 형태와 무관(형태는 안 무늬만) · 기본 형태의 표준경사. */ +export function outlineLeanOf(input: WallLeanInput): number { + return leanOf({ ...input, form: REVET_FORM_DEFAULT }); +} diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index e8c45dd44..a94fd89b5 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -8,7 +8,12 @@ * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. * ========================================================================== */ -import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets"; +import { + boxSpanM, + pipeDiameterM, + wingInstalled, + wingSlabExtendM, +} from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; @@ -20,6 +25,8 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ installed: boolean; + /** 폼이 낸 설치 글(날개벽 · 흙막이 …) — 있으면 저장에 그 글 그대로(있음/없음으로 안 바꿈). */ + text: string; height_m: number; length_m: number; angle_deg: number; @@ -29,7 +36,8 @@ type WingPatch = Partial<{ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record { const prefix = role === "inlet" ? "wing_in" : "wing_out"; const options: Record = {}; - if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; + if (patch.installed !== undefined) + options[prefix] = patch.text ?? (patch.installed ? "있음" : "없음"); if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; @@ -76,8 +84,11 @@ function wingPatchFrom(patch: Record, prefix: string): return Number.isFinite(value) ? value : undefined; }; const wing: WingPatch = {}; - // 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약). - if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음"; + // 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정. + if (patch[prefix] !== undefined) { + wing.installed = wingInstalled(patch[prefix]); + wing.text = String(patch[prefix]); + } const heightM = num(`${prefix}_height_m`); if (heightM !== undefined) wing.height_m = heightM; const lengthM = num(`${prefix}_length_m`); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 73970a199..1d3c8d98f 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -132,6 +132,7 @@ export function applyPipeOptionsToCache( own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m")); + own(culvert.inlet, "basin_height_m", num("inlet_basin_height_m")); // 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다. const reach = Math.max( culvert.inlet.revet_before_m ?? 0, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 41eb873fd..591e2c7ff 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,8 +38,10 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversChainage, NO_SELECTION, PIPE_MATCH_M, + readSelection, resolveSelection, selectDesign, type DesignSelection, @@ -85,6 +87,8 @@ export interface B06StructuresPanelDeps { stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ focusChainage: (chainageM: number) => void; + /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ + markStructure?: (structureId: string | null) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — @@ -116,6 +120,10 @@ export interface B06StructuresPanel { releasePipe: () => void; /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ selectionAt: (chainageM: number | null) => DesignSelection; + /** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */ + prefer: (structureId: string | null) => void; + /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ + selectStructure: (structureId: string) => void; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ getStructures: () => StructureInstance[]; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ @@ -148,6 +156,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); let markTypes: StructureType[] = []; + /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ + let preferredId: string | null = null; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); @@ -162,9 +172,35 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); + // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) + const picked = readSelection(deps.projectId); + const moved = picked?.structureId + ? structures.find((entry) => entry.structure_id === picked.structureId) + : undefined; + if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) { + const id = moved.structure_id ?? null; + preferredId = id; + deps.focusChainage(structureAnchorM(moved)); + preferredId = null; + deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움 + selectDesign( + deps.projectId, + { chainageM: structureAnchorM(moved), structureId: id }, + { from: "b06" }, + ); + } }, onSelect: (structure) => { - if (structure) deps.focusChainage(structureAnchorM(structure)); + if (!structure) { + deps.markStructure?.(null); // 풂 = 알약도 끔(53-2) + return; + } + // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. + preferredId = structure.structure_id ?? null; + deps.focusChainage(structureAnchorM(structure)); + preferredId = null; + // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) + deps.markStructure?.(structure.structure_id ?? null); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. @@ -226,18 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } // 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이 // [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준). - deps.applyPipeOptions?.(fromChainageM, patch); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); + const was = hit?.facility ?? "pipe"; + const facility = attributes.facility ?? was; + // 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음 + if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch); if (hit) { - const facility = hit.facility ?? "pipe"; hit.options = - mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch }) - .options ?? {}; + mergeFacilityOptions( + { facility: was, options: hit.options }, + { facility, options: patch }, + ).options ?? {}; + hit.facility = facility; } persistPipes(); section.setPipeFacilities(pipeFacilities); + if (facility !== was) { + pushMarks(); + deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) + } }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { @@ -248,6 +293,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc { from: "b06" }, ); if (chainageM !== null) deps.focusChainage(chainageM); + // 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2) + deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`); }, }, }); @@ -464,15 +511,20 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const selection = { chainageM, structureId: null }; return { chainageM, structureId: section.resolveAt(selection).structureId }; }, + prefer: (structureId) => { + preferredId = structureId; + }, + selectStructure: (structureId) => section.selectById(structureId), showAtChainage: (chainageM) => { currentChainageM = null; - const selection: DesignSelection = { chainageM, structureId: null }; + const selection: DesignSelection = { chainageM, structureId: preferredId }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). const hit = resolveSelection(selection, pipeFacilities, structures); if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); + deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2) } section.selectAt(selection); return { chainageM, structureId: hit.structureId }; @@ -534,11 +586,17 @@ export function wireStructureSelection( const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { - markWallSelecting(); + // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) + const nested = wallSelecting; + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); if (key) panel.showAtChainage(ownerChainageOf(chainageM)); - else panel.releasePipe(); + else if (!nested) { + remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + panel.releasePipe(); + } if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); @@ -546,22 +604,30 @@ export function wireStructureSelection( const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); }; const origBox = stationControls.box.select; stationControls.box.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts index e4e81cc7b..e94db0ef9 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts @@ -8,6 +8,7 @@ import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { wingInstalled } from "@util/common_util_culvert_sets"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; type Merged = Record; @@ -50,7 +51,9 @@ function keepWings( for (const [wing, prefix] of wings) { // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; - keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); + // 저장 글이 그린 설치와 같은 뜻이면 그대로 둠(BOX 유입·유출 구조 날개벽 · 흙막이 글을 안 덮음) + if (stored?.[prefix] === undefined || wingInstalled(stored[prefix]) !== wing.installed) + keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); keepSpec( merged, stored, diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index d44411c16..b03fa52ba 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -446,8 +446,15 @@ export function createSectionView( refreshCards, focusStation(stationId) { if (selectedStationId !== stationId) selectStation(stationId, true); - else revealCard(stationId, "smooth"); + else { + revealCard(stationId, "smooth"); + // 같은 카드라도 알약 강조는 다시 맞춘다 — 목록에서 같은 측점 다른 항목을 고른 경우(52-15). + profile.showCard( + currentDetail?.cross_sections.find((entry) => entry.station_id === stationId) ?? null, + ); + } }, + selectMark: (structureId) => profile.selectMark(structureId), setStationSelectListener(listener) { stationSelectListener = listener; }, diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index f3f41f182..b78b31fc5 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -29,6 +29,8 @@ export interface SectionStructureEdit { movePipe: (fromChainageM: number, toChainageM: number) => void; /** 구조물(관 아님)을 다른 측점으로 옮긴다. */ moveStructure: (structureId: string, toChainageM: number) => void; + /** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */ + selectStructure: (structureId: string) => void; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 5137d8e45..00b985789 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,6 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; +import { coversChainage } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -74,6 +75,8 @@ export interface SectionProfile { redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ showCard: (section: CrossSection | null) => void; + /** 알약 강조를 그 구조물로(null = 풀기). */ + selectMark: (structureId: string | null) => void; } /** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ @@ -131,6 +134,8 @@ export function createSectionProfile( let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; + /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ + let markedId: string | null = null; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; @@ -189,9 +194,19 @@ export function createSectionProfile( onIrregularSelect: (station) => host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { const hit = marks.find((entry) => entry.structure_id === structureId); - if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + if (!hit) return; + const edit = host.edit(); + if (!isPipeMark(hit) && hit.structure_id && edit) { + edit.selectStructure(hit.structure_id); + return; + } + // 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2) + host.selectCard(null); + host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약 }, onStructureMarkMove: (structureId, toChainageM) => { const edit = host.edit(); @@ -245,6 +260,10 @@ export function createSectionProfile( redraw() { profile?.redraw(); }, + selectMark(structureId) { + markedId = structureId; + profile?.setSelectedStructure(structureId); + }, showCard(section) { if (!profile) return; if (!section) { @@ -255,6 +274,10 @@ export function createSectionProfile( (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); + // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) + const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; + if (!pipe && marked && coversChainage(marked, section.chainage_m)) + profile.setSelectedStructure(markedId); }, }; } diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 8878e2e69..811c93dad 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -40,6 +40,8 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ focusStation: (stationId: string) => void; + /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */ + selectMark: (structureId: string | null) => void; /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; diff --git a/B06_Section/B06_Section_UI_Standard_Diagram.ts b/B06_Section/B06_Section_UI_Standard_Diagram.ts index e6c37a58b..4c409b174 100644 --- a/B06_Section/B06_Section_UI_Standard_Diagram.ts +++ b/B06_Section/B06_Section_UI_Standard_Diagram.ts @@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement { text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"), ); + // 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2) wrap.append(svg); - const caption = document.createElement("p"); - caption.className = "b06-std__diagram-caption"; - caption.textContent = L("B06_Std_Diagram_Caption"); - wrap.append(caption); return wrap; } diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index b564cd922..71c09b3ae 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -276,6 +276,7 @@ export function createStandardPanel( const root = document.createElement("div"); root.className = "b06-std"; + root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김) // 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5). root.append(buildStandardDiagram()); @@ -367,9 +368,6 @@ export function createStandardPanel( const legend = document.createElement("summary"); legend.className = "b06-std__legend"; legend.textContent = L("B06_Std_Detail_Title"); - const note = document.createElement("p"); - note.className = "b06-std__note"; - note.textContent = L("B06_Std_LType_Note"); fieldset.append( legend, buildScope("common"), @@ -377,7 +375,6 @@ export function createStandardPanel( buildScope("rock"), buildDivider("B06_Std_Section_PavedOnly"), buildScope("paved"), - note, ); return fieldset; }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index 89fed6003..c4d3492ec 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -173,12 +173,6 @@ margin-top: var(--spacing-8); } -.b06-std__diagram-caption { - margin: var(--spacing-8) 0 0; - font-size: var(--text-caption); - color: var(--color-text-muted); -} - /* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */ .b06-std__loader { display: flex; @@ -307,12 +301,6 @@ color: var(--color-text-secondary); } -.b06-std__note { - margin: 0; - font-size: var(--text-caption); - color: var(--color-text-muted); -} - .b06-std__actions { display: flex; gap: var(--spacing-8); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index e40721b11..5cd7b9bca 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). const confirmButtonRow = document.createElement("div"); confirmButtonRow.className = "b07-drawing-actions__row"; + confirmButtonRow.dataset.helpRow = ""; confirmButtonRow.append(frameEditor.button, confirmButton); confirmActions.append(frameEditor.banner, confirmButtonRow); diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index a55dc4341..862138d91 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -90,8 +90,11 @@ def collect_items( 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item, 복사본}` · - `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b). + `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b) · + 기타는 `그룹` = 사용자가 적은 상세 종류 이름(52-14). """ + from M02_MasterTemplete.M02_Table_Fill_Spec import etc_label + types = types or structure_type_map() lengths = pipe_lengths or {} items: list[dict[str, Any]] = [] @@ -100,6 +103,10 @@ def collect_items( definition = types.get(item.type_id) if definition is not None and definition.managed_by: continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈 + options = _with_defaults(definition, dict(item.options or {})) + label = etc_label(item.type_id, item.options) + if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2) + options["name"] = label["그룹"] items.append( { "type_id": item.type_id, @@ -107,10 +114,11 @@ def collect_items( "chainage_m": float(item.chainage_m), "start_m": item.start_m, "end_m": item.end_m, - "options": _with_defaults(definition, dict(item.options or {})), + "options": options, "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", "group_items": item.group_items, + **etc_label(item.type_id, item.options), } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): @@ -138,6 +146,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: 복사본 `{구조물 id}__{그룹 id}`. 정의된 그룹만(미지정 그룹 = 「미지정」 알림에 셈) · 정의가 사라진 그룹 연결은 기록에만 남고 표에 안 들어감.""" from common_util.common_util_summary_item import GROUP_JOIN + from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of defined = groups_of() @@ -154,6 +163,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: "그룹": group["name"], } ) + out.extend(container_rows(item, links)) # 선택 컨테이너마다 한 건(PLAN 52-1b) return out @@ -511,11 +521,6 @@ def fill_document( missing = sum(1 for item in items if item["type_id"] == "pipe" and not item.get("관연장")) if missing: notes.append(f"관 연장 없음 {missing}곳 — B06 횡단 설계 전이면 빈칸") - loose = sum( - 1 for item in items if item["type_id"] == "guardrail" and not item["options"].get("kind") - ) - if loose: - notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈") # 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄 hand = _hand_ids(columns) diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py new file mode 100644 index 000000000..e92d13fab --- /dev/null +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -0,0 +1,74 @@ +"""집계표 채움 — 구조물 명세 선택 컨테이너마다 한 건 더(PLAN 52-1b). + +선택 컨테이너 인스턴스(옵션 `{머리}_{n}_{항목}`)마다 같은 구조물 · 자리에 한 건 — +집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값. +열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`) +컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값. +짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — +시험이 대조. +기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. +""" + +from __future__ import annotations + +import re +from typing import Any + +from common_util.common_util_summary_item import GROUP_JOIN + +#: 종류 → 선택 컨테이너 (머리, 잇기, 제목) +SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { + "pipe": ( + ("soil_add", "gsoiladd", "흙막이 추가"), + ("chute_add", "gchuteadd", "도수로/돌붙임"), + ), + "ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),), + "etc": (("etc_detail", "getcdetail", "상세 내용"),), +} +_TEXT = ("structure", "form", "note") + + +def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: + """기타 집계 건 이름 `{그룹: 상세 종류 이름}`(비면 「기타」) — 다른 종류는 빈 묶음.""" + if type_id != "etc": + return {} + return {"그룹": str((options or {}).get("name") or "").strip() or "기타"} + + +def _as_options(values: dict[str, Any]) -> dict[str, Any]: + """컨테이너 값 → 열이 읽는 칸 — 구조 = `structure` · `{쪽}_type` · + 나머지 = 맨 칸 + `{쪽}_revet_*`.""" + out: dict[str, Any] = {} + for item, value in values.items(): + if item == "structure": + out.update(structure=value, inlet_type=value, outlet_type=value) + continue + name = item if item in _TEXT else f"{item}_m" + out[name] = value + for side in ("inlet", "outlet"): + out[f"{side}_revet_{name}"] = value + return out + + +def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str, Any]]: + """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" + rows: list[dict[str, Any]] = [] + for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): + head = re.compile(rf"^{prefix}_(\d+)_(.+)$") + found: dict[int, dict[str, Any]] = {} + for key, value in item["options"].items(): + hit = head.match(key) + if hit: + found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value + for n in sorted(found): + gid = f"{link}{n}" + rows.append( + { + **item, + "options": {**item["options"], **_as_options(found[n])}, + "summary_item": links.get(gid), + "복사본": f"{item['복사본']}{GROUP_JOIN}{gid}", + "그룹": f"{item.get('그룹', title)} {n}", + } + ) + return rows diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 26899fdc1..1529bacc3 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -26,10 +26,15 @@ import { } from "./M02_SO_Def_Model"; import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; +import { buildSpecBlock } from "./M02_SO_Def_Spec"; +import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; +/** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */ +const OLD_EDITOR = false; + export interface DefScreenOptions { /** 시스템 관리자 — 아니면 읽기만 */ isAdmin: boolean; @@ -182,6 +187,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { } const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); + // 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무 + const spec = STRUCTURE_SPECS[type.type_id]; detail.replaceChildren( el("div", { className: "m02-sod__kindhead", @@ -196,16 +203,20 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { edit, attr: "data-sod-kind-off", }), - checkBox( - to("BaseStationShow"), - baseShown(d, type.type_id), - (v) => { - if (v) delete extra().기준측점; - else extra().기준측점 = false; - changed(); - }, - { edit, attr: "data-sod-base" }, - ), + ...(!OLD_EDITOR + ? [] + : [ + checkBox( + to("BaseStationShow"), + baseShown(d, type.type_id), + (v) => { + if (v) delete extra().기준측점; + else extra().기준측점 = false; + changed(); + }, + { edit, attr: "data-sod-base" }, + ), + ]), ...(d.종류[type.type_id]?.동작 ?? []).map((a) => { const tag = createTag(a, "neutral"); tag.title = d.동작[a] ?? ""; @@ -213,10 +224,14 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { }), ], }), - buildOptionsBlock({ def: d, type, edit, open, changed }), - buildLayoutBlock({ def: d, type, edit, changed }), - buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), - buildRulesBlock({ def: d, type, edit, changed }), + ...(!OLD_EDITOR + ? [buildSpecBlock({ def: d, type, spec, edit, changed })] + : [ + buildOptionsBlock({ def: d, type, edit, open, changed }), + buildLayoutBlock({ def: d, type, edit, changed }), + buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), + buildRulesBlock({ def: d, type, edit, changed }), + ]), ); }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts index d45d9511b..2cb6355bb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -57,7 +57,8 @@ export interface StructDef { 구조물군: Record; 동작: Record; types: TypeDef[]; - 종류: Record; + /** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */ + 종류: Record }>; 표시: Record>; 그룹: Record; 배치: Record>; @@ -257,6 +258,10 @@ export function problems(def: StructDef): string[] { if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); } const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); + const spec = Object.entries(def.종류[t.type_id]?.이름 ?? {}) + .filter(([, v]) => !v.trim() || v.length > NAME_MAX) + .map(([k]) => k); + if (spec.length) out.push(`${t.name} 명세 이름: ${spec.join(", ")}`); if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts new file mode 100644 index 000000000..c3f5ab458 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -0,0 +1,119 @@ +/* ============================================================================= + * M02_SO_Def_Spec.ts + * 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴 + * 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 · + * 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음. + * 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품 + * (`field` · `optionControl` · 그룹 상자)으로 같은 차례. + * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. + * ========================================================================== */ + +import { createTag, el } from "@ui/ui_template_elements"; +import { createSpecForm } from "../../A00_Common/design/design_structure_form"; +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; +import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; +import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; +import { + groupNameRows, + setSpecName, + specNameRows, + type SpecNameRow, +} from "./M02_SO_Def_Spec_Model"; +import { head, textBox } from "./M02_SO_Def_Ui"; +import { to, type OptionsTextKey } from "./M02_SO_Text"; + +const TAG: Record, OptionsTextKey> = { + required: "SpecRequired", + optional: "SpecOptional", + select: "FormSelect", + number: "FormNumber", + text: "FormText", + auto: "FormAuto", + bool: "FormBool", +}; + +/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */ +function groupPreview(def: StructDef, type: TypeDef): HTMLElement { + const names = def.종류[type.type_id]?.이름 ?? {}; + const options = type.options.filter((o) => o.enabled !== false); + const cell = (o: (typeof options)[number]): HTMLElement => + field( + o.unit ? `${o.label} (${o.unit})` : o.label, + optionControl( + { + input: o.input === "select" || o.input === "number" ? o.input : "text", + choices: o.choices ?? [], + default: null, + required: !!o.required, + }, + undefined, + (c) => names[`${o.key}=${c}`] ?? c, + ), + ); + const groups = def.그룹[type.type_id] ?? []; + const inGroup = new Set(groups.flatMap((g) => g.옵션)); + const grid = (cells: HTMLElement[]): HTMLElement => + el("div", { className: "b05-structure__grid", children: cells }); + return el("div", { + children: [ + grid(options.filter((o) => !inGroup.has(o.key)).map(cell)), + ...groups.map((g) => { + const box = group(g.이름); + box.body.append(grid(options.filter((o) => g.옵션.includes(o.key)).map(cell))); + return box.root; + }), + ], + }); +} + +export function buildSpecBlock(opt: { + def: StructDef; + type: TypeDef; + /** 없으면 정의 그룹 꼴(52-12) */ + spec: StructureSpec | undefined; + edit: boolean; + changed: () => void; +}): HTMLElement { + const { def, type, spec, edit, changed } = opt; + const rows = (spec ? specNameRows(def, spec) : groupNameRows(def, type)).map((row) => + el("div", { + className: `m02-sod__specrow is-level-${row.level}`, + attrs: { "data-sod-spec": row.nameKey }, + children: [ + row.nameKey + ? textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, { + edit, + attr: "data-sod-spec-name", + cls: "m02-sod__name", + }) + : el("span", { text: to("SpecLoose") }), + el("small", { className: "m02-opts__key", text: row.key }), + ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), + ], + }), + ); + // 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫) + let preview: HTMLElement; + if (spec) { + const form = createSpecForm(null); + const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); + form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + preview = form.root; + } else preview = groupPreview(def, type); + preview.inert = true; + return el("div", { + className: "m02-sod__block", + attrs: { "data-sod-spec-block": "" }, + children: [ + ...head(to("SpecHead"), to("SpecNote")), + ...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("SpecNone") })]), + ...head(to("SpecPreview")), + el("div", { + className: "m02-sod__specpreview", + attrs: { "data-sod-spec-preview": "" }, + children: [preview], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts new file mode 100644 index 000000000..52634ecbc --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts @@ -0,0 +1,157 @@ +/* ============================================================================= + * M02_SO_Def_Spec_Model.ts + * 구조물 명세(PLAN 52 · `design_structure_spec`) 이름 고치기(52-11)의 자료 손질 — 화면 그리기 없음. + * + * · 명세 = 구조 정본(키 · 컨테이너 · 필수/선택 · 꼴) — 여기서는 **이름만** 고침 + * · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`) + * · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기 + * (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀 + * · 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목(`그룹.이름`) › 칸 이름(옵션 `label`) › + * 선택지 이름(이름 표 키 `{옵션}={값}` · 값은 그대로) — 그룹 밖 칸은 맨 앞 머리 줄 아래 + * import 는 꼴만 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; + +export interface SpecNameRow { + /** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */ + level: 0 | 1 | 2; + /** 이름 표 키 — 빈 글 = 고칠 이름 없는 머리 줄(그룹 밖 칸) */ + nameKey: string; + /** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */ + key: string; + /** 지금 이름 */ + label: string; + /** 읽기만 표시 — 필수/선택 · 꼴 */ + tag: "required" | "optional" | "select" | "number" | "auto" | "text" | "bool" | null; + /** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */ + group: string | null; + /** 정의 옵션 이름(`label`)에서 옴 — 고치면 그 옵션 이름에 씀 */ + option: string | null; +} + +/** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */ +export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] { + const names = def.종류[spec.typeId]?.이름 ?? {}; + const groups = def.그룹[spec.typeId] ?? []; + const rows: SpecNameRow[] = []; + const seen = new Set(); + const push = (row: SpecNameRow): void => { + if (seen.has(row.nameKey)) return; + seen.add(row.nameKey); + rows.push(row); + }; + for (const c of spec.containers) { + const group = + c.required && names[c.id] === undefined ? groups.find((g) => g.id === c.link) : undefined; + push({ + level: 0, + nameKey: c.id, + key: c.id, + label: names[c.id] ?? group?.이름 ?? c.title, + tag: c.required ? "required" : "optional", + group: group ? group.id : null, + option: null, + }); + for (const item of c.items) { + const itemKey = `${c.id}.${item.id}`; + push({ + level: 1, + nameKey: itemKey, + key: item.id, + label: names[itemKey] ?? item.label, + tag: item.kind, + group: null, + option: null, + }); + const choices = [ + ...(item.choices ?? []), + ...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []), + ]; + for (const ch of choices) { + const choiceKey = `${itemKey}=${ch.value}`; + push({ + level: 2, + nameKey: choiceKey, + key: ch.value, + label: names[choiceKey] ?? ch.label, + tag: null, + group: null, + option: null, + }); + } + } + } + return rows; +} + +/** 정의 그룹 꼴 종류(명세 없음) 이름 줄 — 그룹 밖 칸 · 그룹마다 제목 › 칸 › 선택지(B05 그리기 차례). */ +export function groupNameRows(def: StructDef, type: TypeDef): SpecNameRow[] { + const names = def.종류[type.type_id]?.이름 ?? {}; + const options = type.options.filter((o) => o.enabled !== false); + const groups = def.그룹[type.type_id] ?? []; + const inGroup = new Set(groups.flatMap((g) => g.옵션)); + const rows: SpecNameRow[] = []; + const optionRows = (keys: readonly string[]): void => { + for (const o of options.filter((x) => keys.includes(x.key))) { + rows.push({ + level: 1, + nameKey: o.key, + key: o.key, + label: o.label, + tag: o.input, + group: null, + option: o.key, + }); + for (const ch of o.input === "select" ? (o.choices ?? []) : []) { + const nameKey = `${o.key}=${ch}`; + rows.push({ + level: 2, + nameKey, + key: ch, + label: names[nameKey] ?? ch, + tag: null, + group: null, + option: null, + }); + } + } + }; + const loose = options.filter((o) => !inGroup.has(o.key)).map((o) => o.key); + if (loose.length) { + rows.push({ level: 0, nameKey: "", key: "", label: "", tag: null, group: null, option: null }); + optionRows(loose); + } + for (const g of groups) { + rows.push({ + level: 0, + nameKey: `#${g.id}`, + key: g.id, + label: g.이름, + tag: null, + group: g.id, + option: null, + }); + optionRows(g.옵션); + } + return rows; +} + +/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 옵션 칸은 옵션 이름 · 나머지는 이름 표. */ +export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void { + const option = row.option + ? def.types.find((t) => t.type_id === typeId)?.options.find((o) => o.key === row.option) + : undefined; + if (option) { + option.label = value; + return; + } + const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined; + if (group) { + group.이름 = value; + return; + } + const kind = (def.종류[typeId] ??= {}); + (kind.이름 ??= {})[row.nameKey] = value; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index a8570c86a..492eb8c4f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -332,3 +332,28 @@ margin: 0; color: var(--color-danger); } + +/* 구조물 명세 이름 고치기(PLAN 52-11) — 컨테이너 › 항목 › 값 들여쓰기 */ +.m02-sod__specrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sod__specrow.is-level-0 { + margin-top: var(--spacing-8); + font-weight: 600; +} + +.m02-sod__specrow.is-level-1 { + padding-left: 1.5rem; +} + +.m02-sod__specrow.is-level-2 { + padding-left: 3rem; +} + +.m02-sod__specpreview { + max-width: 28rem; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 90037edd8..a2c9f8c46 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -45,6 +45,17 @@ const TEXT = { FormNumber: ["숫자", "Number"], FormText: ["글", "Text"], FormBool: ["예/아니오", "Yes/No"], + FormAuto: ["자동", "Auto"], + SpecHead: ["구조물 명세 이름", "Structure spec names"], + SpecNote: [ + "제목 › 칸 이름 › 선택지 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 칸 더하기 · 빼기는 읽기만 · [저장]하면 B05 · B06 이 이 이름으로 그림", + "Rename titles › fields › choices only — keys, required/optional, form and adding/removing fields are read-only", + ], + SpecLoose: ["그룹 밖 칸", "Fields outside groups"], + SpecNone: ["고칠 이름 없음 — 칸이 없는 종류", "No names to edit — this kind has no fields"], + SpecRequired: ["필수", "Required"], + SpecOptional: ["선택", "Optional"], + SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"], Yes: ["예", "Yes"], No: ["아니오", "No"], Unit: ["단위", "Unit"], diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index d53993a58..296dcf02c 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -108,6 +108,7 @@ function sideSpec( num(defaults.inlet_basin_before_m, null), ); spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null)); + spec.basin_height_m = num(options.inlet_basin_height_m, null); return spec; } @@ -231,6 +232,12 @@ export function wingSlabExtendM( * 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량. * 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다. */ +/** 날개벽을 세우는 설치 값인가 — BOX암거 유입·유출 구조(표 02) 없음 · 날개벽 · 흙막이 · 옛 있음 = 날개벽. + * 날개벽만 세움 · 빈 값은 세움(기본). 파이썬 `_wing_spec` 과 짝. */ +export function wingInstalled(value: unknown): boolean { + return !["없음", "흙막이"].includes(String(value ?? "").trim()); +} + function wingSpec( values: Record, defaults: Record, @@ -241,8 +248,7 @@ function wingSpec( const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0)); const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0)); const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0)); - // BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움. - const installed = !["없음", "흙막이"].includes(String(install ?? "").trim()); + const installed = wingInstalled(install); return { installed, height_m: height, diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py new file mode 100644 index 000000000..1f9c4be92 --- /dev/null +++ b/common_util/common_util_help_router.py @@ -0,0 +1,34 @@ +"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌. + +사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임. +파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/help", tags=["help"]) + +HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" +PAGE_RE = re.compile(r"^B0[2-9]$") + + +def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: + if not PAGE_RE.match(page): + raise HTTPException(status_code=404, detail="없는 페이지") + folder = root / page + items = [] + for path in sorted(folder.glob("*.md")) if folder.is_dir() else []: + text = path.read_text(encoding="utf-8-sig") + head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "") + items.append({"key": path.stem, "title": head or path.stem, "text": text}) + return items + + +@router.get("/{page}") +async def get_help(page: str) -> dict[str, list[dict[str, str]]]: + return {"items": read_help(page)} diff --git a/resources/help/B02/00_설명서.md b/resources/help/B02/00_설명서.md new file mode 100644 index 000000000..ce3f51ea9 --- /dev/null +++ b/resources/help/B02/00_설명서.md @@ -0,0 +1,9 @@ +# 프로젝트 등록 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/00_설명서.md b/resources/help/B03/00_설명서.md new file mode 100644 index 000000000..bf1fc7923 --- /dev/null +++ b/resources/help/B03/00_설명서.md @@ -0,0 +1,9 @@ +# 파일입력 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/10_필요한_파일.md b/resources/help/B03/10_필요한_파일.md new file mode 100644 index 000000000..4fe4b1b22 --- /dev/null +++ b/resources/help/B03/10_필요한_파일.md @@ -0,0 +1,5 @@ +# 필요한 파일 + +- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개. +- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW. +- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다. diff --git a/resources/help/B03/20_고르는_방법.md b/resources/help/B03/20_고르는_방법.md new file mode 100644 index 000000000..02333ba83 --- /dev/null +++ b/resources/help/B03/20_고르는_방법.md @@ -0,0 +1,5 @@ +# 고르는 방법 + +- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다. +- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다. +- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다. diff --git a/resources/help/B03/30_알아_둘_것.md b/resources/help/B03/30_알아_둘_것.md new file mode 100644 index 000000000..e80291a60 --- /dev/null +++ b/resources/help/B03/30_알아_둘_것.md @@ -0,0 +1,5 @@ +# 알아 둘 것 + +- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다. +- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다. +- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다. diff --git a/resources/help/B04/00_설명서.md b/resources/help/B04/00_설명서.md new file mode 100644 index 000000000..bcb9a433f --- /dev/null +++ b/resources/help/B04/00_설명서.md @@ -0,0 +1,9 @@ +# 전처리 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/00_설명서.md b/resources/help/B05/00_설명서.md new file mode 100644 index 000000000..52d74d9b4 --- /dev/null +++ b/resources/help/B05/00_설명서.md @@ -0,0 +1,9 @@ +# 종단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/구조물_BOX암거.md b/resources/help/B05/구조물_BOX암거.md new file mode 100644 index 000000000..b6993e1e3 --- /dev/null +++ b/resources/help/B05/구조물_BOX암거.md @@ -0,0 +1,5 @@ +# 구조물 · BOX암거 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_기슭막이.md b/resources/help/B05/구조물_기슭막이.md new file mode 100644 index 000000000..de7432ce8 --- /dev/null +++ b/resources/help/B05/구조물_기슭막이.md @@ -0,0 +1,13 @@ +# 구조물 · 기슭막이 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 + +## 치수 근거 + +- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감) +- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사 +- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식 +- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171 +- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음 diff --git a/resources/help/B05/구조물_돌쌓기(메).md b/resources/help/B05/구조물_돌쌓기(메).md new file mode 100644 index 000000000..1f447c41f --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(메).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(메) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_돌쌓기(찰).md b/resources/help/B05/구조물_돌쌓기(찰).md new file mode 100644 index 000000000..3fcd36e38 --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(찰).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(찰) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_물넘이포장.md b/resources/help/B05/구조물_물넘이포장.md new file mode 100644 index 000000000..048d76751 --- /dev/null +++ b/resources/help/B05/구조물_물넘이포장.md @@ -0,0 +1,5 @@ +# 구조물 · 물넘이포장 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_배관.md b/resources/help/B05/구조물_배관.md new file mode 100644 index 000000000..53872704d --- /dev/null +++ b/resources/help/B05/구조물_배관.md @@ -0,0 +1,5 @@ +# 구조물 · 배관 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_비탈면 녹화.md b/resources/help/B05/구조물_비탈면 녹화.md new file mode 100644 index 000000000..07695f068 --- /dev/null +++ b/resources/help/B05/구조물_비탈면 녹화.md @@ -0,0 +1,5 @@ +# 구조물 · 비탈면 녹화 + +## 칸 기본값의 뜻 + +- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인) diff --git a/resources/help/B05/구조물_세월교.md b/resources/help/B05/구조물_세월교.md new file mode 100644 index 000000000..f3c8824ee --- /dev/null +++ b/resources/help/B05/구조물_세월교.md @@ -0,0 +1,5 @@ +# 구조물 · 세월교 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_옹벽.md b/resources/help/B05/구조물_옹벽.md new file mode 100644 index 000000000..d8ffe2a85 --- /dev/null +++ b/resources/help/B05/구조물_옹벽.md @@ -0,0 +1,6 @@ +# 구조물 · 옹벽 + +## 칸 기본값의 뜻 + +- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음 +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 diff --git a/resources/help/B05/구조물_측구(옆도랑).md b/resources/help/B05/구조물_측구(옆도랑).md new file mode 100644 index 000000000..9d3736fe3 --- /dev/null +++ b/resources/help/B05/구조물_측구(옆도랑).md @@ -0,0 +1,3 @@ +# 구조물 · 측구(옆도랑) + +- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다. diff --git a/resources/help/B05/구조물_큰돌쌓기.md b/resources/help/B05/구조물_큰돌쌓기.md new file mode 100644 index 000000000..05311185e --- /dev/null +++ b/resources/help/B05/구조물_큰돌쌓기.md @@ -0,0 +1,6 @@ +# 구조물 · 큰돌쌓기 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_흙막이.md b/resources/help/B05/구조물_흙막이.md new file mode 100644 index 000000000..5fc4f56de --- /dev/null +++ b/resources/help/B05/구조물_흙막이.md @@ -0,0 +1,9 @@ +# 구조물 · 흙막이 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌) +- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌) +- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌) +- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음 diff --git a/resources/help/B06/00_설명서.md b/resources/help/B06/00_설명서.md new file mode 100644 index 000000000..0308a208e --- /dev/null +++ b/resources/help/B06/00_설명서.md @@ -0,0 +1,9 @@ +# 횡단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B06/표준_횡단면.md b/resources/help/B06/표준_횡단면.md new file mode 100644 index 000000000..2ac92b0ae --- /dev/null +++ b/resources/help/B06/표준_횡단면.md @@ -0,0 +1,9 @@ +# 표준 횡단면 설정 + +## 변수 위치 안내 + +- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님). + +## 표준횡단면 상세값 + +- L형 측구는 각 횡단면도에서 선택합니다. diff --git a/resources/help/B07/00_설명서.md b/resources/help/B07/00_설명서.md new file mode 100644 index 000000000..6fc3ddd3c --- /dev/null +++ b/resources/help/B07/00_설명서.md @@ -0,0 +1,9 @@ +# 상세설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B08/00_설명서.md b/resources/help/B08/00_설명서.md new file mode 100644 index 000000000..291ac50b7 --- /dev/null +++ b/resources/help/B08/00_설명서.md @@ -0,0 +1,9 @@ +# 수량산출 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B09/00_설명서.md b/resources/help/B09/00_설명서.md new file mode 100644 index 000000000..4297e096b --- /dev/null +++ b/resources/help/B09/00_설명서.md @@ -0,0 +1,9 @@ +# 원가계산 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 0e2b2c80b..2088220e5 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 7, + "판": 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`가 새 키로 옮긴다.", "구조물군": { @@ -382,13 +382,14 @@ }, { "key": "wing_in", - "label": "날개벽(유입)", + "label": "유입 구조", "input": "select", "choices": [ - "있음", - "없음" + "없음", + "날개벽", + "흙막이" ], - "default": "있음", + "default": "날개벽", "required": false }, { @@ -417,13 +418,14 @@ }, { "key": "wing_out", - "label": "날개벽(유출)", + "label": "유출 구조", "input": "select", "choices": [ - "있음", - "없음" + "없음", + "날개벽", + "흙막이" ], - "default": "있음", + "default": "날개벽", "required": false }, { @@ -676,28 +678,41 @@ "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": "ditch_spec", - "label": "규격", - "input": "select", - "choices": [ - "콘크리트 개거 150×200", - "L형수로 H=0.2" - ], - "default": null, - "required": false, - "phase": "detail", - "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" - }, - { - "key": "length_m", - "label": "연장", + "key": "culvert_height_m", + "label": "암거 높이", "input": "number", "unit": "m", "default": null, "required": false, - "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" } ] }, @@ -2633,6 +2648,36 @@ } ] }, + { + "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", @@ -2732,26 +2777,40 @@ "drawing_views": [ "plan" ], - "options": [ - { - "key": "facing", - "label": "방향", - "input": "select", - "choices": [ - "좌", - "우", - "양면" - ], - "default": null, - "required": true, - "phase": "detail" - } - ] + "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": "가드레일·경계석·위험표지", + "name": "가드레일", "placement": "interval", "style": { "color": "#1f9e46", @@ -2764,17 +2823,35 @@ ], "options": [ { - "key": "kind", - "label": "종류", - "input": "select", - "choices": [ - "가드레일", - "경계석", - "위험표지" - ], + "key": "form", + "label": "형태", + "input": "text", "default": null, - "required": true, - "phase": "detail" + "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": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" } ] }, @@ -2793,6 +2870,20 @@ ], "options": [] }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, { "type_id": "barrier_gate", "group": "E", @@ -3170,7 +3261,7 @@ "options": [ { "key": "name", - "label": "이름", + "label": "상세 종류 이름", "input": "text", "default": "기타 구조물" } @@ -3246,7 +3337,23 @@ "계곡통과시설", "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": { "동작": [ @@ -3259,7 +3366,25 @@ "계곡통과시설", "월류단면", "날개벽" - ] + ], + "이름": { + "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": { "동작": [ @@ -3304,6 +3429,40 @@ "형상": {} } ], + "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", @@ -3323,6 +3482,41 @@ ], "형상": {} } + ], + "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/master_template/구조물정의_이력/판_10.json b/resources/master_template/구조물정의_이력/판_10.json new file mode 100644 index 000000000..e9778c5fe --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_10.json @@ -0,0 +1,3471 @@ +{ + "판": 10, + "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_본체규격", + "날개벽" + ] + }, + "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" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/master_template/구조물정의_이력/판_11.json b/resources/master_template/구조물정의_이력/판_11.json new file mode 100644 index 000000000..b9b342862 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_11.json @@ -0,0 +1,3516 @@ +{ + "판": 11, + "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" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/master_template/구조물정의_이력/판_7.json b/resources/master_template/구조물정의_이력/판_7.json new file mode 100644 index 000000000..6694fae47 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_7.json @@ -0,0 +1,3336 @@ +{ + "판": 7, + "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": "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": "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": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "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": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "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_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/master_template/구조물정의_이력/판_8.json b/resources/master_template/구조물정의_이력/판_8.json new file mode 100644 index 000000000..6e0503a6b --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_8.json @@ -0,0 +1,3401 @@ +{ + "판": 8, + "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": "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": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "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": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "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_본체규격", + "날개벽" + ] + }, + "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", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/master_template/구조물정의_이력/판_9.json b/resources/master_template/구조물정의_이력/판_9.json new file mode 100644 index 000000000..83644f8a7 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_9.json @@ -0,0 +1,3372 @@ +{ + "판": 9, + "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": "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": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "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_본체규격", + "날개벽" + ] + }, + "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", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_52_4_ford.cjs b/resources/tester/helper_52_4_ford.cjs new file mode 100644 index 000000000..b11d13b4c --- /dev/null +++ b/resources/tester/helper_52_4_ford.cjs @@ -0,0 +1,86 @@ +/* 세월교 명세(표 04) · 월류 폭 ↔ 높이 계산 단위검증 헬퍼(PLAN 52-4) — `design_structure_spec.ts` · + * `design_structure_ford.ts` · `design_structure_ford_calc.ts`(+ `fordSection` 이 든 B05 칸 조각)를 그 자리에서 + * 트랜스파일해 Node 로 돌림. 결과 = JSON 한 벌. 인자 `names` 면 세월교 이름 표(정의 `종류.ford_bridge.이름`)만 냄. + */ +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(); +/** TS 한 파일을 CommonJS 로 — 상대 import 는 그 파일 자리 기준 · `@config/` 는 config 폴더 · .json 은 그대로. */ +const load = (file) => { + if (cache.has(file)) return cache.get(file); + if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8")); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + const base = m.startsWith("@config/") + ? path.join(ROOT, "config", m.slice(8)) + : path.join(path.dirname(file), m); + return load(base.endsWith(".json") ? base : `${base}.ts`); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + cache.set(file, box.exports); + return box.exports; +}; +const D = (f) => path.join(ROOT, "A00_Common", "design", f); +const s = load(D("design_structure_spec.ts")); +const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts")); +const calc = load(D("design_structure_ford_calc.ts")); + +if (process.argv[2] === "names") { + process.stdout.write(JSON.stringify(s.nameTable(FORD_SPEC))); + process.exit(0); +} + +const old = { + pipe_kind: "파형강관", + pipe_diameter_mm: 1000, + pipe_count: 2, + ford_width_m: 10, + ford_height_m: 0.3, + wing_in: "있음", + wing_in_height_m: 1, + wing_out: "없음", +}; +const legacy = withLegacyGuards(old); +const state = s.readState(FORD_SPEC, legacy); +const back = s.writeOptions(FORD_SPEC, state); +const soil = JSON.parse(JSON.stringify(state)); +soil.inlet[0].structure = "흙막이"; +soil.outlet[0].structure = "날개벽"; +const soilOut = s.writeOptions(FORD_SPEC, soil); + +const flows = [0.5, 2.54, 8]; +const widths = [3, 5, 10, 20]; +const table = flows.flatMap((q) => widths.map((b) => [q, b, calc.fordHeightM(q, b)])); + +const out = { + containers: FORD_SPEC.containers.map((c) => [c.id, c.title, c.required]), + items: FORD_SPEC.containers.map((c) => c.items.map((i) => [i.id, i.kind, i.key ?? null])), + choices: FORD_SPEC.containers.map((c) => + c.items.filter((i) => i.kind === "select").map((i) => s.choicesOf(i, {}).map((ch) => ch.label)), + ), + legacy: { inlet: legacy.inlet_type, outlet: legacy.outlet_type }, + keepSoil: withLegacyGuards({ inlet_type: "흙막이", wing_in: "없음" }).inlet_type, + b06Off: withLegacyGuards({ inlet_type: "날개벽", wing_in: "없음" }).inlet_type, + b06On: withLegacyGuards({ inlet_type: "없음", wing_in: "있음" }).inlet_type, + state, + back, + soilOut, + links: FORD_SPEC.containers.map((c) => s.linkOf(c, 1)), + keys: s.specKeys(FORD_SPEC), + retired: FORD_SPEC.retired, + table, + none: [calc.fordHeightM(null, 10), calc.fordHeightM(2.54, 0)], + hints: [ + calc.fordHeightHint(2.54, 10), + calc.fordHeightHint(2.54, 0), + calc.fordHeightHint(null, 10), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 0ff82723b..e625ac36b 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -13,11 +13,92 @@ const load = (file, deps = {}) => { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m)); + const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); // 같은 폴더 명세 + new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? near(m)); return box.exports; }; const s = load("design_structure_spec.ts"); -const { PIPE_SPEC } = load("design_structure_pipe.ts"); +const road = load("design_structure_road.ts"); +const { PIPE_SPEC, STRUCTURE_SPECS } = load("design_structure_pipe.ts", { + "./design_structure_road": road, +}); + +// 노면공 › 콘크리트 포장(PLAN 52-10) — 옛 상세 칸은 남고 명세 키만 오감 +if (process.argv[2] === "road") { + const spec = STRUCTURE_SPECS.pavement_concrete; + const old = { + length_m: 12, + before_m: 4, + after_m: 8, + thickness_cm: 20, + joint_spacing_m: 6, + pave_spec: "콘크리트", + height_m: 0.2, + }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].height = "0.35"; + const cleared = JSON.parse(JSON.stringify(state)); + cleared.detail[0].height = ""; + process.stdout.write( + JSON.stringify({ + registered: Object.keys(STRUCTURE_SPECS), + same: spec === road.PAVEMENT_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]), + choices: road.PAVE_SPEC_CHOICES.map((c) => c.value), + state, + edited: s.writeOptions(spec, edited), + cleared: s.writeOptions(spec, cleared), + keys: s.specKeys(spec), + retired: spec.retired, + }), + ); + process.exit(0); +} + +// 기타(PLAN 52-14) — 선택 컨테이너 「상세 내용」 비고 글 · 길이 상한 · 비우기 · 이름 표 +if (process.argv[2] === "etc") { + const spec = STRUCTURE_SPECS.etc; + const old = { name: "배수판", etc_detail_2_note: "옛 비고", etc_detail_5_note: "둘째" }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].note = " 새 비고 "; + edited.detail[1].note = ""; + edited.detail.push({ note: "가".repeat(s.TEXT_MAX + 30) }); + const out = s.writeOptions(spec, edited); + process.stdout.write( + JSON.stringify({ + same: spec === road.ETC_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.prefix, c.link]), + items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]), + state, + edited: out, + max: s.TEXT_MAX, + longLen: out.etc_detail_3_note?.length ?? 0, + keys: s.specKeys(spec), + names: s.nameTable(spec), + links: [1, 2].map((n) => s.linkOf(spec.containers[0], n)), + }), + ); + process.exit(0); +} + +// 선택 컨테이너 「하나만」(PLAN 52-17) — 종류.컨테이너 → max · 옛 저장 둘은 다 읽힘 +if (process.argv[2] === "max") { + const max = {}; + for (const [type, spec] of Object.entries(STRUCTURE_SPECS)) + for (const c of spec.containers) if (!c.required) max[`${type}.${c.id}`] = c.max ?? null; + const old = { ford_chute_1_structure: "도수로", ford_chute_2_structure: "돌붙임" }; + const state = s.readState(STRUCTURE_SPECS.ford_pavement, old); + process.stdout.write( + JSON.stringify({ + max, + oldTwo: state.chute.length, + back: s.writeOptions(STRUCTURE_SPECS.ford_pavement, state), + }), + ); + process.exit(0); +} if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); diff --git a/resources/tester/helper_52_structure_spec_box.cjs b/resources/tester/helper_52_structure_spec_box.cjs new file mode 100644 index 000000000..ac77f9982 --- /dev/null +++ b/resources/tester/helper_52_structure_spec_box.cjs @@ -0,0 +1,69 @@ +/* 박스암거 명세 단위검증 헬퍼(PLAN 52-2) — `design_structure_spec.ts` · `design_structure_box.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌. + * 인자 `names` 면 박스암거 이름 표(정의 `종류.box_culvert.이름`)만 냄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (file) => { + const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", file), "utf8"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +}; +const s = load("design_structure_spec.ts"); +const b = load("design_structure_box.ts"); +const SPEC = b.BOX_SPEC; + +if (process.argv[2] === "names") { + process.stdout.write(JSON.stringify(s.nameTable(SPEC))); + process.exit(0); +} + +/** 화면이 하는 차례 그대로 — 옛 값 정리 → 명세 읽기 → 본체 칸 되찾기 → 저장 옵션. */ +const roundTrip = (options, edit) => { + const state = s.readState(SPEC, b.boxOptionsIn(options)); + Object.assign(state.body[0], b.bodyOf(options)); + edit?.(state); + return { state, out: { ...s.writeOptions(SPEC, state), ...b.bodyOptions(state.body[0]) } }; +}; +const body = SPEC.containers.find((c) => c.id === "body"); +const width = body.items.find((i) => i.id === "width"); + +const old = { body_width_m: 3, body_height_m: 2.5, wing_in: "있음", wing_in_length_m: 2 }; +const out = { + containers: SPEC.containers.map((c) => [c.id, c.title, c.required]), + links: SPEC.containers.map((c) => s.linkOf(c, 1)), + sizes: body.items[0].choices.map((c) => c.value), + structures: SPEC.containers[1].items[0].choices.map((c) => c.value), + customHidden: s.branchOf(width, { size: "2.0×2.0" }), + customShown: s.branchOf(width, { size: "사용자 입력" }), + old: roundTrip(old), + preset: roundTrip({ body_width_m: 2, body_height_m: 2, wing_out: "없음" }), + toPreset: roundTrip(old, (st) => { + st.body[0].size = "3.0×3.0"; + st.outlet[0].structure = "흙막이"; + }), + toCustom: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => { + st.body[0].size = "사용자 입력"; + st.body[0].width = "2.4"; + st.body[0].height = "1.8"; + }), + blank: roundTrip({}), + // 52-19 — 사용자 입력에서 한 칸 · 두 칸 비우고 저장 → 다시 열기 + oneBlank: roundTrip({ body_height_m: 2.5 }), + bothBlank: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => { + st.body[0].size = "사용자 입력"; + st.body[0].width = ""; + st.body[0].height = ""; + }), + keys: s.specKeys(SPEC), + retired: SPEC.retired, +}; +out.bothBlankAgain = roundTrip(out.bothBlank.out); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_b05_b06_selection.cjs b/resources/tester/helper_b05_b06_selection.cjs index 8892c7e2f..e38ec452e 100644 --- a/resources/tester/helper_b05_b06_selection.cjs +++ b/resources/tester/helper_b05_b06_selection.cjs @@ -80,8 +80,10 @@ sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", f out.store1 = sel.readSelection(P); sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" }); out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음 +sel.selectDesign(P, { chainageM: 205.2, structureId: "wall" }, { from: "b06" }); +out.keyKept = sel.readSelection(P)?.partKey; // 같은 것을 다시 적으면 부재키를 지킨다 sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" }); -out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙) +out.keyDropped = sel.readSelection(P)?.partKey ?? null; // 다른 구조물로 옮기면 버린다(53-1) sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" }); out.cleared = sel.readSelection(P); out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]); @@ -150,7 +152,7 @@ out.apply = { }; // ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ───────────── -const restoreRun = (pickValue) => { +const restoreRun = (pickValue, stationOpens = false) => { sel.selectDesign(P, sel.NO_SELECTION); if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key }); const calls = []; @@ -163,15 +165,17 @@ const restoreRun = (pickValue) => { }, }; const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) }; - const done = pickSession.restoreStructurePick(controls, P, section, (c) => - calls.push(["station", c]), - ); + const done = pickSession.restoreStructurePick(controls, P, section, (c) => { + calls.push(["station", c]); + opened = stationOpens; // 같은 측점 관이 먼저 열림 + }); return { calls, done }; }; out.restore = { pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }), structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }), nothing: restoreRun(null), + structureOverPipe: restoreRun({ selection: { chainageM: 440.24, structureId: "mirror" } }, true), }; sel.selectDesign(P, sel.NO_SELECTION); diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs new file mode 100644 index 000000000..6634b447e --- /dev/null +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -0,0 +1,95 @@ +/* M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). + * 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +// 이웃 TS(`./…`)는 같은 길로 읽음 — 명세 파일이 서로 import 함 +const cache = {}; +const load = (...parts) => { + const file = path.join(ROOT, ...parts); + if (cache[file]) return cache[file].exports; + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[file] = { exports: {} }); + const near = (id) => + id.startsWith(".") + ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) + : require(id); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; +const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts"); +const { PIPE_SPEC } = load("A00_Common", "design", "design_structure_pipe.ts"); +const real = () => + JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), + ); + +const out = {}; +const def = real(); +const rows = m.specNameRows(def, PIPE_SPEC); +out.levels = [0, 1, 2].map((lv) => rows.filter((r) => r.level === lv).length); +out.unique = new Set(rows.map((r) => r.nameKey)).size === rows.length; +out.pipeTitle = rows.find((r) => r.nameKey === "pipe"); +out.optional = rows.find((r) => r.nameKey === "soil_add"); +out.basinForm = rows.find((r) => r.nameKey === "inlet.form=I"); + +// 이름 고침 — 그룹 제목 · 항목 · 값 · 선택 컨테이너 +const work = real(); +const pick = (k) => m.specNameRows(work, PIPE_SPEC).find((r) => r.nameKey === k); +m.setSpecName(work, "pipe", pick("pipe"), "관 본체"); +m.setSpecName(work, "pipe", pick("pipe.diameter"), "관 지름"); +m.setSpecName(work, "pipe", pick("pipe.diameter=800"), "Ø800"); +m.setSpecName(work, "pipe", pick("soil_add"), "흙막이 더"); +out.groupName = work.그룹.pipe.find((g) => g.id === "grv6lny").이름; +out.names = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map( + (k) => work.종류.pipe.이름[k] ?? null, +); +out.reread = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map((k) => pick(k).label); +// 구조는 그대로 — types · 동작 · 다른 종류 · 이름 표 키 수(새 키 없음) +const before = real(); +out.sameTypes = JSON.stringify(work.types) === JSON.stringify(before.types); +out.sameKeys = + Object.keys(work.종류.pipe.이름).length === Object.keys(before.종류.pipe.이름).length; + +// 이름 표가 빈 종류 — 명세 글로 읽고 고치면 이름 표가 생김 +const bare = real(); +delete bare.종류.pipe.이름; +const bareRows = m.specNameRows(bare, PIPE_SPEC); +out.bareLabel = bareRows.find((r) => r.nameKey === "inlet.structure=기슭막이").label; +m.setSpecName( + bare, + "pipe", + bareRows.find((r) => r.nameKey === "inlet"), + "유입", +); +out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름; + +// 정의 그룹 꼴(52-12) — 맹암거 = 그룹 제목 › 칸 · 옆도랑 = 그룹 밖 칸 › 선택지 +const g = real(); +const ud = g.types.find((t) => t.type_id === "cross_underdrain"); +const udRows = m.groupNameRows(g, ud); +out.udRows = udRows.map((r) => [r.level, r.nameKey, r.label]); +m.setSpecName(g, ud.type_id, udRows[0], "맹암거 본체"); +m.setSpecName(g, ud.type_id, udRows[1], "암거 높이 칸"); +out.udGroup = g.그룹.cross_underdrain[0].이름; +out.udLabel = ud.options[0].label; +const side = g.types.find((t) => t.type_id === "ditch_side"); +const sideRows = m.groupNameRows(g, side); +out.sideHead = sideRows[0]; +const lshape = sideRows.find((r) => r.nameKey === "form=L형"); +out.sideChoice = lshape && [lshape.level, lshape.label]; +m.setSpecName(g, side.type_id, lshape, "L 모양"); +out.sideName = g.종류.ditch_side.이름["form=L형"]; +out.sideChoices = side.options.find((o) => o.key === "form").choices; +out.sideReread = m.groupNameRows(g, side).find((r) => r.nameKey === "form=L형").label; +out.bareKind = m.groupNameRows( + g, + g.types.find((t) => t.type_id === "open_ditch"), +).length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_10_structure_spec_road.py b/resources/tester/test_52_10_structure_spec_road.py new file mode 100644 index 000000000..395211b1f --- /dev/null +++ b/resources/tester/test_52_10_structure_spec_road.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""노면공 › 콘크리트 포장 명세 · 기타 이름(사용자 구조물 표 08) — PLAN 52-10. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs road`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +DEFINITION = os.path.join(ROOT, "resources", "master_template", "구조물정의.json") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "road"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_명세_등록_필수_컨테이너_하나(out): + assert {"pipe", "pavement_concrete"} <= set(out["registered"]) # 다른 창 명세도 함께 등록됨 + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", True, "gpavedetail"]] + + +def test_포장사양_선택지는_명세_한_곳(out): + assert out["choices"] == ["콘크리트"] + + +def test_저장_키_왕복_옛_칸은_명세_밖(out): + assert out["state"] == {"detail": [{"spec": "콘크리트", "height": "0.2"}]} + assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35} + assert out["cleared"] == {"pave_spec": "콘크리트"} + assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []} + assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로 + assert set(out["retired"]) == { + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + } + + +def test_기타_상세_종류_이름_글_칸(): + with open(DEFINITION, encoding="utf-8") as f: + doc = json.load(f) + etc = next(t for t in doc["types"] if t["type_id"] == "etc") + name = next(o for o in etc["options"] if o["key"] == "name") + assert name["label"] == "상세 종류 이름" and name["input"] == "text" + pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete") + assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"] diff --git a/resources/tester/test_52_14_etc_text.py b/resources/tester/test_52_14_etc_text.py new file mode 100644 index 000000000..beab8fabc --- /dev/null +++ b/resources/tester/test_52_14_etc_text.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""공용 틀 글 손 입력 · 기타 선택 컨테이너 · 기타 이름 = 집계 건 이름 — PLAN 52-14. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs etc`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`(52-14 덧). +""" + +import json +import os +import re +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows, etc_label +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + RV, + _save, + world, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "etc"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기타_선택_컨테이너_상세_내용(out): + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", False, "etc_detail", "getcdetail"]] + assert out["items"] == [["note", "비고", "text"]] + assert out["links"] == ["getcdetail1", "getcdetail2"] + + +def test_글_저장_비우기_길이_상한(out): + assert out["state"] == {"detail": [{"note": "옛 비고"}, {"note": "둘째"}]} # n 차례대로 당김 + edited = out["edited"] + assert edited["etc_detail_1_note"] == "새 비고" # 앞뒤 빈칸 뺌 + assert edited["etc_detail_2_note"] == "" # 비운 인스턴스도 자리를 지킴(잇기 · 집계 줄) + assert out["longLen"] == out["max"] == 100 + assert out["keys"] == {"keys": [], "prefixes": ["etc_detail_"]} + + +def test_이름_표에_글_항목(out): + assert out["names"] == {"detail": "상세 내용", "detail.note": "비고"} + + +def test_기타_이름이_집계_건_이름(): + assert etc_label("etc", {"name": " 배수판 "}) == {"그룹": "배수판"} + assert etc_label("etc", {"name": ""}) == {"그룹": "기타"} + assert etc_label("etc", {}) == {"그룹": "기타"} + assert etc_label("pipe", {"name": "x"}) == {} + item = { + "type_id": "etc", + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽", "etc_detail_2_note": ""}, + "복사본": "st1", + "그룹": "배수판", + } + rows = container_rows(item, {"getcdetail1": RV, "getcdetail2": MS}) + assert [r["그룹"] for r in rows] == ["배수판 1", "배수판 2"] + assert [r["복사본"] for r in rows] == ["st1__getcdetail1", "st1__getcdetail2"] + assert [r["summary_item"] for r in rows] == [RV, MS] + assert rows[0]["options"]["note"] == "왼쪽" + + +def test_집계_모음에_기타_이름과_컨테이너_건(world): # noqa: F811 — 고정물 + root = world["root"] + etc = { + "structure_id": "etc1", + "type_id": "etc", + "placement": "point", + "chainage_m": 60.0, + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽"}, + "group_items": {"getcdetail1": RV}, + } + blank = {**etc, "structure_id": "etc2", "chainage_m": 80.0, "options": {"name": ""}} + _save(root, [etc, blank], []) + items = {i["복사본"]: i for i in fill.collect_items(root)} + assert items["etc1"]["그룹"] == "배수판" + assert items["etc1__getcdetail1"]["그룹"] == "배수판 1" + assert items["etc1__getcdetail1"]["summary_item"] == RV + assert items["etc2"]["그룹"] == "기타" + + +def test_서버_컨테이너_표는_기타_명세와_짝(): + with open( + os.path.join(ROOT, "A00_Common/design/design_structure_road.ts"), encoding="utf-8" + ) as f: + text = f.read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["etc"]) diff --git a/resources/tester/test_52_17_single_container.py b/resources/tester/test_52_17_single_container.py new file mode 100644 index 000000000..b66cee344 --- /dev/null +++ b/resources/tester/test_52_17_single_container.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""공용 틀 선택 컨테이너 「하나만」(max) — PLAN 52-17. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs max`)가 돌림 · +화면([추가] 숨김)은 ORCA `ui_checks/52-3` · `52-10` · `52-1`. +""" + +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _run(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "max"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_하나만_컨테이너와_여러개_컨테이너(): + out = _run() + assert out["max"] == { + "pipe.soil_add": None, + "pipe.chute_add": None, + "ford_pavement.chute": 1, + "etc.detail": 1, + } + + +def test_옛_저장_둘은_지우지_않음(): + out = _run() + assert out["oldTwo"] == 2 + assert out["back"] == {"ford_chute_1_structure": "도수로", "ford_chute_2_structure": "돌붙임"} diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py new file mode 100644 index 000000000..c0cc03f99 --- /dev/null +++ b/resources/tester/test_52_1b_pipe_links.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""배관 남은 연동 — PLAN 52-1b. + +못박는 것 + - 집계표: 선택 컨테이너(흙막이 추가 · 도수로/돌붙임)마다 한 건 — 잇기 `{link}{n}` 항목 · + 그 컨테이너 값. + - 짝: 서버 컨테이너 표(머리 · 잇기) = 배관 명세 TS. +""" + +from __future__ import annotations + +import json +import os +import re + +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + PIPE, + RV, + _pipe, + _save, + world, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +SOIL = { + "soil_add_1_structure": "기슭막이", + "soil_add_1_form": "바자", + "soil_add_1_length": 6, + "soil_add_1_height": 2, + "soil_add_2_structure": "기슭막이", + "chute_add_1_structure": "돌붙임", +} + + +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] == [ + "pipe@40.0__gsoiladd1", + "pipe@40.0__gsoiladd2", + "pipe@40.0__gchuteadd1", + ] + assert [r["summary_item"] for r in rows] == [RV, None, MS] + first = rows[0]["options"] + assert first["length_m"] == first["inlet_revet_length_m"] == first["outlet_revet_length_m"] == 6 + assert ( + first["form"] == first["outlet_revet_form"] == "바자" and first["outlet_type"] == "기슭막이" + ) + assert first["pipe_diameter_mm"] == 1000 # 구조물 옵션은 그대로 + assert container_rows({"type_id": "masonry_dry", "options": SOIL, "복사본": "st0"}, {}) == [] + # 옛 값 — 컨테이너 없는 옛 관 · 인스턴스 없는 잇기는 줄 없음 + old = {"type_id": "pipe", "options": {"inlet_revet_length_m": 5}, "복사본": "pipe@9.0"} + assert container_rows(old, {"gsoiladd9": RV}) == [] + + +def test_집계표_채움에_컨테이너_줄(world): + """흙막이 추가 1 → 돌기슭막이 열(rv) 6 m · 관 본체 · 미지정 컨테이너는 표에 안 듦.""" + root, system = world["root"], world["system"] + point = {**_pipe(PIPE), "group_items": {"gsoiladd1": RV}} + point["options"] = {**point["options"], **SOIL} + _save(root, [], [point]) + table = json.loads((system / "table/구조물집계표.json").read_text("utf-8")) + doc = fill.fill_table(root, table) + values = [row["값"] for row in doc["줄"] if not row.get("고정")] + rv = [v for row in values for k, v in row.items() if k.split("|")[0] == "rv"] + assert rv == [6] + copies = [i["복사본"] for i in fill.collect_items(root)] + assert "pipe@40.0__gsoiladd1" in copies and "pipe@40.0__gchuteadd1" in copies + + +def test_서버_컨테이너_표는_배관_명세와_짝(): + with open( + os.path.join(ROOT, "A00_Common/design/design_structure_pipe.ts"), encoding="utf-8" + ) as f: + text = f.read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["pipe"]) diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py new file mode 100644 index 000000000..39745778a --- /dev/null +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 물넘이 포장(표 03) 명세 — PLAN 52-3. + +명세 · 저장 병합은 Node 로 그 자리에서 트랜스파일해 돌림 · 화면은 ORCA `ui_checks/52-3_물넘이포장_컨테이너.js`. +""" + +import json +import os +import re +import subprocess + +import pytest + +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +SCRIPT = r""" +const fs = require("fs"), path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const load = (file) => { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, () => ({})); + return box.exports; +}; +const s = load(path.join(ROOT, "A00_Common/design/design_structure_spec.ts")); +const { FORD_PAVEMENT_SPEC: spec } = load(path.join(ROOT, "A00_Common/design/design_structure_ford_pavement.ts")); +const merge = load(path.join(ROOT, "B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts")); +const old = { ford_width_m: 6, ford_height_m: 0.3, ford_slope_pct: 3, thickness_cm: 20, length_m: 7 }; +const state = s.readState(spec, { ...old, ford_slope_deg: 12.5, ford_chute_1_structure: "돌붙임" }); +const edited = JSON.parse(JSON.stringify(state)); +edited.slope[0].angle = "15"; +edited.chute.push({ structure: "도수로" }); +const written = s.writeOptions(spec, edited); +const prev = { facility: "ford_pavement", options: { ...old, ford_chute_2_structure: "도수로" } }; +const saved = merge.mergeFacilityOptions(prev, { facility: "ford_pavement", options: written }); +process.stdout.write(JSON.stringify({ + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.prefix ?? null, c.link]), + state, written, saved: saved.options, keys: s.specKeys(spec), retired: spec.retired, + links: spec.containers.map((c) => s.linkOf(c, 1)), + formKeys: merge.FORM_OPTION_KEYS.ford_pavement, heads: merge.FORM_OPTION_PREFIXES.ford_pavement, +})); +""" + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", "-e", SCRIPT, ROOT], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_컨테이너_경사각도_필수_도수로돌붙임_선택(out): + assert out["containers"] == [ + ["slope", "경사각도", True, None, "gfordpaveslope"], + ["chute", "도수로/돌붙임", False, "ford_chute", "gfordpavechute"], + ] + assert out["links"] == ["gfordpaveslope", "gfordpavechute1"] + + +def test_저장_값은_도_와_인스턴스_키(out): + assert out["state"] == {"slope": [{"angle": "12.5"}], "chute": [{"structure": "돌붙임"}]} + assert out["written"] == { + "ford_slope_deg": 15, + "ford_chute_1_structure": "돌붙임", + "ford_chute_2_structure": "도수로", + } + + +def test_옛_칸은_지난_정의_값으로_남음(out): + assert out["retired"] == [ + "ford_width_m", + "ford_height_m", + "ford_slope_pct", + "thickness_cm", + "length_m", + ] + # 폼이 모르는 옛 칸은 그대로 · 폼이 아는 머리(빠진 인스턴스)는 폼 값으로 갈아 끼움 + assert out["saved"] == { + "ford_width_m": 6, + "ford_height_m": 0.3, + "ford_slope_pct": 3, + "thickness_cm": 20, + "length_m": 7, + **out["written"], + } + assert out["formKeys"] == out["keys"]["keys"] == ["ford_slope_deg"] + assert out["heads"] == out["keys"]["prefixes"] == ["ford_chute_"] + + +def test_집계표_짝_명세와_같음(): + text = open( + os.path.join(ROOT, "A00_Common/design/design_structure_ford_pavement.ts"), encoding="utf-8" + ).read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["ford_pavement"]) + item = { + "type_id": "ford_pavement", + "options": {"ford_chute_1_structure": "도수로", "ford_slope_deg": 10}, + "복사본": "pipe@50.0", + } + rows = container_rows(item, {"gfordpavechute1": "SS000001"}) + assert [ + (r["복사본"], r["그룹"], r["summary_item"], r["options"]["structure"]) for r in rows + ] == [("pipe@50.0__gfordpavechute1", "도수로/돌붙임 1", "SS000001", "도수로")] + + +def test_정의_그룹_경사각도(): + with open( + os.path.join(ROOT, "resources/master_template/구조물정의.json"), encoding="utf-8" + ) as f: + groups = json.load(f)["그룹"]["ford_pavement"] + assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")] diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py new file mode 100644 index 000000000..90a973a9b --- /dev/null +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 세월교(표 04) 명세 + 월류 폭 ↔ 높이 연동 — PLAN 52-4. + +순수 규칙은 Node 헬퍼(`helper_52_4_ford.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-4_세월교_컨테이너.js`. +높이 거울 = 이 파일이 Manning(통수단면.md §2 · n 0.017 · S 0.10)을 따로 풀어 같은 값인지 봄. +""" + +import json +import math +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +N, S = 0.017, 0.10 + + +def _node(*args): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_4_ford.cjs"), *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(): + return _node() + + +def test_컨테이너_셋_모두_필수(out): + assert out["containers"] == [ + ["ford", "세월교", True], + ["inlet", "유입 구조", True], + ["outlet", "유출 구조", True], + ] + assert out["items"][0] == [ + ["diameter", "select", "pipe_diameter_mm"], + ["width", "number", "ford_width_m"], + ["height", "number", "ford_height_m"], + ["count", "number", "pipe_count"], + ] + assert out["choices"] == [ + [["800", "1000", "1200", "1500"]], + [["없음", "날개벽", "흙막이"]], + [["없음", "날개벽", "흙막이"]], + ] + + +def test_옛_날개벽_칸으로_구조를_세우고_같은_키로_씀(out): + assert out["legacy"] == {"inlet": "날개벽", "outlet": "없음"} + assert out["keepSoil"] == "흙막이" # 흙막이는 설치 칸 없음이어도 그대로 + assert out["b06Off"] == "없음" and out["b06On"] == "날개벽" # B06 조정창 설치 칸을 따름 + back = out["back"] + assert back["pipe_diameter_mm"] == 1000 and back["pipe_count"] == 2 + assert back["ford_width_m"] == 10 and back["ford_height_m"] == 0.3 + assert back["wing_in"] == "있음" and back["wing_out"] == "없음" + # 표 04 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) + assert "pipe_kind" not in back and "wing_in_height_m" not in back + assert "pipe_kind" in out["retired"] + + +def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out): + s = out["soilOut"] + assert s["inlet_type"] == "흙막이" and s["wing_in"] == "없음" + assert s["outlet_type"] == "날개벽" and s["wing_out"] == "있음" + + +def test_잇기_키_컨테이너마다(out): + assert out["links"] == ["gford", "gfordin", "gfordout"] + + +def _manning_depth(q, b): + """넓은 직사각 수로 Manning 을 h 에 대해 이분법으로 풂 — 헬퍼와 다른 풀이.""" + + def cap(h): + return b * h * (1 / N) * ((b * h) / (b + 2 * h)) ** (2 / 3) * math.sqrt(S) + + lo, hi = 0.0, 5.0 + for _ in range(100): + mid = (lo + hi) / 2 + lo, hi = (mid, hi) if cap(mid) < q else (lo, mid) + return hi + + +def test_월류_높이는_폭과_연동_필요_수심_올림(out): + for q, b, h in out["table"]: + need = _manning_depth(q, b) + assert h == pytest.approx(math.ceil(need * 100 - 1e-9) / 100), (q, b) + # 폭이 넓을수록 높이가 낮아짐 + row = [h for q, b, h in out["table"] if q == 2.54] + assert row == sorted(row, reverse=True) and len(set(row)) == len(row) + assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력 + assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"] + + +def test_정의_파일에_세월교_이름_표가_올라_있음(): + names = _node("names") + with open( + os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" + ) as f: + doc = json.load(f) + assert doc["종류"]["ford_bridge"]["이름"] == names diff --git a/resources/tester/test_52_6_cross_underdrain.py b/resources/tester/test_52_6_cross_underdrain.py new file mode 100644 index 000000000..b8146e009 --- /dev/null +++ b/resources/tester/test_52_6_cross_underdrain.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""52-6 횡단배수 › 맹암거(표 06 · 새 종류) — 정의 · 컨테이너 · 서버 저장 왕복. + +새 키 `cross_underdrain`(횡단배수 · 점형) — 종단배수 맹암거(`underdrain`)와 따로. +컨테이너 = 정의 그룹 「맹암거」(필수 · 늘 한 벌) — 암거 높이(m) · 암거 경사(%) 손 입력(비워 둘 수 있음) · +그룹 잇기(`group_items`)로 구조물 조합 항목. 저장은 임시 storage 에서만. +""" + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureInstance, + is_station_planting_type, + load_definition, + structure_type_map, +) + +KEY = "cross_underdrain" +GROUP = "gcrossud" + + +def test_새_종류는_횡단배수_점형_종단배수_맹암거와_따로(): + types = structure_type_map() + new, old = types[KEY], types["underdrain"] + assert (new.group, new.placement, new.name, new.enabled) == ("A", "point", "맹암거", True) + assert (old.group, old.placement) == ("B", "interval") + assert new.style["abbr"] != old.style["abbr"] and new.style["color"] != old.style["color"] + assert is_station_planting_type(new) # 횡단배수 = 그 측점에 횡단도 + + +def test_컨테이너_맹암거_높이_경사_손_입력(): + opts = {o.key: o for o in structure_type_map()[KEY].options} + assert list(opts) == ["culvert_height_m", "culvert_slope_pct"] + for key, label, unit in ( + ("culvert_height_m", "암거 높이", "m"), + ("culvert_slope_pct", "암거 경사", "%"), + ): + o = opts[key] + assert (o.label, o.input, o.unit, o.default, o.required) == ( + label, + "number", + unit, + None, + False, + ) + assert o.empty_means # 손 입력 · 기본값 근거 없음 — 비워 둘 수 있음(B05 단계 필수 금지) + groups = load_definition()["그룹"][KEY] + assert groups == [ + {"id": GROUP, "이름": "맹암거", "옵션": list(opts), "형상": {}}, + ] + + +def test_정의_검사_통과(): + from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + + assert definition.check(load_definition()) == [] + + +def _item(**options): + return StructureInstance( + type_id=KEY, + placement="point", + chainage_m=63.5, + options=options, + group_items={ + GROUP: {"layer": "system", "owner": None, "key": "SS000001", "name": "맹암거"} + }, + ) + + +def test_저장_다시_읽기_그대로(tmp_path): + from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures + + item = _item(culvert_height_m=0.6, culvert_slope_pct=2.5) + save_structures(str(tmp_path), [item], base_revision=0) + _, loaded = load_structures(str(tmp_path)) + assert [(s.type_id, s.chainage_m, s.options) for s in loaded] == [ + (KEY, 63.5, {"culvert_height_m": 0.6, "culvert_slope_pct": 2.5}) + ] + assert loaded[0].group_items == item.group_items + + +@pytest.mark.parametrize( + "options", + [{"culvert_height_m": -1}, {"culvert_slope_pct": -0.5}], +) +def test_음수는_서버가_막음(tmp_path, options): + from B05_Profile.B05_Profile_Structures_Repository import save_structures + + with pytest.raises(ValueError): + save_structures(str(tmp_path), [_item(**options)], base_revision=0) diff --git a/resources/tester/test_52_7_safety.py b/resources/tester/test_52_7_safety.py new file mode 100644 index 000000000..824382bef --- /dev/null +++ b/resources/tester/test_52_7_safety.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""안전부대용지(표 07) — PLAN 52-7. + +정의(대피로 · 가드레일 컨테이너 · 점형 일곱) · 옛 대피 종류 읽기(`asRoad` · `pickable`) · +화면은 ORCA `ui_checks/52-7_안전부대용지.js`. +""" + +import json +import os +import subprocess + +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +POINTS = [ + "mirror", + "curb", + "hazard_sign", + "position_sign", + "water_intake", + "completion_sign", + "barrier_gate", +] + + +def test_정의_검사_통과(): + assert definition.check(definition.read()) == [] + + +def test_컨테이너_없는_일곱은_점형_옵션_없음(): + types = structure_type_map() + for tid in POINTS: + t = types[tid] + assert (t.group, t.placement, t.options) == ("E", "point", []), tid + assert [types[t].name for t in ("curb", "hazard_sign", "water_intake")] == [ + "경계석", + "위험표시", + "취수정", + ] + + +def test_대피로_가드레일_상세_내용_컨테이너(): + doc = definition.read() + groups = doc["그룹"] + assert groups["evac_road"] == [ + {"id": "gevacdetail", "이름": "상세 내용", "옵션": ["detail"], "형상": {}} + ] + assert groups["guardrail"][0]["옵션"] == ["form", "length_m", "before_m", "after_m"] + types = structure_type_map() + road = types["evac_road"] + assert road.placement == "point" + assert road.options[0].choices == ["대피소", "정차/작업장", "차돌림곳"] + guard = types["guardrail"] + assert guard.name == "가드레일" and guard.placement == "interval" + assert [(o.key, o.input) for o in guard.options] == [ + ("form", "text"), + ("length_m", "number"), + ("before_m", "number"), + ("after_m", "number"), + ] + # 옛 대피 종류는 정의에 그대로(옛 기록 도면 · 집계) + assert {"refuge", "work_yard", "turnaround"} <= set(types) + + +NODE = r""" +const fs = require("fs"), path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const src = fs.readFileSync( + path.join(process.argv[1], "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; +const m = { exports: {} }; +new Function("exports", "module", js)(m.exports, m); +const d = m.exports; +process.stdout.write(JSON.stringify({ + old: d.asRoad({ type_id: "turnaround", options: { width_m: 10 } }), + other: d.asRoad({ type_id: "mirror", options: { facing: "좌" } }), + pick: ["refuge", "work_yard", "turnaround", "evac_road", "guardrail"].map( + (type_id) => d.pickable({ type_id })), +})); +""" + + +def test_옛_대피_종류는_대피로_상세로_읽고_목록에서_뺌(): + proc = subprocess.run( + ["node", "-e", NODE, ROOT], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["old"] == { + "type_id": "evac_road", + "options": {"detail": "차돌림곳", "width_m": 10}, + } + assert out["other"] == {"type_id": "mirror", "options": {"facing": "좌"}} + assert out["pick"] == [False, False, False, True, True] diff --git a/resources/tester/test_52_7c_legacy_roundtrip.py b/resources/tester/test_52_7c_legacy_roundtrip.py new file mode 100644 index 000000000..e02ed795b --- /dev/null +++ b/resources/tester/test_52_7c_legacy_roundtrip.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""옛 종류 구조물 저장 왕복(PLAN 52-7c) — 저장 · 다시 열기 · 다시 저장 뒤에도 수 · 종류 그대로. + +옛 대피소 · 정차·작업장 · 차돌림곳(52-7 고르기 목록 뺌) · 종단배수 · 계류사방(52-8 숨김) 모두. +임시 storage(tmp_path) · 라우터 PUT/GET. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import B05_Profile.B05_Profile_Structures_Router as router_module +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +PROJECT_ID = "52700000-0000-0000-0000-00000000007c" +URL = f"/api/projects/{PROJECT_ID}/route/structures" +LEGACY = ("refuge", "work_yard", "turnaround") + + +def _client(tmp_path, monkeypatch): + async def project_root(project_id): + return str(tmp_path / "project") + + async def invalidate(project_id): + return True + + async def route_length(project_id): + return None + + monkeypatch.setattr(router_module, "_project_root", project_root) + monkeypatch.setattr(router_module, "_invalidate_downstream", invalidate) + monkeypatch.setattr(router_module, "_route_length", route_length) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _record(type_, at): + options = {} + for o in type_.options: + if o.default not in (None, ""): + options[o.key] = o.default + elif o.required: + options[o.key] = o.choices[0] if o.choices else 1 + record = {"type_id": type_.type_id, "placement": type_.placement, "options": options} + if type_.placement == "interval": + record.update(chainage_m=at, start_m=at, end_m=at + 10) + else: + record["chainage_m"] = at + return record + + +def _kinds(body): + return sorted((s["type_id"], s["chainage_m"]) for s in body["structures"]) + + +def test_옛_종류_저장_왕복_수_그대로(tmp_path, monkeypatch): + types = structure_type_map() + ids = [*LEGACY, *(t for t, v in types.items() if v.group in ("B", "D") and not v.managed_by)] + assert len(ids) >= 3 + 6 + 3 # 기슭막이 = pipe_points 정본 + records = [_record(types[t], 20.0 * (i + 1)) for i, t in enumerate(ids)] + client = _client(tmp_path, monkeypatch) + + first = client.put(URL, json={"base_revision": 0, "structures": records}) + assert first.status_code == 200, first.text + opened = client.get(URL).json() + assert len(opened["structures"]) == len(ids) + want = _kinds(opened) + assert sorted(t for t, _ in want) == sorted(ids) + + # 다시 연 목록 그대로 저장 → 다시 열기 + again = client.put( + URL, json={"base_revision": opened["revision"], "structures": opened["structures"]} + ) + assert again.status_code == 200, again.text + reopened = client.get(URL).json() + assert _kinds(reopened) == want + by_id = {s["structure_id"]: s["options"] for s in opened["structures"]} + assert {s["structure_id"]: s["options"] for s in reopened["structures"]} == by_id diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py new file mode 100644 index 000000000..2091a01a1 --- /dev/null +++ b/resources/tester/test_52_structure_spec_box.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 박스암거(표 02 고친 판) 명세 — PLAN 52-2. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec_box.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-2_박스암거_컨테이너.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +def _node(*args): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec_box.cjs"), *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(): + return _node() + + +def test_컨테이너_셋_모두_필수(out): + assert out["containers"] == [ + ["body", "본체", True], + ["inlet", "유입 구조", True], + ["outlet", "유출 구조", True], + ] + assert out["links"] == ["gboxbody", "gboxin", "gboxout"] + + +def test_선택지_표_02_그대로(out): + assert out["sizes"] == ["2.0×2.0", "3.0×3.0", "사용자 입력"] + assert out["structures"] == ["없음", "날개벽", "흙막이"] + + +def test_사용자_입력일_때만_가로_세로_칸(out): + assert out["customHidden"] is None + assert out["customShown"] == {"key": "body_width_m"} + + +def test_옛_값_읽기_규격_밖은_사용자_입력_있음은_날개벽(out): + st = out["old"]["state"] + assert st["body"][0] == {"size": "사용자 입력", "width": "3", "height": "2.5"} + assert st["inlet"][0]["structure"] == "날개벽" + back = out["old"]["out"] + assert back == {"body_width_m": 3, "body_height_m": 2.5, "wing_in": "날개벽"} + # 옛 날개벽 치수는 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) + assert "wing_in_length_m" not in back + assert "wing_in_length_m" in out["retired"] + + +def test_규격은_폭_높이_수로_저장_규격_칸은_저장_안_함(out): + assert out["preset"]["state"]["body"][0]["size"] == "2.0×2.0" + assert out["preset"]["out"] == {"body_width_m": 2, "body_height_m": 2, "wing_out": "없음"} + to = out["toPreset"]["out"] + assert to["body_width_m"] == 3 and to["body_height_m"] == 3 + assert to["wing_out"] == "흙막이" + assert out["toCustom"]["out"] == {"body_width_m": 2.4, "body_height_m": 1.8} + assert "size" not in json.dumps(out["keys"]) + + +def test_빈_값은_빈_채로(out): + assert out["blank"]["out"] == {} + assert out["blank"]["state"]["body"][0]["size"] == "" + + +def test_사용자_입력_빈_칸은_다시_열어도_사용자_입력(out): + # 52-19 — 가로만 비움: 세로 손 값이 남아 사용자 입력 + 빈 가로 + assert out["oneBlank"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "2.5", + } + assert out["oneBlank"]["out"] == {"body_height_m": 2.5} + # 둘 다 비움: 표시 키만 적고 다시 열면 사용자 입력 + 빈 칸 둘 + assert out["bothBlank"]["out"] == {"body_custom": "사용자 입력"} + assert out["bothBlankAgain"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "", + } + assert out["bothBlankAgain"]["out"] == {"body_custom": "사용자 입력"} + + +def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): + names = _node("names") + with open( + os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" + ) as f: + doc = json.load(f) + assert set(names) == set(doc["종류"]["box_culvert"]["이름"]) + assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"] + box = next(t for t in doc["types"] if t["type_id"] == "box_culvert") + wing = {o["key"]: o for o in box["options"]} + for key in ("wing_in", "wing_out"): + assert wing[key]["choices"] == ["없음", "날개벽", "흙막이"] diff --git a/resources/tester/test_53_2_etc_table_name.py b/resources/tester/test_53_2_etc_table_name.py new file mode 100644 index 000000000..166f82c04 --- /dev/null +++ b/resources/tester/test_53_2_etc_table_name.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""기타 집계표 머리(이름마다 펼침) = 목록 이름 — 손 이름 · 비면 「기타」 — PLAN 53-2. + +화면은 ORCA `ui_checks/53-2_목록_강조.js`. +""" + +from M02_MasterTemplete import M02_Table_Fill as fill +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + _save, + world, +) + +SOURCE = {"종류": "etc", "펼침": ["name"]} + + +def test_기타_표_머리는_손_이름_비면_기타(world): # noqa: F811 — 고정물 + base = {"type_id": "etc", "placement": "point"} + named = {**base, "structure_id": "e1", "chainage_m": 20.0, "options": {"name": " 배수판 "}} + same = {**base, "structure_id": "e2", "chainage_m": 40.0, "options": {"name": "배수판"}} + space = {**base, "structure_id": "e3", "chainage_m": 60.0, "options": {"name": " "}} + none = {**base, "structure_id": "e4", "chainage_m": 80.0, "options": {}} + _save(world["root"], [named, same, space, none], []) + items = {i["복사본"]: i for i in fill.collect_items(world["root"])} + heads = {key: fill._expansion(SOURCE, items[key]) for key in ("e1", "e2", "e3", "e4")} + assert heads == {"e1": ("배수판",), "e2": ("배수판",), "e3": ("기타",), "e4": ("기타",)} + assert [items[key]["그룹"] for key in ("e1", "e3", "e4")] == ["배수판", "기타", "기타"] diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py new file mode 100644 index 000000000..480fda24a --- /dev/null +++ b/resources/tester/test_54_2_help_files.py @@ -0,0 +1,38 @@ +"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜).""" + +import tempfile +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from common_util.common_util_help_router import HELP_ROOT, read_help + + +def test_every_page_has_manual_first(): + for n in range(2, 10): + items = read_help(f"B0{n}") + assert items and items[0]["key"] == "00_설명서" + + +def test_title_order_and_live_edit(): + tmp_path = Path(tempfile.mkdtemp()) + page = tmp_path / "B05" + page.mkdir() + (page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8") + (page / "00_설명서.md").write_text("제목 없음", encoding="utf-8") + items = read_help("B05", tmp_path) + assert [i["key"] for i in items] == ["00_설명서", "10_배관"] + assert [i["title"] for i in items] == ["00_설명서", "배관"] + (page / "10_배관.md").write_text("# 배관\n\n고친 글", encoding="utf-8") + assert "고친 글" in read_help("B05", tmp_path)[1]["text"] + + +@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"]) +def test_bad_page_refused(page): + with pytest.raises(HTTPException): + read_help(page) + + +def test_root_is_repo_resources(): + assert HELP_ROOT.parts[-2:] == ("resources", "help") diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 13728118d..33b63cf3e 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -105,13 +105,19 @@ def test_세션에_적고_읽는다(out): def test_부재키를_안_주면_지키고_해제하면_비운다(out): assert out["keyKept"] == "own" + assert out["keyDropped"] is None # 다른 구조물로 옮기면 옛 부재가 안 따라옴(53-1) assert out["cleared"] is None def test_같은_값은_알리지_않고_출처를_함께_알린다(out): assert out["sameQuiet"] == 1 - assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]] - assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림 + assert out["heardFrom"] == [ + [205, "wall", "b06"], + [205.2, "wall", "b06"], + [173.09, None, "b05"], + [None, None, "b05"], + ] + assert out["afterOff"] == 4 # 구독을 끊으면 더는 안 들림 def test_알림_도중_다시_낸_값은_적기만_한다(out): @@ -172,6 +178,13 @@ def test_B05_되살리기_적힌_것이_없으면_끝난_것(out): assert out["restore"]["nothing"] == {"calls": [], "done": True} +def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out): + # B06 에서 고른 반사경(22+0) — 측점이 관을 먼저 열어도 id 의 구조물로 연다(53-1) + over = out["restore"]["structureOverPipe"] + assert over["calls"][-1] == ["selectAt", 440.24, "mirror"] + assert over["done"] is True + + # ── 배선 ─────────────────────────────────────────────────────────────── def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index 4fcdd9ed2..823499553 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -38,7 +38,7 @@ def test_새_관만_기본값_빈_키에만(): assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") basins._apply_new_defaults([box]) - assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음" + assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "날개벽" def test_원본에_있던_관은_빈칸_그대로(monkeypatch): diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py new file mode 100644 index 000000000..d31a94891 --- /dev/null +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -0,0 +1,74 @@ +"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감. + +측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침. +다른 구조물 라벨(옹벽 등)은 건드리지 않음. +""" + +import json + +from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations +from common_util.common_util_drainage_pipes import parse_pipe_points + + +def _write_points(root, points): + edits = root / "B04_PreProcess" / "drainage" / "edits" + edits.mkdir(parents=True, exist_ok=True) + (edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8") + + +def _sections(): + return [ + {"chainage_m": 80.0}, + {"chainage_m": 85.05, "structure": "파형강관 D900"}, + {"chainage_m": 120.0, "structure": "옹벽"}, + {"chainage_m": 173.09, "structure": "세월교"}, + {"chainage_m": 258.12, "structure": "파형강관 D800"}, + ] + + +def test_relabel_follows_saved_kind(tmp_path): + _write_points( + tmp_path, + [ + {"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"}, + {"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리 + { + "chainage_m": 173.09, + "source": "stream", + "facility": "pipe", + "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200}, + }, + {"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"}, + ], + ) + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + labels = {s["chainage_m"]: s.get("structure") for s in sections} + assert labels == { + 80.0: None, + 85.05: "BOX암거", + 120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로 + 173.09: "흄관 D1200", + 258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음) + } + + +def test_relabel_matches_generation_rule(tmp_path): + points = [ + {"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}}, + {"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"}, + ] + _write_points(tmp_path, points) + sections = [ + {"chainage_m": 85.05, "structure": "세월교"}, + {"chainage_m": 173.09, "structure": "BOX암거"}, + ] + relabel_pipe_stations(tmp_path, sections) + generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points))) + assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]] + + +def test_relabel_without_file_keeps_labels(tmp_path): + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + assert sections == _sections() diff --git a/resources/tester/test_b05_structures_define.py b/resources/tester/test_b05_structures_define.py index 1cb497663..a767f877c 100644 --- a/resources/tester/test_b05_structures_define.py +++ b/resources/tester/test_b05_structures_define.py @@ -74,7 +74,7 @@ def test_정의_덧칸_얹기(out): def test_판1_정의는_지금_화면과_같음(out): - assert out["v1Types"] == 37 + assert out["v1Types"] == 42 # 52-6 맹암거 · 52-7 대피로 · 경계석 · 위험표시 · 취수정 assert out["v1Same"] is True assert out["v1Columns"] == [2] assert out["v1Anchor"] is True diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 2f2c006c7..9577096c2 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family(): def test_all_enabled_non_managed_types_are_quick_addable(): - """잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1. + """잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 14 + G군 1(콘크리트 포장) + 기타 1. 콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로 받는 정본이 됐다(종단경사 자동 포장 적용을 대체). @@ -201,8 +201,10 @@ def test_all_enabled_non_managed_types_are_quick_addable(): 소단 규격은 법령·교본마다 갈려 프로그램이 판정하지 않고 **사용자가 구간에 놓는다** — 그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이 아니라 여기 수에 안 잡힌다.) + + E군이 10에서 14로 늘었다 — 표 07(PLAN 52-7) 대피로 · 경계석 · 위험표시 · 취수정. """ - assert len(_quick_add_types()) == 23 + assert len(_quick_add_types()) == 28 def test_removed_types_are_absent(): @@ -366,6 +368,7 @@ def test_a6_a7_types_exist_for_manual_placement(): item = by_id[type_id] assert item.group == "A" and item.managed_by is None assert item.placement == "point" + assert not item.options, f"{type_id} 옵션 없음 — 기준 측점 + 종류 단위 잇기만(PLAN 52-5)" def test_ford_bridge_shares_pipe_kind_and_diameter(): @@ -544,8 +547,9 @@ def test_box_culvert_wing_wall_options_defined(): for side in ("in", "out"): toggle = options[f"wing_{side}"] assert toggle.phase == "b05" and not toggle.required - assert toggle.choices == ["있음", "없음"] - assert toggle.default == "있음", f"wing_{side} 기본 설치" + # 표 02 고친 판(PLAN 52-2) — 유입 · 유출 구조 = 없음 · 날개벽 · 흙막이 + assert toggle.choices == ["없음", "날개벽", "흙막이"] + assert toggle.default == "날개벽", f"wing_{side} 기본 구조" for dim, value in expected.items(): option = options[f"wing_{side}_{dim}"] assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}" diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index e78ba74a2..6cdbdd9a3 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -77,7 +77,11 @@ def test_창_키_듣기를_뗌(): def test_겹친_알약은_누른_것을_그대로(): profile = _read("A00_Common/design/design_profile.ts") - assert "stationIdAtStructure(selectedStructureId) === stationId" in profile + assert "structureIdKeeping(stationId, selectedStructureId" in profile + # 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2) + rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts") + assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule + assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule def test_진행_단계_두_칸이_보기를_따라감(): diff --git a/resources/tester/test_m02_so_def_spec.py b/resources/tester/test_m02_so_def_spec.py new file mode 100644 index 000000000..e575dad1a --- /dev/null +++ b/resources/tester/test_m02_so_def_spec.py @@ -0,0 +1,74 @@ +"""M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증. + +Node 헬퍼(`helper_m02_so_def_spec.cjs`)가 TS 를 트랜스파일해 진짜 정의 사본(메모리)으로 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배관 명세 나무: 컨테이너 5 · 항목 · 값 줄 · 이름 키 겹침 없음 · 필수/선택 · 꼴 표시. + - 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀. + - 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음. + - 이름 표가 빈 종류는 명세 글로 읽음. + - 정의 그룹 꼴(52-12): 그룹 제목 › 칸 › 선택지 · 제목 = 그룹 이름 · 칸 = 옵션 이름 · + 선택지 = 이름 표(값 그대로) · 칸 없는 종류는 줄 없음. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_spec.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배관_나무(r) -> None: + assert r["levels"][0] == 5 + assert r["levels"][1] > 5 and r["levels"][2] > 10 + assert r["unique"] is True + assert r["pipeTitle"]["tag"] == "required" and r["pipeTitle"]["group"] == "grv6lny" + assert r["optional"]["tag"] == "optional" and r["optional"]["group"] is None + assert r["basinForm"]["label"] == "I형" and r["basinForm"]["key"] == "I" + + +def test_이름_고침_자리(r) -> None: + assert r["groupName"] == "관 본체" + assert r["names"] == [None, "관 지름", "Ø800", "흙막이 더"] + assert r["reread"] == ["관 본체", "관 지름", "Ø800", "흙막이 더"] + + +def test_구조는_그대로(r) -> None: + assert r["sameTypes"] is True and r["sameKeys"] is True + + +def test_이름_표_없는_종류(r) -> None: + assert r["bareLabel"] == "흙막이" + assert r["bareGroup"] == "유입" + + +def test_정의_그룹_꼴(r) -> None: + assert r["udRows"] == [ + [0, "#gcrossud", "맹암거"], + [1, "culvert_height_m", "암거 높이"], + [1, "culvert_slope_pct", "암거 경사"], + ] + assert r["udGroup"] == "맹암거 본체" and r["udLabel"] == "암거 높이 칸" + assert r["sideHead"]["nameKey"] == "" and r["sideHead"]["level"] == 0 + assert r["sideChoice"] == [2, "L형"] + assert r["sideName"] == "L 모양" and r["sideReread"] == "L 모양" + assert "L형" in r["sideChoices"] + assert r["bareKind"] == 0 diff --git a/resources/tester/test_m02_structure_definition.py b/resources/tester/test_m02_structure_definition.py index d4df44197..675dd2af3 100644 --- a/resources/tester/test_m02_structure_definition.py +++ b/resources/tester/test_m02_structure_definition.py @@ -3,7 +3,7 @@ 못박는 것 - 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과. - 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403). - - 저장마다 판 +1 · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). + - 저장마다 판 +1(지금 판에서) · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). - 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상. - 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존. """ @@ -49,12 +49,16 @@ def _client(session: dict) -> TestClient: def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: - doc = definition.read() + doc = definition.read_version(1) assert doc["판"] == 1 and len(doc["types"]) == 37 assert sum(len(t["options"]) for t in doc["types"]) == 235 - assert len(load_structure_types()) == 37 - assert sum(len(t.options) for t in load_structure_types()) == 235 assert definition.check(doc) == [] + now = definition.read() + assert now["판"] >= 1 and definition.check(now) == [] + assert len(load_structure_types()) == len(now["types"]) + assert sum(len(t.options) for t in load_structure_types()) == sum( + len(t["options"]) for t in now["types"] + ) assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"] assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"]) @@ -62,26 +66,28 @@ def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None: user = _client(USER) got = user.get(URL) - assert got.status_code == 200 and got.json()["판"] == 1 - assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403 - assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀 + v = json.loads(copy.read_text("utf-8"))["판"] + assert got.status_code == 200 and got.json()["판"] == v + assert user.put(URL, json={"판": v, "정의": got.json()}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == v # 안 씀 def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None: admin = _client(ADMIN) doc = admin.get(URL).json() + v = doc["판"] tid = doc["types"][0]["type_id"] doc["types"][0]["name"] = "이름 바꿈" - res = admin.put(URL, json={"판": 1, "정의": doc}) + res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 200, res.text - assert res.json()["판"] == 2 - old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8")) - assert old["판"] == 1 and old["types"][0]["name"] != "이름 바꿈" + assert res.json()["판"] == v + 1 + old = json.loads((copy.parent / "구조물정의_이력" / f"판_{v}.json").read_text("utf-8")) + assert old["판"] == v and old["types"][0]["name"] != "이름 바꿈" seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]} - assert seen[tid]["name"] == "이름 바꿈" and len(seen) == 37 - assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판 - assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3 - assert (copy.parent / "구조물정의_이력" / "판_2.json").is_file() + assert seen[tid]["name"] == "이름 바꿈" and len(seen) == len(doc["types"]) + assert admin.put(URL, json={"판": v, "정의": doc}).status_code == 409 # 낡은 판 + assert admin.put(URL, json={"판": v + 1, "정의": res.json()}).json()["판"] == v + 2 + assert (copy.parent / "구조물정의_이력" / f"판_{v + 1}.json").is_file() def _rule(doc: dict) -> tuple[str, dict, dict]: @@ -96,6 +102,7 @@ def _rule(doc: dict) -> tuple[str, dict, dict]: def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: admin = _client(ADMIN) base = admin.get(URL).json() + v = base["판"] tid, up, down = _rule(base) good = { "종류": tid, @@ -133,13 +140,13 @@ def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: bad(모르는칸=1), ] for i, doc in enumerate(cases): - res = admin.put(URL, json={"판": 1, "정의": doc}) + res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 422, (i, res.text) - assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + assert json.loads(copy.read_text("utf-8"))["판"] == v # 거절되면 안 씀 assert not (copy.parent / "구조물정의_이력").exists() shaped = bad(종류={tid: {"기준측점": False}}) shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}} - assert admin.put(URL, json={"판": 1, "정의": shaped}).status_code == 200 + assert admin.put(URL, json={"판": v, "정의": shaped}).status_code == 200 def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None: @@ -154,5 +161,5 @@ def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_pa ) assert save_structures(root, [item], base_revision=0) == 1 raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8")) - assert raw["definition_version"] == 1 + assert raw["definition_version"] == definition.read()["판"] assert load_structures(root)[1][0].options["지난정의키"] == "옛값" diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js new file mode 100644 index 000000000..10ca498ab --- /dev/null +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -0,0 +1,249 @@ +/* 52-10 — B05 · B06 좌측 「구조물 배치」 노면공 › 콘크리트 포장 · 기타 = 사용자 구조물 표 08 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_보조) · 폼 비어 있음 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5210 = "단계";` 를 붙여 + * orca eval --expression "window.__m5210='edit'; $(cat 이 파일)" → 두 종류 모양 · 값 · 잇기 · [추가] · [저장] + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m5210='verify'; $(cat 이 파일)" → 저장 값 그대로 · 같은 모양 + * (B06 으로 옮겨 같은 단계 — 같은 값 · 같은 모양) + * orca eval --expression "window.__m5210='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) + * 기대 수치: + * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · + * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 + * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · + * 선택 컨테이너 「상세 내용」(52-14) 하나만(52-17) — 추가 → [추가] 없음 · 빼기 → 다시 보임 · + * 다시 추가 · 비고 「52-14 비고」(길이 상한 100) · 잇기 · 하나 남음 · 종류 단위 잇기 줄 가림 + * verify — 같은 값 · 같은 모양(B05 = B06) + */ +(async () => { + const MODE = window.__m5210 || "edit"; + const NAME = "52-10 시험 기타"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => !!n && n.offsetParent !== null; + const sec = () => document.querySelector(".b05-structure-section"); + const body = () => sec().querySelector(".b05-route__panel-body"); + const spec = () => sec().querySelector(".b05-structure__spec"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const ITEMS = [ + { typeId: "pavement_concrete", group: "G", at: [4, 6], list: /콘크리트 포장/ }, + { typeId: "etc", group: "호환", at: [5, 2], list: new RegExp(NAME) }, + ]; + const typeSummary = () => + [...body().children].find((c) => c.classList.contains("b05-structure__summary")); + const optionRow = () => + [...body().children].filter((c) => c.classList.contains("b05-structure__grid"))[1]; + const optionLabels = () => + vis(optionRow()) + ? [...optionRow().querySelectorAll(":scope > label > span:first-child")].map((s) => + s.textContent.trim(), + ) + : []; + const specCtl = (item) => + spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input"); + + const shape = (typeId) => { + const position = [...body().children].find((c) => + c.classList.contains("b05-structure__position-row"), + ); + const after = [...body().children] + .slice([...body().children].indexOf(position) + 1) + .find((c) => vis(c)); + const nameInput = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + ?.querySelector("input"); + const boxes = vis(spec()) + ? [...spec().querySelectorAll("[data-spec-container]")].filter(vis) + : []; + return { + typeId, + options: optionLabels(), + containers: boxes.map((b) => b.dataset.specContainer), + legend: boxes.map((b) => b.querySelector("legend").firstChild.textContent), + paveChoices: specCtl("spec") + ? [...specCtl("spec").options].filter((o) => o.value).map((o) => o.value) + : null, + paveSpec: specCtl("spec")?.value ?? null, + height: specCtl("height")?.value ?? null, + containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), + containerLinks: boxes.map( + (b) => !!b.querySelector(".b05-structure__summary-name.is-assigned"), + ), + notes: boxes.map( + (b) => b.querySelector('[data-spec-item="detail.note"] input')?.value ?? null, + ), + noteMax: spec()?.querySelector('[data-spec-item="detail.note"] input')?.maxLength ?? null, + typeSummaryVisible: vis(typeSummary()), + typeSummaryAfterPosition: after === typeSummary(), + typeLink: !!typeSummary().querySelector(".b05-structure__summary-name.is-assigned"), + name: nameInput?.value ?? null, + stale: vis(sec().querySelector(".b05-structure__stale")), + locked: !!spec()?.inert, + }; + }; + const pickType = async (group, typeId) => { + const [g, t] = selects(); + fire(g, group); + await w(300); + fire(t, typeId); + await w(500); + }; + const linkFirstLeaf = async (button) => { + button.click(); + await until(() => document.querySelector(".ui-item-picker")); + await w(600); + const modal = document.querySelector(".ui-item-picker"); + const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const leaf = [...modal.querySelectorAll("button")].find((b) => { + const t = b.textContent.trim(); + return t && !/^[▸▾]/.test(t) && !ctl.includes(t); + }); + leaf?.click(); + await w(300); + [...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click(); + await w(500); + return !!leaf; + }; + const clickBtn = async (text) => { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === text && vis(b)) + ?.click(); + await w(1500); + }; + // B06 은 첫 눌림이 그 측점의 다른 항목(관 등)을 고르기도 함 — 목록이 다시 그려지므로 새로 찾아 한 번 더 + const open = async (item) => { + for (let k = 0; k < 3 && selects()[1].value !== item.typeId; k++) { + const li = listItems().find((l) => item.list.test(l.textContent)); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(800); + } + return selects()[1].value === item.typeId; + }; + const out = { mode: MODE, items: [] }; + + if (MODE === "edit") { + await until(() => listItems().length > 0); + out.before = listItems().length; + for (const item of ITEMS) { + await pickType(item.group, item.typeId); + const row = { lockedBeforePosition: shape(item.typeId).locked }; + const [no, dist] = sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(no, String(item.at[0])); + fire(dist, String(item.at[1])); + await w(300); + if (item.typeId === "pavement_concrete") { + fire(specCtl("spec"), "콘크리트"); + await w(200); + fire(specCtl("height"), "0.35"); + await w(200); + row.picked = await linkFirstLeaf( + spec().querySelector('[data-spec-link] [data-b05-summary="pick"]'), + ); + } else { + const input = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + .querySelector("input"); + input.value = NAME; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + // 52-14 · 52-17 — 상세 내용 하나만: 추가 → [추가] 없음 · 빼기 → 다시 보임 · 다시 추가 · 비고 · 잇기 + const addShown = () => !!spec().querySelector('[data-spec-add="detail"]'); + const notes = () => [...spec().querySelectorAll('[data-spec-item="detail.note"] input')]; + const note = async (text) => { + notes()[0].value = text; + notes()[0].dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + row.addAfterOne = addShown(); + await note("지울 것"); + spec().querySelector('[data-spec-remove="detail"]').click(); + await w(300); + row.addAfterRemove = addShown(); + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + await note("52-14 비고"); + row.picked = await linkFirstLeaf( + spec().querySelector('[data-spec-link] [data-b05-summary="pick"]'), + ); + row.addAfterReAdd = addShown(); + } + Object.assign(row, shape(item.typeId), { phase: "edit" }); + sec().querySelector(".is-primary").click(); // 추가 + await w(800); + out.items.push(row); + } + out.after = listItems().length; + await clickBtn("저장"); + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "verify") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) { + out.items.push({ typeId: item.typeId, missing: true }); + continue; + } + await w(300); + out.items.push({ ...shape(item.typeId), phase: "verify" }); + const li = listItems().find((l) => item.list.test(l.textContent)); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(300); + } + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "restore") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) continue; + await clickBtn("삭제"); + } + await clickBtn("저장"); + out.left = listItems().length; + out.leftover = listItems().filter((l) => /52-10/.test(l.textContent)).length; + } + const [pave, etc] = out.items; + out.paveOk = + !!pave && + !pave.missing && + JSON.stringify(pave.containers) === '["detail"]' && + pave.legend[0] === "상세 내용" && + JSON.stringify(pave.paveChoices) === '["콘크리트"]' && + pave.paveSpec === "콘크리트" && + Number(pave.height) === 0.35 && + pave.containerLink && + !pave.typeSummaryVisible && + !pave.options.some((l) => /두께|줄눈|철망|비닐|포장 폭|확폭/.test(l)) && + ["길이", "기준측점 전", "기준측점 후"].every((l) => pave.options.some((o) => o.startsWith(l))); + out.etcOk = + !!etc && + !etc.missing && + etc.name === NAME && + JSON.stringify(etc.containers) === '["detail"]' && + JSON.stringify(etc.legend) === '["상세 내용 1"]' && + JSON.stringify(etc.notes) === '["52-14 비고"]' && + JSON.stringify(etc.containerLinks) === "[true]" && + (etc.phase !== "edit" || + (etc.addAfterOne === false && etc.addAfterRemove === true && etc.addAfterReAdd === false)) && + etc.noteMax === 100 && + !etc.typeSummaryVisible; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-11_정의_이름고치기.js b/resources/tester/ui_checks/52-11_정의_이름고치기.js new file mode 100644 index 000000000..125dd3622 --- /dev/null +++ b/resources/tester/ui_checks/52-11_정의_이름고치기.js @@ -0,0 +1,270 @@ +/* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림 + * 52-12 — 정의 그룹 꼴 종류(맹암거 · 개거 · 바닥막이 …)도 같은 이름 나무 · 옛 편집기는 모든 종류에서 숨김 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여 + * M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자: + * orca eval --expression "window.__m5211='m02'; $(cat 이 파일)" + * B05 종단설계(#/b05-profile) · 목록에 「배관 1」: + * orca eval --expression "window.__m5211='b05'; $(cat 이 파일)" + * 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 · + * B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림 + * 기대 수치(m02): + * - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류도 이름 나무 + * - 맹암거 = 그룹 제목 › 칸 2 · 고치면 미리보기 그룹 상자 제목 · 칸 이름 따라감 + * - 바닥막이 = 선택지 이름 고치면 미리보기 보기 글 따라감(값 그대로) · 개거 = 고칠 이름 없음 · 종류 이름만(왼쪽 목록 따라감) + * - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님) + * - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐 + * - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로 + * 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true + * group.* = 맹암거 그룹 제목 · 칸 이름 · 바닥막이 선택지 글이 새 구조물 폼에 그대로(값 그대로) + */ +(async () => { + const MODE = window.__m5211 || "m02"; + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 10000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const put = (box, value) => { + box.value = value; + box.dispatchEvent(new Event("input", { bubbles: true })); + box.dispatchEvent(new Event("change", { bubbles: true })); + }; + const NEW = { pipe: "관 본체X", "pipe.diameter": "관 지름X", "pipe.diameter=800": "Ø800X" }; + const out = { mode: MODE }; + + if (MODE === "m02") { + const getDef = async () => + (await fetch("/api/m02/structure-definition", { credentials: "include" })).json(); + const first = await getDef(); + out.판 = first.판; + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ error: "정의 줄 없음" }); + row.click(); + if (!(await until(() => $$("[data-sod-kind]").length))) + return JSON.stringify({ error: "종류 목록 없음" }); + // 명세 없는 종류 — 옛 편집기 숨김 · 이름 나무(52-12) + $('[data-sod-kind="retaining_wall"]').click(); + await W(200); + out.other = { + base: !!$("[data-sod-base]"), + spec: !!$("[data-sod-spec-block]"), + blocks: $("[data-sod-detail]").children.length, + }; + const nameBox = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`); + const groupPreview = () => $("[data-sod-spec-preview]"); + // 맹암거 — 그룹 제목 › 칸 + $('[data-sod-kind="cross_underdrain"]').click(); + await W(200); + { + const keys = $$("[data-sod-spec]").map((r) => r.dataset.sodSpec); + const g0 = nameBox("#gcrossud").value; + const o0 = nameBox("culvert_height_m").value; + put(nameBox("#gcrossud"), "맹암거X"); + await W(150); + put(nameBox("culvert_height_m"), "암거 높이X"); + await W(150); + const pv = groupPreview(); + out.ud = { + keys, + legend: [...pv.querySelectorAll("legend")].some((l) => l.textContent === "맹암거X"), + label: pv.textContent.includes("암거 높이X (m)"), + old: !!$("[data-sod-option]") || !!$("[data-sod-base]"), + }; + put(nameBox("#gcrossud"), g0); + await W(150); + put(nameBox("culvert_height_m"), o0); + await W(150); + out.ud.restored = + nameBox("#gcrossud").value === g0 && nameBox("culvert_height_m").value === o0; + } + // 바닥막이 — 선택지 이름(값 그대로) + $('[data-sod-kind="bed_sill"]').click(); + await W(200); + { + const k = "form=돌붙임(메)"; + const c0 = nameBox(k).value; + put(nameBox(k), "돌붙임 메X"); + await W(150); + const opt = [...groupPreview().querySelectorAll("option")].find( + (o) => o.textContent === "돌붙임 메X", + ); + out.bed = { choice: !!opt, value: opt?.value }; + put(nameBox(k), c0); + await W(150); + out.bed.restored = nameBox(k).value === c0; + } + // 개거 — 칸 없음 · 종류 이름만 + $('[data-sod-kind="open_ditch"]').click(); + await W(200); + { + const kind = $("[data-sod-kind-name]"); + const n0 = kind.value; + out.ditch = { + rows: $$("[data-sod-spec]").length, + none: !!$("[data-sod-spec-block] p.m02-side__empty"), + }; + put(kind, "개거X"); + await W(200); + out.ditch.list = $('[data-sod-kind="open_ditch"]').textContent.includes("개거X"); + put($("[data-sod-kind-name]"), n0); + await W(200); + out.ditch.restored = $('[data-sod-kind="open_ditch"]').textContent.includes(n0); + } + // 배관 — 명세 이름 나무 + $('[data-sod-kind="pipe"]').click(); + await W(200); + const block = $("[data-sod-spec-block]"); + const detail = $("[data-sod-detail]"); + const rows = $$("[data-sod-spec]", block); + out.spec = { + rows: rows.length, + containers: $$(".is-level-0", block).length, + inputs: $$("input, select, textarea", block).filter( + (n) => !n.closest("[data-sod-spec-preview]"), + ).length, + nameInputs: $$("[data-sod-spec-name]", block).length, + keysReadOnly: rows.every((r) => r.querySelector("small.m02-opts__key")), + }; + out.old = { + base: !!$("[data-sod-base]", detail), + blocks: detail.children.length, // 머리 칸 + 명세 칸 = 2 + }; + const save = $("[data-sod-save]"); + out.saveBefore = save.disabled; + const box = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`); + const orig = Object.fromEntries(Object.keys(NEW).map((k) => [k, box(k).value])); + for (const [k, v] of Object.entries(NEW)) { + put(box(k), v); + await W(150); + } + const preview = $("[data-sod-spec-preview]"); + const pipeBox = $('[data-spec-container="pipe"]', preview); + out.preview = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + out.saveAfterEdit = $("[data-sod-save]").disabled; + // 빈 이름 → 저장 막힘 + put(box("pipe.diameter"), ""); + await W(150); + out.blank = { + issue: $("[data-sod-issues]").textContent.includes("명세 이름"), + save: $("[data-sod-save]").disabled, + }; + // 되돌림 + for (const [k, v] of Object.entries(orig)) { + put(box(k), v); + await W(150); + } + out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v); + out.판After = (await getDef()).판; + out.ok = + !out.other.base && + out.other.spec && + out.other.blocks === 2 && + out.ud.keys.join() === "#gcrossud,culvert_height_m,culvert_slope_pct" && + out.ud.legend && + out.ud.label && + !out.ud.old && + out.ud.restored && + out.bed.choice && + out.bed.value === "돌붙임(메)" && + out.bed.restored && + out.ditch.rows === 0 && + out.ditch.none && + out.ditch.list && + out.ditch.restored && + out.spec.containers === 5 && + out.spec.inputs === out.spec.rows && + out.spec.nameInputs === out.spec.rows && + out.spec.keysReadOnly && + !out.old.base && + out.old.blocks === 2 && + out.preview.title && + out.preview.item && + out.preview.choice && + !out.saveAfterEdit && + out.blank.issue && + out.blank.save && + out.restored && + out.판After === out.판; + } else { + const sec = $(".b05-structure-section"); + if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" }); + const url = performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts")) + .pop(); + const api = await import(url); + const pipe = (await api.fetchStructureTypes(true)).find((t) => t.type_id === "pipe"); + pipe.spec_names = { ...(pipe.spec_names ?? {}), ...NEW }; + await until(() => $$(".b05-route__irregular-list li").length); + const li = $$(".b05-route__irregular-list li").find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + const spec = () => $$(".b05-structure__spec", sec).find((n) => n.offsetParent !== null); // 명세 폼이 종류마다 여럿 — 보이는 것 + (li.querySelector("button") ?? li).click(); + await W(800); + if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click(); + await until(() => spec() && spec().offsetParent !== null); + const pipeBox = $('[data-spec-container="pipe"]', spec()); + out.named = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await W(500); + // 정의 그룹 꼴(52-12) — 받아 둔 종류 객체만 고치고 새 구조물 폼(군 · 종류 고르기 · 놓지 않음)으로 봄 + const types = await api.fetchStructureTypes(true); + const ud = types.find((t) => t.type_id === "cross_underdrain"); + ud.groups[0].name = "맹암거X"; + ud.options[0].label = "암거 높이X"; + const bed = types.find((t) => t.type_id === "bed_sill"); + bed.spec_names = { ...(bed.spec_names ?? {}), "form=돌붙임(메)": "돌붙임 메X" }; + const count = $$(".b05-route__irregular-list li").length; + const [famSel, kindSel] = $$("select", sec); + const choose = async (fam, kind) => { + famSel.value = fam; + famSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(300); + kindSel.value = kind; + kindSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(500); + }; + await choose("A", "cross_underdrain"); + out.group = { + legend: $$("legend", sec).some( + (l) => l.offsetParent !== null && l.textContent.includes("맹암거X"), + ), + label: $$(".b05-route__field > span", sec).some( + (n) => n.offsetParent !== null && n.textContent === "암거 높이X (m)", + ), + }; + await choose("D", "bed_sill"); + const opt = $$("option", sec).find((o) => o.textContent === "돌붙임 메X"); + out.group.choice = !!opt && opt.value === "돌붙임(메)"; + famSel.value = ""; + famSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(300); + out.group.noWrite = $$(".b05-route__irregular-list li").length === count; + out.ok = Object.values(out.named).every(Boolean) && Object.values(out.group).every(Boolean); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-13_예외.js b/resources/tester/ui_checks/52-13_예외.js new file mode 100644 index 000000000..f3c60e262 --- /dev/null +++ b/resources/tester/ui_checks/52-13_예외.js @@ -0,0 +1,189 @@ +/* 52-13 — 명세 폼 예외 모음(배관 1 · BOX암거 · 세월교 1) — 빈 값 · 오가기 · 종류 바꾸기 · 여러 개 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「배관 1」 「세월교 1」 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5213 = "단계";` 를 붙여 + * cache → 저장 안 하고 B05 → B06 → B05 오가기(캐시 값 그대로) · 종류 바꾸기(배관 → BOX → 배관) + * pipe → 배관 길이 · 전 · 후 비움 + 흙막이 추가 셋 + 도수로/돌붙임 하나 → [저장] + * (orca reload) pipe_v → 다시 열기 · 흙막이 수 3 · 비운 칸 그대로 + * 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 + * (배관 도수로/돌붙임 [추가] 는 하나 뒤에도 보임 — 「하나만」은 물넘이 포장 · 기타만 · 52-3 · 52-10) + */ +(async () => { + const MODE = window.__m5213 || "cache"; + 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 errors = []; + const onErr = (e) => errors.push(String(e.message ?? e.reason ?? e)); + window.addEventListener("error", onErr); + window.addEventListener("unhandledrejection", onErr); + 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 fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + 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 ctl = (c, item, i = 0) => + box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); + const set = async (c, item, v, i = 0) => { + const el = ctl(c, item, i); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + const clearDrafts = () => { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:")) sessionStorage.removeItem(k); + }; + const pick = async (re) => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + re.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + (li.querySelector("button") ?? li).click(); + await until(() => !!spec(), 6000); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + 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 point = async (lo, hi) => + (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( + (p) => p.chainage_m > lo && p.chainage_m < hi, + ); + const out = { mode: MODE }; + + if (MODE === "cache") { + await pick(/배관 1$/); + await set("inlet", "structure", "기슭막이"); + await set("inlet", "length", "12"); + out.before = ctl("inlet", "length").value; + location.hash = "#/b06-section"; + await w(7000); + location.hash = "#/b05-profile"; + await w(7000); + await pick(/배관 1$/); + out.after = ctl("inlet", "length")?.value; + out.same = out.before === out.after; + clearDrafts(); + } + + if (MODE === "pipe") { + 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); + } + spec().querySelector('[data-spec-add="chute_add"]').click(); + await w(200); + await set("chute_add", "structure", "돌붙임"); + out.soilCount = spec().querySelectorAll('[data-spec-container="soil_add"]').length; + out.saved = await save(); + const p = await point(80, 90); + out.file = p && p.options; + } + + if (MODE === "pipe_v") { + await pick(/배관 1$/); + out.soilCount = spec().querySelectorAll('[data-spec-container="soil_add"]').length; + out.chuteAdd = !!spec().querySelector('[data-spec-add="chute_add"]'); + out.inletBlank = ["length", "before", "after"].map((k) => ctl("inlet", k)?.value); + } + + if (MODE === "bf") { + const orig = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points; + if (!sessionStorage.getItem("__5213_original")) + sessionStorage.setItem("__5213_original", JSON.stringify(orig)); + const pts = JSON.parse(JSON.stringify(orig)).map((p) => + Math.abs(p.chainage_m - 258.12) < 0.05 + ? { ...p, facility: "box_culvert", options: { body_width_m: 3, body_height_m: 2.5 } } + : p, + ); + await fetchApi.saveDetailPipePoints(projectId, pts); + clearDrafts(); + out.ready = true; + } + + if (MODE === "bf_edit") { + await pick(/BOX암거$/); + await set("body", "size", "사용자 입력"); + await set("body", "width", ""); + out.boxSaved = await save(); + out.boxFile = (await point(250, 260))?.options; + await pick(/세월교 1$/); + await set("ford", "width", ""); + out.fordSaved = await save(); + out.fordFile = (await point(170, 176))?.options; + } + + if (MODE === "bf_v") { + await pick(/BOX암거$/); + out.box = { size: ctl("body", "size")?.value, width: ctl("body", "width")?.value }; + await pick(/세월교 1$/); + out.ford = { width: ctl("ford", "width")?.value, height: ctl("ford", "height")?.value }; + } + + if (MODE === "kind") { + const sec = document.querySelector(".b05-structure-section"); + const sel = () => sec.querySelectorAll(".b05-structure__grid select"); + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const seen = {}; + for (const t of ["pipe", "box_culvert", "pipe"]) { + fire(sel()[0], "A"); + await w(300); + fire(sel()[1], t); + await w(600); + seen[t + "#" + Object.keys(seen).length] = items().join(","); + } + const vals = Object.values(seen); + out.seen = vals; + out.leak = vals[0] === vals[2] && !/body\./.test(vals[0]) && !/pipe\./.test(vals[1]) ? 0 : 1; + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__5213_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + await fetchApi.saveDetailPipePoints(projectId, original); + 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])) === + JSON.stringify(original.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])); + } + + out.errors = errors; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-15_같은측점_고르기.js b/resources/tester/ui_checks/52-15_같은측점_고르기.js new file mode 100644 index 000000000..605392815 --- /dev/null +++ b/resources/tester/ui_checks/52-15_같은측점_고르기.js @@ -0,0 +1,158 @@ +/* 52-15 — 같은 측점에 관 + 포장이 함께 있을 때 좌측 목록 · 알약 · 넘김값이 누른 것을 그대로 고르는가 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 로그인 상태 · 목록에 「22+0 배관 3」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5215 = "단계";` 를 붙여 + * orca eval --expression "window.__m5215='setup'; $(cat 이 파일)" → sessionStorage 사진 · 22+0 에 콘크리트 포장 [추가](저장 안 함) + * orca eval --expression "window.__m5215='check'; $(cat 이 파일)" → 지금 화면(B05 · B06) 목록 고르기 네 길 + * orca eval --expression "window.__m5215='handoff'; $(cat 이 파일)" → B05 에서 포장 고른 채 B06 으로(넘김값) + * orca eval --expression "window.__m5215='pill'; $(cat 이 파일)" → B06 종단 알약으로 포장 고르기 + * orca eval --expression "window.__m5215='restore'; $(cat 이 파일)" → sessionStorage 를 사진 그대로 · 뒤에 orca reload + * 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage) + * 기대 수치: 네 길 모두 누른 종류(pavement_concrete · pipe) · 포장일 때 고름 칸 sid = 포장 id · + * handoff = pavement_concrete · pill = pavement_concrete · restore 뒤 sessionStorage 같음 + */ +(async () => { + const MODE = window.__m5215 || "check"; + const PIPE = /^22\+0 배관/; + const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 기준 22+0 · 길이 10 m + const OTHER = /^4\+5 배관/; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const sec = () => document.querySelector(".b05-structure-section"); + const spec = () => sec().querySelector(".b05-structure__spec"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const specCtl = (item) => + spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input"); + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const text = (li) => li.innerText.replace(/\s+/g, " ").trim(); + const row = (re) => listItems().find((li) => re.test(text(li))); + const click = async (re) => { + const li = row(re); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(900); + return true; + }; + const pickKey = () => + Object.keys(sessionStorage).find((k) => k.includes("structure-pick") && k.includes(PROJECT)); + const PROJECT = localStorage.getItem("frd_current_project_id"); // 환경마다 검증 프로젝트가 다름 + const picked = () => { + const raw = pickKey() ? sessionStorage.getItem(pickKey()) : null; + let sid = null; + try { + const value = JSON.parse(raw); + sid = (value?.value ?? value)?.sid ?? null; + } catch { + /* 빈 값 */ + } + return { type: selects()[1]?.value ?? null, sid }; + }; + /** 한 화면의 네 길 — 다른 측점 → 포장(첫 눌림) · 다른 측점 → 관 · 관 → 포장 · 포장 → 관. */ + const fourWays = async () => { + const out = {}; + const run = async (name, first, second) => { + await click(first); + await click(second); + out[name] = picked(); + }; + await run("otherToPave", OTHER, PAVE); + await run("otherToPipe", OTHER, PIPE); + await click(PIPE); // 재선택 = 해제 — 관이 골라진 채로 시작 + await click(PIPE); + await click(PAVE); + out.pipeToPave = picked(); + await click(PIPE); + out.paveToPipe = picked(); + await click(PIPE); // 해제 + return out; + }; + const out = { mode: MODE }; + await until(() => listItems().length > 0); + + if (MODE === "setup") { + window.__m5215snap = JSON.stringify(Object.entries(sessionStorage)); + const [g, t] = selects(); + fire(g, "G"); + await w(300); + fire(t, "pavement_concrete"); + await w(1500); + const anchor = () => + sec().querySelectorAll(".b05-structure__position-row > label")[2].querySelectorAll("input"); + fire(anchor()[0], "22"); + await w(300); + fire(anchor()[1], "0"); + await w(500); + out.anchor = [...anchor()].map((i) => i.value); + fire(specCtl("spec"), "콘크리트"); + await w(200); + fire(specCtl("height"), "0.35"); + await w(200); + sec().querySelector(".is-primary").click(); // 추가 + await w(1000); + out.rows = listItems() + .map(text) + .filter((t) => /22\+[05]/.test(t)); + } else if (MODE === "check") { + out[location.hash.includes("b06") ? "b06" : "b05"] = await fourWays(); + } else if (MODE === "handoff") { + await click(OTHER); + await click(PAVE); // B05 에서 포장을 고른 채 옮김 + out.b05Picked = picked(); + location.hash = "#/b06-section"; + await until(() => listItems().length > 0 && selects()[1]?.value, 20000); + await w(2000); + out.handoff = picked(); + out.handoffOk = out.handoff.type === "pavement_concrete"; + } else if (MODE === "pill") { + // 알약 — B06 종단 알약 레인의 포장을 누른다(다른 측점을 고른 뒤 · 같은 측점 관이 골라진 뒤). + await click(OTHER); + await click(PAVE); + const sid = picked().sid; + await click(PAVE); // 해제 + const pill = () => + [...document.querySelectorAll("[data-structure-id]")].find( + (node) => node.dataset.structureId === sid, + ); + out.pillFound = !!pill(); + for (const [name, first] of [ + ["otherToPill", OTHER], + ["pipeToPill", PIPE], + ]) { + await click(first); + pill()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(900); + out[name] = picked(); + } + await click(PIPE); // 관 고른 채로 끝났으면 해제 + out.pillOk = + out.otherToPill?.type === "pavement_concrete" && out.pipeToPill?.type === "pavement_concrete"; + } else if (MODE === "restore") { + const snap = window.__m5215snap; + if (!snap) { + out.error = "사진 없음"; + } else { + sessionStorage.clear(); + for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v); + out.same = JSON.stringify(Object.entries(sessionStorage)) === snap; + } + } + const ways = (r) => + !!r && + r.otherToPave.type === "pavement_concrete" && + !!r.otherToPave.sid && + r.otherToPipe.type === "pipe" && + r.pipeToPave.type === "pavement_concrete" && + r.paveToPipe.type === "pipe"; + if (out.b05) out.b05Ok = ways(out.b05); + if (out.b06) out.b06Ok = ways(out.b06); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-19_박스_빈칸.js b/resources/tester/ui_checks/52-19_박스_빈칸.js new file mode 100644 index 000000000..4048f6acb --- /dev/null +++ b/resources/tester/ui_checks/52-19_박스_빈칸.js @@ -0,0 +1,85 @@ +/* 52-19 — BOX암거 본체 「사용자 입력」 빈 칸 저장 → 다시 열기 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「BOX암거」(52-2 edit 뒤 2.4 × 1.8 · 흙막이) + * 부르는 법: `window.__m5219 = "단계";` 를 붙여 — 단계 사이 orca reload + * one → 가로 비우고 [저장] (reload) view → 사용자 입력 · 가로 빈 칸 · 세로 1.8 + * both → 세로도 비우고 [저장] (reload) view → 사용자 입력 · 빈 칸 둘 · 파일 body_custom + * fill → 2.2 × 1.6 [저장] → 파일에 body_custom 없음 + * 기대: 유입 · 유출 구조 글(흙막이 · 없음) 그대로 · 되돌림은 52-2 restore + */ +(async () => { + const MODE = window.__m5219; + 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 pid = localStorage.getItem("frd_current_project_id"); + const api = await import( + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")) + .pop() + ); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, i) => + box(c)?.querySelector(`[data-spec-item="${c}.${i}"]`)?.querySelector("select, input"); + const set = async (c, i, v) => { + const el = ctl(c, i); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + await until(() => + [...document.querySelectorAll(".b05-route__irregular-list li")].some((l) => + /BOX암거$/.test(l.textContent.trim()), + ), + ); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /BOX암거$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!box("body")) (li.querySelector("button") ?? li).click(); + await until(() => !!box("body")); + await w(500); + const read = () => ({ + size: ctl("body", "size")?.value, + width: ctl("body", "width")?.value ?? null, + height: ctl("body", "height")?.value ?? null, + }); + const file = async () => + (await api.fetchDetailPipePoints(pid)).pipe_points.find((p) => p.facility === "box_culvert") + ?.options; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const wings = () => ["inlet", "outlet"].map((c) => ctl(c, "structure")?.value); + const out = { mode: MODE, reopened: read(), wings: wings() }; + if (MODE === "one") { + await set("body", "width", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "both") { + await set("body", "height", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "fill") { + await set("body", "width", "2.2"); + await set("body", "height", "1.6"); + out.saved = await save(); + out.file = await file(); + } + out.wingsAfter = wings(); + out.after = read(); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 02840470e..f0f72fee0 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -9,6 +9,8 @@ * 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 · * soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06) + * 52-1b: edit 가 흙막이 추가 · 도수로/돌붙임 컨테이너도 이음 → verify 집계표 줄 = 처음 + 3 + * (유입 잇기 1 · 컨테이너마다 1) */ (async () => { const MODE = window.__m521 || "edit"; @@ -28,7 +30,18 @@ .filter((n) => n.includes(part)) .pop(); const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); - const spec = () => document.querySelector(".b05-structure__spec"); + const m02Api = await import( + modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts").replace( + /B04_PreProcess\/B04_PreProcess_Api_Fetch\.ts.*$/, + "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts", + ) + ); + /** 구조물집계표 채움 줄 수(전구간 고정 줄 뺌) — 52-1b. */ + const filledRows = async () => + (await m02Api.readFilled(projectId, "구조물집계표")).문서.줄.filter((r) => !r.고정).length; + // 명세 폼은 둘(관 시설 · 일반 구조물 52-10) — 보이는 것 + 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 ctl = (c, item, i = 0) => box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); @@ -86,6 +99,8 @@ if (MODE === "edit") { const before = await fetchApi.fetchDetailPipePoints(projectId); sessionStorage.setItem("__521_original", JSON.stringify(before.pipe_points)); + out.baseRows = await filledRows(); + sessionStorage.setItem("__521_baseRows", String(out.baseRows)); await pickPipe(); const s0 = shape(); out.kinds = @@ -128,6 +143,10 @@ 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")]; // 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂 await pickLink("inlet"); out.linkInlet = linkName("inlet"); @@ -169,6 +188,8 @@ out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); if (!out.sameDrawing) out.diff = { b05, b06 }; out.b06Length = ctl("pipe", "length").value; + out.baseRows = Number(sessionStorage.getItem("__521_baseRows")); + out.rows = await filledRows(); } if (MODE === "restore") { @@ -191,7 +212,7 @@ ); await fetchApi.saveDetailPipePoints(projectId, points); for (const k of Object.keys(sessionStorage)) - if (k.startsWith("aislo:draft:pipes:") || k === "__521_original") + if (k.startsWith("aislo:draft:pipes:") || k.startsWith("__521_")) sessionStorage.removeItem(k); const now = await fetchApi.fetchDetailPipePoints(projectId); const pick = (list) => diff --git a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js new file mode 100644 index 000000000..67c606819 --- /dev/null +++ b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js @@ -0,0 +1,180 @@ +/* 52-2 — B05 · B06 좌측 「구조물 배치」 박스암거 = 사용자 구조물 표 02 고친 판(컨테이너 셋) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub3_검증) · 목록에 「BOX암거」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m522 = "단계";` 를 붙여 + * orca eval --expression "window.__m522='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠) + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m522='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m522='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * (연결이 끊기면 결과는 `sessionStorage.getItem("__522_out_{단계}")` 로 읽음) + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 3(본체 · 유입 구조 · 유출 구조 · 모두 필수 · [추가] 0) · 옛 값 3 × 2.5 = 사용자 입력 · + * 옛 「있음」 = 날개벽 · 규격 2.0 × 2.0 고르면 숫자 칸 0 · 사용자 입력 고르면 가로 · 세로 2 · + * 구조 선택지 없음/날개벽/흙막이 · 잇기 본체 · 유입 지정 · 유출 지정 뒤 풂 · + * 저장 = body 2.4 × 1.8 · wing_in 흙막이 · wing_out 없음 · group_items gboxbody · gboxin + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m522 || "edit"; + 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 fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const isBox = (p) => p.facility === "box_culvert"; + // 보이는 명세 폼(배관 · BOX 가 각자 폼을 가짐 — 숨은 것은 뺌) + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`); + const field = (c, item) => box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`); + const ctl = (c, item) => field(c, item)?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text); + const pickBox = async () => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /BOX암거$/.test(l.textContent.trim()), + ); + // 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더(재선택 = 해제) + (li.querySelector("button") ?? li).click(); + await w(800); + if (!box("body")) (li.querySelector("button") ?? li).click(); + await until(() => !!box("body")); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem), + values: [...b.querySelectorAll("[data-spec-item]")].map( + (n) => n.querySelector("select, input").value, + ), + link: b.querySelector(".b05-structure__summary-name")?.textContent, + })); + const linked = (c) => + box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); + const pickLink = async (c) => { + box(c).querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const out = { mode: MODE }; + + if (MODE === "edit") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + // 원래 값은 처음 한 번만 둠(끊겨 다시 돌려도 고친 값으로 덮지 않음 · 되돌림이 비움) + if (!sessionStorage.getItem("__522_original")) + sessionStorage.setItem("__522_original", JSON.stringify(before.pipe_points)); + out.original = before.pipe_points.find(isBox)?.options; + await pickBox(); + const s0 = shape(); + out.containers = s0.map((b) => [b.c, b.legend]); + out.allVisible = ["body", "inlet", "outlet"].every((c) => box(c)?.offsetParent !== null); + out.addButtons = spec().querySelectorAll("[data-spec-add]").length; + out.oldRead = { + body: s0[0].values, + inlet: ctl("inlet", "structure").value, + outlet: ctl("outlet", "structure").value, + }; + out.sizes = labels("body", "size"); + out.structures = labels("inlet", "structure"); + await set("body", "size", "2.0×2.0"); + out.presetNumberFields = ["width", "height"].filter((i) => field("body", i)).length; + await set("body", "size", "사용자 입력"); + out.customNumberFields = ["width", "height"] + .filter((i) => field("body", i)) + .map((i) => field("body", i).querySelector("label, span")?.textContent ?? i); + await set("body", "width", "2.4"); + await set("body", "height", "1.8"); + await set("inlet", "structure", "흙막이"); + await set("outlet", "structure", "없음"); + await pickLink("body"); + await pickLink("inlet"); + await pickLink("outlet"); + out.linkPicked = ["body", "inlet", "outlet"].map(linked); + box("outlet").querySelector('[data-b05-summary="clear"]').click(); + await w(300); + out.linkOutletCleared = !linked("outlet"); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const after = await fetchApi.fetchDetailPipePoints(projectId); + const p = after.pipe_points.find(isBox); + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) }; + } + + if (MODE === "verify") { + await pickBox(); + const b05 = shape(); + out.reopened = b05.map((b) => [b.c, b.values, b.link]); + out.linkReopened = ["body", "inlet", "outlet"].map(linked); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pickBox(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__522_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const keys = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const points = original.map((p) => + Object.fromEntries( + keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + await fetchApi.saveDetailPipePoints(projectId, points); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:") || k === "__522_original") + sessionStorage.removeItem(k); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const pick = (list) => + JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}])); + out.restored = pick(now.pipe_points) === pick(original); + } + // 긴 단계는 ORCA 연결이 끊길 수 있어 결과를 남김 — `sessionStorage.__522_out_{단계}` 로 읽음 + sessionStorage.setItem(`__522_out_${MODE}`, JSON.stringify(out)); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-2b_조정줄_자리.js b/resources/tester/ui_checks/52-2b_조정줄_자리.js new file mode 100644 index 000000000..7abeee314 --- /dev/null +++ b/resources/tester/ui_checks/52-2b_조정줄_자리.js @@ -0,0 +1,83 @@ +/* 52-2b — B06 횡단 조정 줄(단 수 · 옵션 · 읽기)이 서는 자리 = 지금 보이는 명세 폼의 유입 · 유출 칸 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub3_검증) · 목록에 「배관 1」 「BOX암거」 「세월교 1」 + * 부르는 법: orca eval --expression "$(cat 이 파일)" — 쓰는 것 없음(가짜 조정 사본을 세웠다 내림) + * 차례: 배관 1 → BOX암거 → 세월교 1 → 배관 1 (앞 시설 명세 폼이 숨은 채 남는 경우까지) + * 기대 수치: 모든 단계 · 양쪽 — 줄이 숨은 조상 없이 보이는 자리 · 그 시설 명세 폼의 inlet/outlet 칸 · + * 숨은 명세 칸을 「쓸 수 있음」으로 보던 옛 판정 건수(oldWouldPickHidden)는 참고값 + */ +(async () => { + 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 modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const dock = await import(modUrl("/B06_Section/B06_Section_UI_Adjust_Dock.ts")); + const visibleSpec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => !n.closest("[hidden]")); + const firstContainer = () => + visibleSpec()?.querySelector("[data-spec-container]")?.dataset.specContainer; + const pick = async (name, head) => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && name.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await w(800); + if (firstContainer() !== head) (li.querySelector("button") ?? li).click(); + return until(() => firstContainer() === head, 8000); + }; + // 옛 판정 — 바로 위 fieldset 만 봄 + const oldUsable = (t) => !!t.closest("fieldset") && !t.closest("fieldset").hidden; + const fakePanel = (side) => { + const panel = document.createElement("div"); + panel.dataset.side = side; + panel.dataset.panelTitle = "52-2b 시험"; + const row = document.createElement("div"); + row.className = "b06-structure-panel__struct--tier"; + row.dataset.m522b = side; + panel.append(row); + return { panel, row }; + }; + + const steps = [ + [/배관 1$/, "pipe"], + [/BOX암거$/, "body"], + [/세월교 1$/, "ford"], + [/배관 1$/, "pipe"], + ]; + const out = { steps: [], oldWouldPickHidden: 0 }; + for (const [name, head] of steps) { + const step = { name: String(name), shown: await pick(name, head) }; + for (const side of ["inlet", "outlet"]) { + // 숨은 명세 폼의 같은 칸 — 옛 판정이 「쓸 수 있음」으로 봤는지 + for (const s of document.querySelectorAll( + `[data-spec-container="${side}"] .b05-structure__adjust-slot`, + )) + if (s.closest("[hidden]") && oldUsable(s)) out.oldWouldPickHidden++; + const { panel, row } = fakePanel(side); + dock.adoptAdjustPanel(panel); + step[side] = { + visible: !row.closest("[hidden]"), + container: row.closest("[data-spec-container]")?.dataset.specContainer ?? null, + inShownForm: !!visibleSpec()?.contains(row), + }; + dock.releaseAdjustPanel(panel); + } + out.steps.push(step); + } + out.pass = out.steps.every( + (s) => + s.shown && + ["inlet", "outlet"].every((k) => s[k].visible && s[k].inShownForm && s[k].container === k), + ); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js new file mode 100644 index 000000000..01a7e673e --- /dev/null +++ b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js @@ -0,0 +1,212 @@ +/* 52-3 — B05 · B06 좌측 「구조물 배치」 물넘이 포장 = 사용자 구조물 표 03 명세(경사각도 · 도수로/돌붙임) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub7_검증) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m523 = "단계";` 를 붙여 + * orca eval --expression "window.__m523='setup'; $(cat 이 파일)" → 관 12+18(258.12)을 옛 값 든 물넘이 포장으로(PUT · 원래 값은 sessionStorage) + * (orca reload) + * orca eval --expression "window.__m523='edit'; $(cat 이 파일)" → 고치고 [저장] + * (orca reload) + * orca eval --expression "window.__m523='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m523='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 = 경사각도(필수) · [+ 도수로/돌붙임] · 옛 칸 안 보임 · 지난 정의 값 5 · + * 구조 선택지 도수로/돌붙임 · 각도 12.5 · 하나 추가(돌붙임) → [추가] 없음(52-17) · 잇기 경사각도 · 도수로/돌붙임 · + * [빼기] → 잇기 풂 · [추가] 다시 보임 · 다시 추가(도수로) · 저장 파일 = 도 · 인스턴스 키 하나 · 옛 값 그대로 + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m523 || "edit"; + const AT = 258.12; + const NAME = /^12\+18물넘이포장/; + const OLD = { + ford_width_m: 6, + ford_height_m: 0.3, + ford_slope_pct: 3, + thickness_cm: 20, + length_m: 7, + }; + 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 fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const KEYS = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const plain = (list) => + list.map((p) => + Object.fromEntries( + KEYS.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + const clearDrafts = () => { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:")) sessionStorage.removeItem(k); + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const boxes = (c) => [...spec().querySelectorAll(`[data-spec-container="${c}"]`)]; + const ctl = (b, c, item) => + b.querySelector(`[data-spec-item="${c}.${item}"]`).querySelector("select, input"); + const set = async (b, c, item, v) => { + const el = ctl(b, c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const pick = async () => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && NAME.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + if (spec()) return; // 이미 열림(B06 은 늦게 열려 두 번 누르면 닫힘) + (li.querySelector("button") ?? li).click(); + await until(() => !!spec(), 6000); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem, + n.querySelector("select, input").value, + ]), + link: b.querySelector(".b05-structure__summary-name")?.classList.contains("is-assigned"), + })); + const pickLink = async (b) => { + b.querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((x) => x.textContent.includes("적용")) + .click(); + await w(500); + }; + const visibleKeys = () => + [...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")] + .filter((n) => n.offsetParent !== null) + .map((n) => n.dataset.optionKeys); + const staleCount = () => { + const legend = [...document.querySelectorAll("legend")].find( + (l) => l.offsetParent !== null && l.textContent.trim() === "지난 정의 값", + ); + return legend ? legend.parentElement.querySelectorAll("input").length : 0; + }; + const point = async () => + (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( + (p) => Math.abs(p.chainage_m - AT) < 0.05, + ); + const out = { mode: MODE }; + + if (MODE === "setup") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + if (!sessionStorage.getItem("__523_original")) + sessionStorage.setItem("__523_original", JSON.stringify(before.pipe_points)); + const points = plain(before.pipe_points).map((p) => + Math.abs(p.chainage_m - AT) < 0.05 + ? { ...p, facility: "ford_pavement", options: { ...OLD } } + : p, + ); + await fetchApi.saveDetailPipePoints(projectId, points); + clearDrafts(); + out.ready = (await point())?.facility === "ford_pavement"; + } + + if (MODE === "edit") { + await pick(); + out.containers = shape().map((b) => [b.c, b.legend]); + out.addButton = spec().querySelector('[data-spec-add="chute"]')?.textContent; + out.oldFieldsVisible = visibleKeys(); + out.stale = staleCount(); + await set(boxes("slope")[0], "slope", "angle", "12.5"); + // 52-17 — 하나만: 하나 넣으면 [추가] 없음 · 빼면 다시 보임 + const addShown = () => !!spec().querySelector('[data-spec-add="chute"]'); + spec().querySelector('[data-spec-add="chute"]').click(); + await w(300); + out.addAfterOne = addShown(); + out.chuteChoices = [...ctl(boxes("chute")[0], "chute", "structure").options] + .filter((o) => o.value) + .map((o) => o.text); + await set(boxes("chute")[0], "chute", "structure", "돌붙임"); + await pickLink(boxes("slope")[0]); + await pickLink(boxes("chute")[0]); + out.beforeRemove = shape().map((b) => [b.legend, b.items, b.link]); + boxes("chute")[0].querySelector('[data-spec-remove="chute"]').click(); + await w(400); + out.afterRemove = shape().map((b) => [b.legend, b.items, b.link]); + out.addAfterRemove = addShown(); + spec().querySelector('[data-spec-add="chute"]').click(); + await w(300); + await set(boxes("chute")[0], "chute", "structure", "도수로"); + out.final = shape().map((b) => [b.legend, b.items, b.link]); + out.addAfterReAdd = addShown(); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + await w(1000); + const p = await point(); + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}).sort() }; + } + + if (MODE === "verify") { + await pick(); + const b05 = shape(); + out.reopened = b05.map((b) => [b.legend, b.items, b.link]); + out.stale = staleCount(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pick(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + out.b06Stale = staleCount(); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__523_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + await fetchApi.saveDetailPipePoints(projectId, plain(original)); + clearDrafts(); + sessionStorage.removeItem("__523_original"); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const key = (list) => + JSON.stringify( + list.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? {}, p.group_items ?? {}]), + ); + out.restored = key(now.pipe_points) === key(original); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-4_세월교_컨테이너.js b/resources/tester/ui_checks/52-4_세월교_컨테이너.js new file mode 100644 index 000000000..c5c9143e0 --- /dev/null +++ b/resources/tester/ui_checks/52-4_세월교_컨테이너.js @@ -0,0 +1,181 @@ +/* 52-4 — B05 · B06 좌측 「구조물 배치」 세월교 = 사용자 구조물 표 04 명세(컨테이너 셋) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 목록에 「세월교 1」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m524 = "단계";` 를 붙여 + * orca eval --expression "window.__m524='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠) + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m524='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m524='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 3(세월교 · 유입 구조 · 유출 구조 · 모두 필수) · 관경 800/1000/1200/1500 · + * 구조 없음/날개벽/흙막이 · 옛 관종 · 날개벽 칸 안 보임 · 폭 8 → 높이 = 필요 수심(설계유량 있으면) · + * 낮게 적으면 필요 수심으로 되돌림 · 수량 3 · 잇기 세월교 · 유입 지정 · 유출 지정 뒤 풂 + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m524 || "edit"; + const NAME = /세월교 1$/; + 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 fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec().querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, item) => + box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text); + const pickFord = async () => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && NAME.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem, + n.querySelector("select, input").value, + ]), + link: b.querySelector(".b05-structure__summary-name")?.textContent, + })); + const assigned = (c) => + box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); + const pickLink = async (c) => { + box(c).querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const visibleKeys = () => + [...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")] + .filter((n) => n.offsetParent !== null) + .map((n) => n.dataset.optionKeys); + const out = { mode: MODE }; + + if (MODE === "edit") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + sessionStorage.setItem("__524_original", JSON.stringify(before.pipe_points)); + await pickFord(); + const s0 = shape(); + out.containers = s0.map((b) => [b.c, b.legend]); + out.oldFieldsVisible = visibleKeys(); // 옛 관종 · 월류 · 날개벽 칸 — 빈 목록이어야 + out.diameters = labels("ford", "diameter"); + out.inletChoices = labels("inlet", "structure"); + out.outletChoices = labels("outlet", "structure"); + out.start = s0.map((b) => b.items); + await set("ford", "diameter", "1200"); + await set("ford", "width", "8"); + out.heightAfterWidth8 = ctl("ford", "height").value; + out.heightHint = ctl("ford", "height").placeholder; + await set("ford", "width", "4"); + out.heightAfterWidth4 = ctl("ford", "height").value; + await set("ford", "height", "0.01"); + out.heightLowBounced = ctl("ford", "height").value; + const raised = (Number(out.heightAfterWidth4 || 0) + 0.2).toFixed(2); + await set("ford", "height", raised); + out.heightRaisedKept = ctl("ford", "height").value === raised; + await set("ford", "count", "3"); + await set("inlet", "structure", "흙막이"); + await set("outlet", "structure", "날개벽"); + await pickLink("ford"); + await pickLink("inlet"); + await pickLink("outlet"); + out.linksPicked = ["ford", "inlet", "outlet"].map(assigned); + box("outlet").querySelector('[data-b05-summary="clear"]').click(); + await w(300); + out.linkOutletCleared = !assigned("outlet"); + out.edited = shape().map((b) => b.items); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const after = await fetchApi.fetchDetailPipePoints(projectId); + const p = after.pipe_points.find((x) => x.chainage_m > 170 && x.chainage_m < 176); // 세월교 1 = 8+13 + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) }; + } + + if (MODE === "verify") { + await pickFord(); + const b05 = shape(); + out.reopened = b05.map((b) => b.items); + out.links = ["ford", "inlet", "outlet"].map(assigned); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pickFord(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + out.b06WingSlots = ["inlet", "outlet"].map( + (c) => box(c).querySelector(".b05-structure__adjust-slot")?.childElementCount ?? -1, + ); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__524_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const keys = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const points = original.map((p) => + Object.fromEntries( + keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + await fetchApi.saveDetailPipePoints(projectId, points); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:") || k === "__524_original") + sessionStorage.removeItem(k); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const pick = (list) => + JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}])); + out.restored = pick(now.pipe_points) === pick(original); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-5_횡단수로_개거.js b/resources/tester/ui_checks/52-5_횡단수로_개거.js new file mode 100644 index 000000000..6745c6ed2 --- /dev/null +++ b/resources/tester/ui_checks/52-5_횡단수로_개거.js @@ -0,0 +1,158 @@ +/* 52-5 — B05 · B06 좌측 「구조물 배치」 노출형 횡단수로 · 개거(겉도랑) = 사용자 구조물 표 05 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 폼 비어 있음 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m525 = "단계";` 를 붙여 + * orca eval --expression "window.__m525='edit'; $(cat 이 파일)" → 두 종류 모양 · 추가 · 잇기 · [저장] + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → 저장 값 그대로 · 다시 고름 + * (B06 으로 옮겨 — 같은 모양) + * orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → B06 에서도 같은 값 · 같은 모양 + * orca eval --expression "window.__m525='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) + * 기대 수치: + * shape(종류마다) — optionInputs 0 · containers 0 · summaryRows 1 · summaryVisible true · summaryRightAfterPosition true · + * anchor true · stale false + * edit — added 둘 · 잇기 이름 값 있음 · 저장 뒤 목록 2 늘어남 + * verify — 같은 모양 · 잇기 이름 그대로 · 옵션 값 없음 + */ +(async () => { + const MODE = window.__m525 || "edit"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => !!n && n.offsetParent !== null; + const sec = () => document.querySelector(".b05-structure-section"); + const body = () => sec().querySelector(".b05-route__panel-body"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const NAMES = { cross_drain_exposed: "노출형 횡단수로", open_ditch: "개거(겉도랑)" }; + const AT = { cross_drain_exposed: [2, 7], open_ditch: [6, 3] }; + + const shape = (typeId) => { + const kids = [...body().children]; + const position = kids.find((c) => c.classList.contains("b05-structure__position-row")); + const summary = kids.find((c) => c.classList.contains("b05-structure__summary")); + const optionRow = kids.filter((c) => c.classList.contains("b05-structure__grid"))[1]; + const after = kids.slice(kids.indexOf(position) + 1).find((c) => vis(c)); + const anchorWrap = position.querySelectorAll(":scope > label")[2]; + return { + typeId, + optionInputs: vis(optionRow) ? optionRow.querySelectorAll("input, select").length : 0, + containers: [...sec().querySelectorAll("[data-spec-container]")].filter(vis).length, + summaryRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, + summaryVisible: vis(summary), + summaryRightAfterPosition: after === summary, + anchor: vis(anchorWrap), + stale: vis(sec().querySelector(".b05-structure__stale")), + linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "", + }; + }; + const pickType = async (typeId) => { + const [group, type] = selects(); + fire(group, "A"); + await w(300); + fire(type, typeId); + await w(500); + }; + const linkFirstLeaf = async () => { + sec().querySelector("[data-b05-summary=pick]").click(); + await until(() => document.querySelector(".ui-item-picker")); + await w(600); + const modal = document.querySelector(".ui-item-picker"); + // 끝 항목 = ▸ · ▾ 로 안 시작하고 모달 조작 단추가 아닌 첫 단추 + const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const leaf = [...modal.querySelectorAll("button")].find((b) => { + const t = b.textContent.trim(); + return t && !/^[▸▾]/.test(t) && !ctl.includes(t); + }); + leaf?.click(); + await w(300); + [...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click(); + await w(500); + return !!leaf; + }; + const clickBtn = async (text) => { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === text && vis(b)) + ?.click(); + await w(1500); + }; + const out = { mode: MODE, items: [] }; + + if (MODE === "edit") { + await until(() => listItems().length > 0); + out.before = listItems().length; + for (const typeId of Object.keys(NAMES)) { + await pickType(typeId); + const row = { ...shape(typeId), phase: "edit" }; + const [no, dist] = sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(no, String(AT[typeId][0])); + fire(dist, String(AT[typeId][1])); + await w(200); + row.picked = await linkFirstLeaf(); + row.linked = shape(typeId).linked; + sec().querySelector(".is-primary").click(); // 추가 + await w(800); + out.items.push(row); + } + out.after = listItems().length; + await clickBtn("저장"); + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /횡단수로|개거/.test(t)); + } else if (MODE === "verify") { + await until(() => listItems().length > 0); + for (const typeId of Object.keys(NAMES)) { + const li = listItems().find((l) => l.textContent.includes(NAMES[typeId])); + if (!li) { + out.items.push({ typeId, missing: true }); + continue; + } + (li.querySelector("button") ?? li).click(); + await w(800); + // B06 은 다른 항목이 골라져 있으면 첫 눌림이 풀기만 함 — 안 불러와졌으면 한 번 더 + if (selects()[1].value !== typeId) { + (li.querySelector("button") ?? li).click(); + await w(800); + } + out.items.push({ ...shape(typeId), phase: "verify", title: li.title || "" }); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(300); + } + } else if (MODE === "restore") { + await until(() => listItems().length > 0); + for (const typeId of Object.keys(NAMES)) { + const li = listItems().find((l) => l.textContent.includes(NAMES[typeId])); + if (!li) continue; + (li.querySelector("button") ?? li).click(); + await w(600); + await clickBtn("삭제"); + } + await clickBtn("저장"); + out.left = listItems().length; + } + const rows = out.items.filter((r) => !r.missing); + out.shapeOk = + rows.length === 2 && + rows.every( + (r) => + r.optionInputs === 0 && + r.containers === 0 && + r.summaryRows === 1 && + r.summaryVisible && + r.summaryRightAfterPosition && + r.anchor && + !r.stale, + ); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-6_횡단맹암거.js b/resources/tester/ui_checks/52-6_횡단맹암거.js new file mode 100644 index 000000000..3519947f5 --- /dev/null +++ b/resources/tester/ui_checks/52-6_횡단맹암거.js @@ -0,0 +1,217 @@ +/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 + * 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여 + * orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기 + * orca eval --expression "window.__m526='b06'; $(cat 이 파일)" → B06 에서 같은 항목 다시 열기 · B05 와 같은 그리기 + * orca eval --expression "window.__m526='save'; $(cat 이 파일)" → [저장] 요청 가로챔(몸통만 봄) + * orca eval --expression "window.__m526='restore'; $(cat 이 파일)" → sessionStorage 를 처음 그대로 · 뒤에 orca reload + * 쓰는 것: 없음 — [저장] 은 fetch 를 가로채 서버로 안 보냄(공유 storage) · 서버 저장 왕복은 test_52_6_cross_underdrain + * 기대 수치(edit): b05Types 에 cross_underdrain:맹암거 · underdrain 없음 · box 맹암거 · keys 높이 · 경사 · + * 잇기 줄 1(맹암거 상자 안만 · 종류 단위 줄 가림 · 52-6b) · pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음 + * 기대 수치(b06): b06Types A 에 맹암거 · B 에 종단배수 맹암거 따로 · sameDrawing · 값 같음 + * 기대 수치(save): saved = PUT 몸통에 같은 값(67 m · 0.6 · 2.5 · gcrossud) + */ +(async () => { + const MODE = window.__m526 || "edit"; + const KEY = "cross_underdrain"; + const GROUP = "gcrossud"; + 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 vis = (n) => n && n.offsetParent !== null; + 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 sec = () => document.querySelector(".b05-structure-section"); + const sels = () => sec().querySelectorAll(".b05-structure__grid select"); + const choose = async (sel, value) => { + sel.value = value; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const typesOf = async (group) => { + await choose(sels()[0], group); + return [...sels()[1].options].filter((o) => o.value).map((o) => `${o.value}:${o.text}`); + }; + const box = () => sec().querySelector(`[data-b05-group="${GROUP}"]`); + const input = (k) => box()?.querySelector(`[data-option-keys="${k}"] input`); + const shape = () => { + const b = box(); + return { + legend: b?.querySelector("legend").textContent, + keys: [...(b?.querySelectorAll("[data-option-keys]") ?? [])].map( + (n) => `${n.dataset.optionKeys}|${n.querySelector("span").textContent}`, + ), + values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value), + link: b?.querySelector(".b05-structure__summary-name").textContent, + linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"), + linkRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, + }; + }; + const item = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /^3\+7.*맹암거/.test(l.textContent.trim()), + ); + const openItem = async () => { + await until(() => item()); + const li = item(); + (li.querySelector("button") ?? li).click(); + await w(600); + // 이미 고른 채로 열렸으면 누르면 풀림 — 풀렸으면 한 번 더(재선택 = 해제) + if (sels()[1].value !== KEY) (li.querySelector("button") ?? li).click(); + await until(() => vis(box())); + await w(300); + }; + const pickLink = async () => { + box().querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const pendingMine = () => + (api.readPendingStructures(projectId) ?? []).filter((s) => s.type_id === KEY); + const out = { mode: MODE }; + + if (MODE === "edit") { + sessionStorage.setItem("__526_session", JSON.stringify({ ...sessionStorage })); + out.b05Types = await typesOf("A"); + out.b05HasLongUnderdrain = (await typesOf("B")).some((t) => t.startsWith("underdrain:")); + await typesOf("A"); + await choose(sels()[1], KEY); + out.boxBeforeStation = { shown: vis(box()), locked: !!box()?.querySelector(".is-locked") }; + const pos = [...sec().querySelectorAll(".b05-structure__position-row input")].filter(vis); + for (const [el, v] of [ + [pos[0], "3"], + [pos[1], "7"], + ]) { + el.value = v; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + await w(300); + for (const [k, v] of [ + ["culvert_height_m", "0.6"], + ["culvert_slope_pct", "2.5"], + ]) { + input(k).value = v; + input(k).dispatchEvent(new Event("input", { bubbles: true })); + input(k).dispatchEvent(new Event("change", { bubbles: true })); + } + await w(200); + await pickLink(); + out.form = shape(); + sec().querySelector(".is-primary").click(); // [추가] + await w(800); + const mine = pendingMine(); + out.pending = mine.map((s) => ({ + chainage_m: s.chainage_m, + options: s.options, + groups: Object.keys(s.group_items ?? {}), + })); + // 종단 알약 — 약자 · 색이 종단배수 맹암거(맹암거 · #1f7fb8)와 따로 + const types = await api.fetchStructureTypes(true); + const ids = new Set(mine.map((s) => s.structure_id)); + if (!document.querySelector(".b05-structure__mark")) { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === "▲종단") + ?.click(); + await until(() => document.querySelector(".b05-structure__mark"), 8000); + } + out.marks = [...document.querySelectorAll(".b05-structure__mark")] + .filter((m) => ids.has(m.dataset.structureId)) + .map((m) => `${m.textContent}|${m.style.color}`); + out.longStyle = types.find((t) => t.type_id === "underdrain")?.style; + // 다시 열기 — 폼을 비우고 목록에서 다시 고름 + const li = item(); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(400); + await openItem(); + out.reopened = shape(); + out.reopenSame = JSON.stringify(out.reopened) === JSON.stringify(out.form); + } + + if (MODE === "save") { + // [저장] — 요청을 가로채 몸통만 봄(서버 · storage 안 감) · 뒤에 화면이 서버 목록을 다시 읽어 항목이 사라짐 + const real = window.fetch; + const sent = []; + window.fetch = async (url, init = {}) => { + const method = (init.method || "GET").toUpperCase(); + if (method !== "GET" && String(url).includes(`/projects/${projectId}/`)) { + sent.push({ url: String(url), method, body: init.body ? String(init.body) : "" }); + return new Response(JSON.stringify({ status: "success", revision: 0, count: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return real(url, init); + }; + try { + document.querySelector('[data-dock-action="save"]').click(); + await w(3000); + await until(() => !document.querySelector(".ui-overlay.is-active"), 20000); + } finally { + window.fetch = real; + } + const put = sent.find((r) => r.url.includes("/route/structures") && r.method === "PUT"); + const body = put ? JSON.parse(put.body) : null; + const saved = body?.structures.filter((s) => s.type_id === KEY) ?? []; + out.saved = saved.map((s) => ({ + chainage_m: s.chainage_m, + options: s.options, + groups: Object.keys(s.group_items ?? {}), + })); + out.sent = sent.map((r) => `${r.method} ${r.url.split("/api/")[1] ?? r.url}`); + } + + if (MODE === "b06") { + await openItem(); + const b05 = shape(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await openItem(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + out.b06 = b06; + sels()[1].value = ""; + const li = item(); + (li.querySelector("button") ?? li).click(); // 고름 풂 + await w(300); + out.b06TypesA = await typesOf("A"); + out.b06TypesB = await typesOf("B"); + } + + if (MODE === "restore") { + const raw = sessionStorage.getItem("__526_session"); + if (!raw) return JSON.stringify({ error: "처음 sessionStorage 없음" }); + const before = JSON.parse(raw); + sessionStorage.clear(); + for (const [k, v] of Object.entries(before)) sessionStorage.setItem(k, v); + out.restored = JSON.stringify({ ...sessionStorage }) === JSON.stringify(before); + out.mine = pendingMine().length; + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-7_안전부대용지.js b/resources/tester/ui_checks/52-7_안전부대용지.js new file mode 100644 index 000000000..bfe28c65b --- /dev/null +++ b/resources/tester/ui_checks/52-7_안전부대용지.js @@ -0,0 +1,218 @@ +/* 52-7 — B05 · B06 좌측 「구조물 배치」 안전부대용지 = 사용자 구조물 표 07 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m527 = "단계";` 를 붙여 + * seed → 원래 구조물을 sessionStorage 에 두고 옛 「대피소」 한 건(refuge · 너비 4 · 유효길이 15)을 더해 PUT + * (orca reload) + * edit → 종류 목록 · 일곱 종류 컨테이너 0 / 잇기 1 · 대피로 상세 3 · 옛 대피소 = 대피로 + 대피소 · + * 가드레일 길이 10 전 3 → 후 7 · 가드레일 · 대피로 [추가] → [저장] + * (orca reload) + * verify → 다시 연 값 · 옛 대피소 기록 그대로 · B05 = B06 같은 그리기 + * restore → 원래 구조물로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 목록으로 다시 씀) + */ +(async () => { + const MODE = window.__m527 || "edit"; + 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 section = () => document.querySelector(".b05-structure-section"); + const fieldOf = (label) => + [...section().querySelectorAll(".b05-route__field")].find( + (f) => f.querySelector("span")?.textContent.trim() === label, + ); + const groupSelect = () => fieldOf("구조물군").querySelector("select"); + const typeSelect = () => fieldOf("종류").querySelector("select"); + const choose = async (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const pickType = async (typeId) => { + await choose(groupSelect(), "E"); + await choose(typeSelect(), typeId); + }; + const anchor = () => fieldOf("기준 측점")?.querySelectorAll("input"); + const place = async (station, plus) => { + const [s, r] = anchor(); + s.value = String(station); + s.dispatchEvent(new Event("change", { bubbles: true })); + r.value = String(plus); + r.dispatchEvent(new Event("change", { bubbles: true })); + await w(400); + }; + // 보이는 칸만 — 안 쓰는 시설 폼에도 같은 키 칸(포장 길이 등)이 숨어 있음 + const opt = (key) => + [...section().querySelectorAll(`[data-option-keys="${key}"]`)] + .find((n) => n.offsetParent !== null) + ?.querySelector("input, select"); + const setOpt = async (key, value) => { + const el = opt(key); + el.value = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const shown = (el) => !!el && el.offsetParent !== null; + // 종류 단위 잇기 = 기준 측점 아래 줄(컨테이너 밖) · 컨테이너 = 정의 그룹 상자 + const typeLinks = () => + [...section().querySelectorAll('[data-b05-summary="row"]')].filter( + (r) => !r.closest("[data-b05-group]") && shown(r), + ).length; + const containers = () => + [...section().querySelectorAll("[data-b05-group]")].filter(shown).map((box) => ({ + title: box.querySelector("legend").textContent, + keys: [...box.querySelectorAll("[data-option-keys]")].map((n) => n.dataset.optionKeys), + links: box.querySelectorAll('[data-b05-summary="row"]').length, + })); + const listItem = (name) => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + l.textContent.includes(name), + ); + const openItem = async (name) => { + await until(() => listItem(name)); + const li = listItem(name); + (li.querySelector("button") ?? li).click(); + await w(600); + if (!typeSelect().value) { + (listItem(name).querySelector("button") ?? listItem(name)).click(); // 재선택 = 해제였으면 다시 + await w(600); + } + }; + const stale = () => + [...section().querySelectorAll(".b05-structure__stale .b05-route__field")].map((f) => [ + f.querySelector("span").textContent, + f.querySelector("input").value, + ]); + const form = () => ({ + type: typeSelect().selectedOptions[0]?.text, + containers: containers(), + typeLinks: typeLinks(), + values: Object.fromEntries( + [...section().querySelectorAll("[data-option-keys]")] + .filter(shown) + .map((n) => [n.dataset.optionKeys, n.querySelector("input, select").value]), + ), + stale: stale(), + }); + const out = { mode: MODE }; + + if (MODE === "seed") { + const now = await api.fetchStructures(projectId); + sessionStorage.setItem("__527_original", JSON.stringify(now.structures)); + const legacy = { + structure_id: null, + type_id: "refuge", + placement: "interval", + chainage_m: 150, + start_m: 145, + end_m: 160, + options: { width_m: 4, length_m: 15 }, + memo: "52-7 옛 대피소", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + await api.saveStructures(projectId, now.revision, [...now.structures, legacy]); + api.writePendingStructures(projectId, null); + const saved = await api.fetchStructures(projectId); + out.seeded = saved.structures.filter((s) => s.type_id === "refuge").length; + out.total = saved.structures.length; + } + + if (MODE === "edit") { + await until(() => section() && groupSelect().options.length > 1); + await choose(groupSelect(), "E"); + out.picker = [...typeSelect().options].filter((o) => o.value).map((o) => o.text); + // 컨테이너 없는 일곱 — 기준 측점 아래 잇기 1 · 컨테이너 0 + out.seven = {}; + for (const id of [ + "mirror", + "curb", + "hazard_sign", + "position_sign", + "water_intake", + "completion_sign", + "barrier_gate", + ]) { + await choose(typeSelect(), id); + out.seven[typeSelect().selectedOptions[0].text] = [containers().length, typeLinks()]; + } + // 대피로 — 상세 내용 컨테이너 · 상세 셋 + await pickType("evac_road"); + await place(7, 0); + out.road = form(); + out.roadDetails = [...opt("detail").options].filter((o) => o.value).map((o) => o.text); + await setOpt("detail", "정차/작업장"); + section().querySelector(".b05-route__irregular-btn.is-primary").click(); + await w(600); + // 가드레일 — 형태 · 길이 10 · 전 3 → 후 7 + await pickType("guardrail"); + await place(5, 0); + await setOpt("form", "W빔 가드레일"); + await setOpt("length_m", "10"); + await setOpt("before_m", "3"); + out.guard = form(); + out.range = section().querySelector(".b05-structure__range-value").textContent; + section().querySelector(".b05-route__irregular-btn.is-primary").click(); + await w(600); + // 옛 대피소 기록 = 대피로 + 상세 대피소 · 옛 칸 값은 지난 정의 값 + await openItem("대피소"); + out.legacy = form(); + section().querySelectorAll(".b05-route__irregular-btn")[2].click(); // [리셋] — 안 고치고 닫음 + await w(300); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const file = await api.fetchStructures(projectId); + out.file = file.structures + .filter((s) => ["evac_road", "guardrail", "refuge"].includes(s.type_id)) + .map((s) => [s.type_id, s.placement, s.chainage_m, s.start_m, s.end_m, s.options]); + } + + if (MODE === "verify") { + await openItem("가드레일"); + const b05 = form(); + out.guard = b05; + await openItem("대피로"); + out.road = form(); + await openItem("대피소"); + out.legacy = form(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await openItem("가드레일"); + const b06 = form(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + location.hash = "#/b05-profile"; + await w(3000); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__527_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const now = await api.fetchStructures(projectId); + await api.saveStructures(projectId, now.revision, original); + api.writePendingStructures(projectId, null); + sessionStorage.removeItem("__527_original"); + const after = await api.fetchStructures(projectId); + const pick = (list) => JSON.stringify(list.map((s) => [s.type_id, s.chainage_m, s.options])); + out.restored = pick(after.structures) === pick(original); + out.count = after.structures.length; + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-8_종단_계류_숨김.js b/resources/tester/ui_checks/52-8_종단_계류_숨김.js new file mode 100644 index 000000000..d77b951a7 --- /dev/null +++ b/resources/tester/ui_checks/52-8_종단_계류_숨김.js @@ -0,0 +1,99 @@ +/* 52-8 — B05 · B06 좌측 「구조물 배치」 종단배수(B) · 계류사방(D) 고르기 목록에서 숨김 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub2_검증) + * 부르는 법: 앞에 `window.__m528 = "단계";` 를 붙여 + * orca eval --expression "window.__m528='seed'; $(cat 이 파일)" → 옛 값 둘(골막이 D · 측구 B)을 세션 초안에만 둠 + * (orca reload) + * orca eval --expression "window.__m528='verify'; $(cat 이 파일)" → 군 목록 · 우클릭 메뉴 · 옛 값 열기 (B05) + * (#/b06-section 으로 가서 reload 후 verify 한 번 더) + * orca eval --expression "window.__m528='clear'; $(cat 이 파일)" → 세션 초안 비움(서버 저장 안 함) + * 기대 수치(verify): visibleGroups 에 B · D 없음(0) · hiddenGroups 가림만 · menuHasBD 0 · + * 옛 값 목록 둘 그대로 · 옛 값을 열면 군 = D / B 로 열림(editGroup) · 잇기 칸 pickLinks 골막이(D) 1 · 측구(B) 0 + */ +(async () => { + const MODE = window.__m528 || "verify"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + 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 out = { mode: MODE, hash: location.hash }; + if (MODE === "seed") { + const base = { + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + memo: "", + options: {}, + }; + api.writePendingStructures(projectId, [ + { + ...base, + structure_id: "m528d", + type_id: "erosion_check", + placement: "point", + chainage_m: 40, + }, + { + ...base, + structure_id: "m528b", + type_id: "ditch_side", + placement: "interval", + start_m: 60, + end_m: 80, + }, + ]); + out.seeded = api.readPendingStructures(projectId).length; + return JSON.stringify(out); + } + if (MODE === "clear") { + api.writePendingStructures(projectId, null); + out.cleared = api.readPendingStructures(projectId) === null; + return JSON.stringify(out); + } + const groupSel = [...document.querySelectorAll("select")].find((s) => + [...s.options].some((o) => o.text.includes("A 횡단배수")), + ); + const isBD = (o) => /^(B|D) /.test(o.text); + out.visibleGroups = [...groupSel.options].filter((o) => !o.hidden && o.value).map((o) => o.text); + out.visibleBD = [...groupSel.options].filter((o) => !o.hidden && isBD(o)).length; + out.hiddenGroups = [...groupSel.options].filter((o) => o.hidden).map((o) => o.text); + // 우클릭 메뉴 목록(공용 만들기) — 종류 전부를 줘도 B · D 군이 안 나옴 + const menuMod = await import(modUrl("/B05_Profile/B05_Profile_UI_Profile_Structures.ts")); + const types = (await api.fetchStructureTypes(true)).map((t) => ({ + type_id: t.type_id, + group: t.group, + name: t.name, + })); + const menu = menuMod.structureGroupMenuItems(types, () => {}); + out.menuGroups = menu.map((m) => m.label); + out.menuHasBD = menu.filter((m) => /^(B|D) /.test(m.label)).length; + // 옛 값 — 목록에 그대로 · 열면 그 군으로 열림 + const rows = [...document.querySelectorAll(".b05-route__irregular-list li")]; + out.rows = rows.map((r) => r.textContent.trim().replace(/\s+/g, " ").slice(0, 40)); + const old = rows.find((r) => + (window.__m528row ? new RegExp(window.__m528row) : /골막이/).test(r.textContent), + ); + if (old) { + (old.querySelector("button") ?? old).click(); + await w(1500); + const sels = [...document.querySelectorAll("select")].filter((s) => + [...s.options].some((o) => o.text.includes("A 횡단배수")), + ); + // 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더 + if (!sels[0]?.value) { + (old.querySelector("button") ?? old).click(); + await w(1500); + } + out.editGroup = sels[0]?.value; + // 종단배수(B)는 조합 잇기 칸이 없음(0) · 계류사방(D)은 옛 그대로(1) + out.pickLinks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter( + (n) => n.offsetParent, + ).length; + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js new file mode 100644 index 000000000..c6025557e --- /dev/null +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -0,0 +1,607 @@ +/* 53-1 — B06 횡단도에서 구조물을 누르면 좌측 패널이 그 구조물로 켜지는가(전수) + * 시작 화면: B06 횡단설계(#/b06-section) · 창마다 자기 검증 프로젝트 · 로그인 상태 + * 부르는 법: orca eval 은 30초 안팎에 끊김 — 단계는 시작만 하고 결과는 따로 읽음 + * orca eval --expression "window.__m531='setup'; $(cat 이 파일)" → 초안 사진 + 표 20종 중 없는 종류를 세션에만 넣음(저장 안 함) + * orca eval --expression "window.__m531='check'; $(cat 이 파일)" → 전수 목록(벽 · 구체 · 카드 · 같은 측점 · 값 · 옮기기) + * orca eval --expression "window.__m531='handoff'; $(cat 이 파일)" → 저장 안 하고 B06 → B05 → B06 + * orca eval --expression "window.__m531='restore'; $(cat 이 파일)" → 초안을 사진대로 · 뒤에 orca reload + * 결과: orca eval --expression "JSON.stringify(window.__r531)" (끝나기 전엔 null) + * 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage 안 씀) + * 기대 수치: check · handoff 의 bad = [] · rows 마다 ok = true + * ① 종류 · 기준 측점 = 누른 것 ② 컨테이너 몫(유입 · 유출 · N단 · 좌우)이면 그 상자 강조 + 보임 + * ③ 목록 강조 1 · 종단 알약 1 ④ 좌측 값 → 횡단 다시 그림 ⑤ 다시 누름 = 풂 · 빈 곳 · 같은 측점 · 옮기기 · B05 ↔ B06 + */ +(() => { + const MODE = window.__m531 || "check"; + window.__r531 = null; + const PROJECT = localStorage.getItem("frd_current_project_id"); + const SNAP = "ui531-snap"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 250) { + if (fn()) return true; + await w(250); + } + return false; + }; + const sec = () => document.querySelector(".b05-structure-section"); + const selects = () => [...sec().querySelectorAll(".b05-structure__grid select")]; + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const flat = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const listRows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const cards = () => [...document.querySelectorAll("article.b06-cross-card")]; + const chainOf = (card) => { + const m = card.id.match(/(\d{9})(\d{3})$/); + return m ? Number(m[1]) + Number(m[2]) / 1000 : NaN; + }; + const selectedCard = () => document.querySelector(".b06-cross-card--selected"); + const click = (el) => el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + /** 지금 좌측 패널 · 목록 · 알약 · 선택 칸. */ + const state = () => { + const pickKey = Object.keys(sessionStorage).find( + (k) => k.includes("structure-pick") && k.includes(PROJECT), + ); + let pick = null; + try { + pick = JSON.parse(sessionStorage.getItem(pickKey)); + } catch { + /* 없음 */ + } + const box = [...document.querySelectorAll("fieldset.is-active")].map((f) => { + const r = f.getBoundingClientRect(); + let sc = f.parentElement; + while (sc && !/(auto|scroll)/.test(getComputedStyle(sc).overflowY)) sc = sc.parentElement; + const b = sc ? sc.getBoundingClientRect() : { top: 0, bottom: innerHeight }; + const seen = r.height > 0 && r.top < Math.min(b.bottom, innerHeight) && r.bottom > b.top; + return { legend: flat(f.querySelector("legend")), seen }; + }); + const anchor = sec().querySelectorAll(".b05-structure__position-row > label")[2]; + return { + type: selects()[1]?.value ?? "", + anchor: anchor ? [...anchor.querySelectorAll("input")].map((i) => i.value).join("+") : "", + pick, + box, + list: listRows() + .filter((li) => li.classList.contains("is-selected")) + .map(flat), + pill: [...document.querySelectorAll(".b05-structure__mark.is-selected")].map((m) => m.title), + card: selectedCard()?.id ?? null, + }; + }; + const bad = []; + const known = []; // 고치지 않은 흠(횡단 그리기 몫) — 보고에 올림 + const rows = []; + const judge = (name, ok, s, note = "") => { + rows.push({ + name, + ok, + note, + type: s.type, + key: s.pick?.key ?? null, + box: s.box, + list: s.list, + pill: s.pill.length, + card: s.card?.slice(-9) ?? null, + }); + if (!ok) bad.push(name); + }; + /** 부재키 → 강조돼야 할 상자 이름. own(사면 구조물)은 컨테이너가 없음. */ + const boxFor = (key) => + key === "inlet" + ? /유입|좌|우/ + : key === "outlet" + ? /유출|좌|우/ + : /^b?extra/.test(key ?? "") + ? /추가/ + : key === "left" || key === "right" + ? /유입|유출/ + : null; + const boxOk = (s) => { + const want = boxFor(s.pick?.key); + if (!want) return s.box.length === 0; + return s.box.length === 1 && want.test(s.box[0].legend) && s.box[0].seen; + }; + const commonOk = (s) => + !!s.type && s.list.length === 1 && s.pill.length === 1 && !!s.pick && boxOk(s); + const hitsOf = (card) => [ + ...card.querySelectorAll(".b06-chart__culvert-revet-hit, .b06-chart__box-hit"), + ]; + const uniqueHits = (card) => [...new Set(hitsOf(card))]; + /** 카드 고름을 풂(같은 카드 다시 누름 = 풂). */ + const clearCard = async () => { + const c = selectedCard(); + if (c) { + click(c.querySelector("svg")); + await w(900); + } + }; + const openProfile = async () => { + const prof = document.querySelector(".b05-route-profile"); + if (prof?.classList.contains("is-collapsed")) { + document.querySelector(".ui-workflow-overlay__handle--bottom")?.click(); + await w(2500); + } + // 뒤에 깔린 탭은 그리기가 밀림 — 알약이 설 때까지 크기 알림을 보냄 + for (let i = 0; i < 20 && !document.querySelector(".b05-structure__mark"); i++) { + dispatchEvent(new Event("resize")); + await w(500); + } + }; + /** 세션에만 넣는 종류(표 01~08) — 이미 있으면 건너뜀. [군, 종류, 측점, +m, 길이(구간형)] */ + const ADD = [ + ["G", "pavement_concrete", 22, 0, 10], + ["A", "cross_underdrain", 22, 0], + ["E", "mirror", 22, 0], + ["A", "ford_pavement", 3, 0], + ["A", "open_ditch", 5, 0], + ["A", "cross_drain_exposed", 6, 0], + ["E", "evac_road", 9, 0], + ["E", "guardrail", 11, 0, 10], + ["E", "curb", 14, 0], + ["E", "hazard_sign", 15, 0], + ["E", "position_sign", 18, 0], + ["E", "water_intake", 19, 0], + ["E", "completion_sign", 20, 0], + ["E", "barrier_gate", 21, 0], + ["E", "spoil_bank", 24, 0], + ["E", "borrow_pit", 25, 0], + ["호환", "etc", 27, 0], + ]; + const addOne = async ([g, t, no, plus, len]) => { + const btn = (label) => [...sec().querySelectorAll("button")].find((b) => flat(b) === label); + btn("리셋")?.click(); + await w(400); + const n = listRows().length; // A군은 측점을 넣는 순간 임시로 목록에 섬 + const [gs, ts] = selects(); + fire(gs, g); + await w(300); + if (![...ts.options].some((o) => o.value === t)) return `${t}: 종류 없음`; + fire(ts, t); + await w(1200); + const labels = [...sec().querySelectorAll(".b05-structure__position-row > label")]; + const anchor = labels[2].querySelectorAll("input"); + fire(anchor[0], String(no)); + await w(200); + fire(anchor[1], String(plus)); + await w(300); + const field = (re) => + [...sec().querySelectorAll("label")] + .filter((l) => l.offsetParent) + .find((l) => re.test(flat(l))) + ?.querySelector("input"); + if (len) { + // 구간형 — 길이 · 전(시작 = 기준 − 전), 사토장은 시작 · 종료 칸 + if (field(/^길이/)) { + fire(field(/^길이/), String(len)); + fire(field(/^기준측점 전/), String(len / 2)); + } + if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + } + await w(300); + } else if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + await w(300); + } + btn("추가")?.click(); + await w(1500); + return listRows().length > n ? null : `${t}: 안 들어감`; + }; + + const run = async () => { + // 뒤에 깔린 탭(ORCA)은 화면 프레임이 안 돎 — rAF 를 기다리는 넘김 · 스크롤이 멈춤. 시험 동안만 시간으로 대신 + let frame = false; + requestAnimationFrame(() => (frame = true)); + await w(300); + if (!frame && !window.__raf531) { + window.__raf531 = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + } + await until(() => listRows().length > 0 && cards().length > 0); + await w(1000); + const out = { mode: MODE, project: PROJECT }; + + if (MODE === "setup") { + if (!sessionStorage.getItem(SNAP)) + sessionStorage.setItem( + SNAP, + JSON.stringify( + Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:")), + ), + ); + const have = new Set(listRows().map((li) => flat(li).replace(/^[\d+~ ]+/, ""))); + out.added = []; + for (const item of ADD) { + const [g, t] = item; + selects()[0] && fire(selects()[0], g); + await w(200); + const name = [...selects()[1].options].find((o) => o.value === t)?.text ?? t; + if ([...have].some((h) => h.startsWith(name))) continue; + const err = await addOne(item); + out.added.push(err ?? name); + if (err) bad.push(err); + } + sec() + .querySelectorAll("button") + .forEach((b) => flat(b) === "리셋" && b.click()); + // 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로) + out.tier = null; + for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + if (/추가\(성토부\)/.test(card.innerHTML)) { + out.tier = card.id; + break; + } + const outlet = () => + [ + ...document.getElementById(card.id).querySelectorAll(".b06-chart__culvert-revet-hit"), + ].find((h) => /유출/.test(h.querySelector("title")?.textContent ?? "")); + click(outlet()); + await w(1400); + [...sec().querySelectorAll(".b06-structure-panel__struct--tier button")] + .find((b) => b.offsetParent && /추가/.test(b.title)) + ?.click(); + await w(2500); + const added = /추가\(성토부\)/.test(document.getElementById(card.id).innerHTML); + click(outlet()); // 풂 + await w(1000); + if (added) { + out.tier = card.id; + break; + } + } + if (!out.tier) bad.push("다단 벽 못 세움"); + out.rows = listRows().map(flat); + } + + if (MODE === "check") { + await openProfile(); + // ── 가. 횡단도에 그려진 고를 거리 전부 — 다른 카드에서 처음 누름 · 같은 카드 다른 부재 ── + await clearCard(); + const targets = cards().filter((c) => hitsOf(c).length); + for (const card of targets) { + const n = uniqueHits(card).length; + for (let i = 0; i < n; i++) { + const hit = uniqueHits(document.getElementById(card.id))[i]; + const before = state(); + click(hit); + await w(1400); + let s = state(); + // 이미 고른 부재였으면 풀렸을 것 — 한 번 더 눌러 고름 + if (!s.pick?.key && before.pick?.key) { + click(uniqueHits(document.getElementById(card.id))[i]); + await w(1400); + s = state(); + } + judge(`벽 ${card.id.slice(-9)}#${i}`, commonOk(s) && s.card === card.id, s); + } + } + // ── 나. 같은 부재 다시 누름 = 풂(카드 고름은 남음) ── + const t0 = targets[0]; + if (t0) { + click(uniqueHits(t0)[0]); + await w(1400); + if (!state().pick?.key) { + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + } + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + const s = state(); + judge("다시 누름 = 풂", !s.pick?.key && s.box.length === 0 && s.card === t0.id, s); + } + // ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ── + await clearCard(); + const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML)); + if (pipeCard) { + click(pipeCard.querySelector("svg")); + await w(1400); + let s = state(); + judge("빈 곳(구조물 카드)", !!s.type && !s.pick?.key && s.box.length === 0, s); + click(document.getElementById(pipeCard.id).querySelector("svg")); + await w(1400); + s = state(); + judge("빈 곳 다시 = 카드 풂", !s.card && !s.type && !s.pick, s); + } + const used = new Set(listRows().map((li) => flat(li).match(/^(\d+)\+/)?.[1])); + const empty = cards().find((c) => !used.has(String(Math.floor(chainOf(c) / 20)))); + if (empty) { + click(empty.querySelector("svg")); + await w(1400); + const s = state(); + judge("빈 카드", !s.type && s.box.length === 0 && s.card === empty.id, s); + await clearCard(); + } + // ── 라. 표 20종 — 목록으로 골라 종류 확인 → 그 카드 빈 곳 눌러 같은 것이 켜지나 ── + const rowsNow = listRows().map(flat); + for (const text of rowsNow) { + const li = listRows().find((l) => flat(l) === text); + if (!li) continue; + await clearCard(); + (li.querySelector("button") ?? li).click(); + await w(1400); + const viaList = state(); + const card = viaList.card && document.getElementById(viaList.card); + judge( + `목록 ${text}`, + !!viaList.type && viaList.list.length === 1 && !!card && viaList.pill.length === 1, + viaList, + ); + if (!card) continue; + await clearCard(); + click(card.querySelector("svg")); + await w(1400); + const viaCard = state(); + const same = viaCard.type === viaList.type; + if (same) + judge(`카드 ${text}`, viaCard.list.length === 1 && viaCard.pill.length === 1, viaCard); + // 같은 카드에 관(그려진 것)이 있으면 관이 먼저 — 그림 없는 구조물은 목록으로만(흠 · 그리기 몫) + else if (uniqueHits(card).length && viaCard.type) + known.push(`카드 ${text} → ${viaCard.list[0] ?? viaCard.type}`); + else judge(`카드 ${text}`, false, viaCard, `카드는 ${viaCard.type || "빈 것"}`); + } + await clearCard(); + // ── 마. 같은 측점 여럿 — 목록 구조물 → 그 카드 관 벽 → 다시 목록 ── + const shared = rowsNow.find( + (t) => + !/배관/.test(t) && + rowsNow.some((u) => /배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ); + if (shared) { + const pipeRow = rowsNow.find( + (u) => /배관/.test(u) && u.split(" ")[0] === shared.split(" ")[0], + ); + const pick = (t) => { + const li = listRows().find((l) => flat(l) === t); + (li.querySelector("button") ?? li).click(); + }; + pick(pipeRow); + await w(1400); + const pc = selectedCard(); + pick(shared); + await w(1400); + let s = state(); + judge(`같은 측점: 목록 ${shared}`, s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + const wall = pc && uniqueHits(document.getElementById(pc.id))[0]; + if (wall) { + click(wall); + await w(1400); + s = state(); + judge("같은 측점: 그 카드 관 벽", s.type === "pipe" && commonOk(s), s); + } + pick(shared); + await w(1400); + s = state(); + judge("같은 측점: 다시 목록", s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + } + // ── 바. 좌측 값 → 횡단 다시 그림 → 되돌림(관 유출 형태 · 세월교 련 수 · 박스 유입 구조) ── + const titles = (card) => + [...document.getElementById(card.id).querySelectorAll("title")].map((t) => t.textContent); + const ctlOf = (key) => + [...sec().querySelectorAll(`[data-spec-item="${key}"]`)] + .find((e) => e.offsetParent) + ?.querySelector("input, select"); + const bodyCase = async (card, key, value, re) => { + await clearCard(); + click(uniqueHits(document.getElementById(card.id))[0]); + await w(1400); + const ctl = ctlOf(key); + if (!ctl) return judge(`값 → 횡단: ${key}`, false, state(), "칸 없음"); + const was = ctl.value; + const before = titles(card).join("|"); + fire(ctl, value); + await w(3000); + const after = titles(card).join("|"); + judge( + `값 → 횡단: ${key}=${value}`, + re(after) && after !== before && boxOk(state()), + state(), + ); + fire(ctlOf(key), was); + await w(3000); + const back = titles(card).join("|"); + // 빈 칸 = 자동(24-4a) — 서버가 채운 기본 연장을 앞단이 지워 「연장」 표기만 빠짐(흠 · 저장하면 다시 섬) + const noSpan = (t) => t.replace(/ · 연장 [\d.]+m/g, ""); + if (back !== before && noSpan(back) === noSpan(before)) + known.push( + `값 되돌림 ${key}: 벽 설명의 「연장」 표기 빠짐(서버 기본값 · 저장 뒤 다시 섬)`, + ); + judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state()); + }; + // 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음 + for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + if (!ctlOf("outlet.form")) continue; + await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t)); + break; + } + const fordCard = targets.find((c) => /련/.test(titles(c).join("|"))); + if (fordCard) await bodyCase(fordCard, "ford.count", "3", (t) => /3련/.test(t)); + const boxCard = targets.find((c) => c.querySelector(".b06-chart__box-hit")); + if (boxCard) + await bodyCase( + boxCard, + "inlet.structure", + "없음", + (t) => (t.match(/날개벽 구간/g) ?? []).length === 1, + ); + // ── 사. 옮기기 — 사면 구조물 기준 측점 +2 → 목록 · 카드 · 알약 · 선택 칸이 따라감 → 되돌림 ── + await clearCard(); + // 사면 구조물 차례로 — 필수 칸이 빈 옛 구조물은 측점을 바꿔도 반영이 막히고 그 칸으로 포커스만 감(흠) + const input = () => + sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input")[0]; + const move = async (v) => { + const el = input(); + el.focus(); + fire(el, String(v)); + el.blur(); + el.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + await w(8000); + return state(); + }; + for (const c of targets.filter((c) => + /독립 기슭막이/.test(c.querySelector(".b06-chart__culvert-revet title")?.textContent ?? ""), + )) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + const s0 = state(); + const no = Number(input().value); + let s = await move(no + 2); + if (s.list[0] === s0.list[0]) { + const stuck = flat(document.activeElement?.closest("label") ?? document.body).slice( + 0, + 12, + ); + known.push(`옮기기 ${s0.list[0]}: 반영 안 됨 — 빈 필수 칸 「${stuck}」 으로 포커스만`); + await move(no); + continue; + } + judge( + "옮기기: 목록 · 카드 · 알약 · 선택 칸", + s.list[0]?.startsWith(`${no + 2}+`) && + Math.abs(chainOf(document.getElementById(s.card)) - (no + 2) * 20) < 1 && + s.pill.length === 1 && + s.pick?.sid === s0.pick?.sid && + Math.abs(s.pick.at - (no + 2) * 20) < 1, + s, + ); + s = await move(no); + judge( + "옮기기 되돌림", + s.list[0] === s0.list[0] && + Math.abs(chainOf(document.getElementById(s.card)) - no * 20) < 1 && + s.pill.length === 1, + s, + ); + break; + } + await clearCard(); + } + + if (MODE === "handoff") { + await openProfile(); + const go = async (hash) => { + location.hash = hash; + await until(() => listRows().length > 0 && selects()[1]?.value); + await w(5000); + return state(); + }; + if (!location.hash.includes("b06")) await go("#/b06-section"); + // 같은 측점에 다른 구조물이 있는 관을 고름(없으면 아무 관) + const texts = listRows().map(flat); + const pipeRow = + texts.find( + (t) => + /배관/.test(t) && + texts.some((u) => !/배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ) ?? texts.find((t) => /배관/.test(t)); + await clearCard(); + for (let i = 0; i < 2 && !selectedCard(); i++) { + // 목록 다시 누름 = 풂 — 이미 골라져 있었으면 한 번 더 + const li = listRows().find((l) => flat(l) === pipeRow); + (li.querySelector("button") ?? li).click(); + await w(1400); + } + const pipeCard = selectedCard(); + const outletHit = () => + uniqueHits(document.getElementById(pipeCard.id)).find((h) => + /유출/.test(h.querySelector("title")?.textContent ?? ""), + ); + await clearCard(); + click(outletHit()); + await w(1400); + const a = state(); + const b05 = await go("#/b05-profile"); + const b06 = await go("#/b06-section"); + judge( + "관 유출 → B05", + b05.type === a.type && b05.anchor === a.anchor && b05.list[0] === a.list[0], + b05, + ); + judge( + "B05 → B06 유출 그대로", + b06.pick?.key === "outlet" && boxOk(b06) && b06.list[0] === a.list[0], + b06, + ); + // 같은 측점 다른 구조물을 목록으로 — 부재키가 따라오지 않고 그 구조물이 열림 + const station = a.list[0]?.split(" ")[0]; + const other = listRows().find( + (li) => flat(li).startsWith(station + " ") && !/배관/.test(flat(li)), + ); + if (other) { + const otherText = flat(other); + (other.querySelector("button") ?? other).click(); + await w(1400); + const c = state(); + const b05b = await go("#/b05-profile"); + const b06b = await go("#/b06-section"); + judge("같은 측점 구조물 → B05", b05b.type === c.type && !b05b.pick?.key, b05b); + judge("B05 → B06 그 구조물", b06b.type === c.type && b06b.box.length === 0, b06b); + // B06 에서 관 카드를 고른 채 B05 에서 같은 측점 다른 구조물을 고르고 돌아옴 — 카드가 이미 골라져 있어도 그 구조물 + const pick = (t) => { + const row = listRows().find((l) => flat(l) === t); + (row.querySelector("button") ?? row).click(); + }; + pick(pipeRow); + await w(1400); + await go("#/b05-profile"); + pick(otherText); + await w(1400); + const d = state(); + const b06c = await go("#/b06-section"); + judge( + "B06 카드 골라진 채 B05 에서 다른 것 → B06", + b06c.type === d.type && b06c.type !== "pipe", + b06c, + ); + } + } + + if (MODE === "restore") { + const snap = sessionStorage.getItem(SNAP); + if (!snap) out.error = "사진 없음"; + else { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:") || k.includes("section-detail")) + sessionStorage.removeItem(k); + for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v); + sessionStorage.removeItem(SNAP); + const sorted = (list) => JSON.stringify([...list].sort(([a], [b]) => a.localeCompare(b))); + out.same = + sorted(Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:"))) === + sorted(JSON.parse(snap)); + } + } + + if (window.__raf531) { + window.requestAnimationFrame = window.__raf531; + delete window.__raf531; + } + out.count = rows.length; + out.bad = bad; + out.known = known; + out.rows = out.rows ?? rows; + return out; + }; + run() + .then((out) => (window.__r531 = out)) + .catch((e) => (window.__r531 = { error: String(e?.stack ?? e) })); + return "started"; +})(); diff --git a/resources/tester/ui_checks/53-2_목록_강조.js b/resources/tester/ui_checks/53-2_목록_강조.js new file mode 100644 index 000000000..76c58b674 --- /dev/null +++ b/resources/tester/ui_checks/53-2_목록_강조.js @@ -0,0 +1,334 @@ +/* 53-2 — B05 · B06 좌측 하단 구조물 목록 · 하이라이트 전수 점검 + * 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계 · 검증 프로젝트(sub4_검증) · 로그인 · 종단 도크 펼침 + * B05 3D 는 [3D 업데이트] 뒤(코리도 부재 신원표가 있어야 함) + * 자료: 구조물 정본에 표 20종 + 숨긴 종단배수(측구) · 옛 계류사방(골막이 · 기슭막이) · 옛 대피소 · 기타 손 이름 · + * 같은 측점 여럿(4+5 = 배관 · 메쌓기 기준점 · 경계석 / 9+0 = 반사경 · 번호판) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m532 = "단계";` + * names → 목록 이름 = 정본 · 레지스트리로 지은 이름(기준점 차례 · 같은 이름 번호) · 측점 글 + * list → 줄마다 누르기: 그 줄만 강조 · 폼 종류 · 알약 하나(그 구조물) · B05 3D(그 측점 부재) · B06 카드(그 측점) · 다시 누르면 다 풀림 + * pill → 알약마다 누르기: 그 줄 강조 · 목록 칸 안에 보임 · 알약 그대로 켜짐 + * card → (B06) 구조물 기준점 측점 카드 누르기: 그 측점 줄 강조 · 알약 켜짐 + * three → (B05) 3D 부재 누르기(화면 좌표 · 실제 포인터 이벤트): 그 측점 줄 강조 · 목록 칸 안 + * misc → 마우스 올림 규칙(목록 줄 · 알약) · 목록 스크롤(칸보다 김 · 끝 줄 알약 → 보임) + * edit → (B05) 반사경 2+10 [추가] → 차례 · 번호 · 강조 풀림 → 그 줄 [삭제] → 처음 목록 → 옹벽 고르고 [저장] + * → 차례 그대로 · 강조 그대로 (서버 저장 · 검증 프로젝트) + * reopen → (orca reload 뒤 · orca screenshot 한 번 — 안 그려진 탭은 rAF 이 멈춰 「보이게 스크롤」이 안 돎) + * 옹벽 강조 · 알약 되살아남 · 목록 칸 안 + * (B05 · B06 모두 돌려 names · list 결과가 같은지 대조) + * 긴 단계(list · pill · card · three)는 연결이 끊길 수 있음 — `void` 로 띄우고 + * `sessionStorage.getItem("__532_out_{단계}_{b05|b06}")` 로 읽음 + * 쓰는 것: 화면 조작만(sessionStorage 선택 키) — 끝나면 고른 것 풀어 둠 + * ORCA 탭이 안 그려지는 동안 requestAnimationFrame 이 멈춤(목록 「보이게 스크롤」이 그 다음 프레임에 돎) — + * 단계 동안만 setTimeout 으로 대신 돌리고 끝에 되돌림 + * 기대 수치: 모든 단계 fails 0 + */ +(async () => { + const MODE = window.__m532 || "names"; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const pid = localStorage.getItem("frd_current_project_id"); + const api = async (u) => (await fetch(u, { credentials: "include" })).json(); + const vis = (el) => !!el && el.getClientRects().length > 0; + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const isB06 = location.hash.includes("b06"); + const list = () => document.querySelector(".b05-route__irregular-list"); + const items = () => [...list().querySelectorAll("li.b05-route__irregular-item")]; + const selRows = () => items().filter((li) => li.classList.contains("is-selected")); + const typeSel = () => + document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + const marks = () => [...document.querySelectorAll(".b05-structure__mark")].filter(vis); + const litPills = () => + marks() + .filter((m) => m.classList.contains("is-selected")) + .map((m) => m.dataset.structureId); + const cardSel = () => + [...document.querySelectorAll(".b06-cross-card--selected")] + .filter(vis) + .map((c) => Number(txt(c).split(" ")[1]?.replace("m", ""))); + const tags = () => { + const out = []; + window.__corridorScene?.scene.traverse((o) => { + if (!o.isMesh || typeof o.userData?.chainageM !== "number") return; + const ms = Array.isArray(o.material) ? o.material : [o.material]; + out.push({ o, c: o.userData.chainageM, lit: ms.some((m) => m.emissive?.getHex()) }); + }); + return out; + }; + const lit3D = () => [ + ...new Set( + tags() + .filter((t) => t.lit) + .map((t) => t.c.toFixed(2)), + ), + ]; + const inView = (li) => { + const a = li.getBoundingClientRect(); + const b = list().getBoundingClientRect(); + return a.top >= b.top - 1 && a.bottom <= b.bottom + 1; + }; + + // 기대 목록 — 정본 둘(구조물 · 관 지점)을 기준점 오름차순으로(목록과 같은 규칙) + const types = (await api("/api/projects/structure-types?include_disabled=true")).types; + const typeName = (id) => types.find((t) => t.type_id === id)?.name ?? id; + const interval = 20; + const sta = (m) => { + let n = Math.floor((m + 1e-6) / interval); + let r = m - n * interval; + if (Math.abs(r) < 0.05) r = 0; + if (r >= interval - 0.05) ((n += 1), (r = 0)); + return `${n}+${Math.round(r)}`; + }; + const structures = (await api(`/api/projects/${pid}/route/structures`)).structures; + const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + const rows = [ + ...structures.map((s) => ({ + anchor: s.chainage_m ?? s.start_m, + type: s.type_id === "refuge" ? "evac_road" : s.type_id, // 옛 대피소는 대피로 폼으로 엶(52-7) + pill: s.structure_id, + name: (s.type_id === "etc" && String(s.options?.name ?? "").trim()) || typeName(s.type_id), + station: s.placement === "interval" ? `${sta(s.start_m)}~${sta(s.end_m)}` : sta(s.chainage_m), + })), + ...pipes.map((p) => ({ + anchor: p.chainage_m, + type: p.facility ?? "pipe", + pill: `pipe-${p.chainage_m.toFixed(2)}`, + name: typeName(p.facility ?? "pipe"), + station: sta(p.chainage_m), + })), + ].sort((a, b) => a.anchor - b.anchor); + const total = {}; + rows.forEach((r) => (total[r.name] = (total[r.name] ?? 0) + 1)); + const seen = {}; + rows.forEach((r) => { + if (total[r.name] > 1) r.name = `${r.name} ${(seen[r.name] = (seen[r.name] ?? 0) + 1)}`; + r.text = `${r.station} ${r.name}`; + }); + + const clearPick = async () => { + for (let k = 0; k < 2 && selRows().length; k++) { + selRows()[0].click(); + await w(900); + } + }; + const out = { mode: MODE, view: isB06 ? "b06" : "b05", fails: [] }; + const fail = (what) => out.fails.push(what); + if (MODE === "reopen") { + // 다시 연 화면 — 저장 전에 고른 것이 되살아났는가(세션 고름) + const sel = selRows(); + out.restored = sel.map(txt); + if (sel.length !== 1 || !/옹벽$/.test(txt(sel[0]))) fail(`되살린 강조 ${out.restored}`); + else if (!inView(sel[0])) fail("되살린 줄이 목록 칸 밖"); + const pills = litPills(); + if (pills.length !== 1 || pills[0] !== rows.find((r) => /옹벽$/.test(r.text))?.pill) + fail(`되살린 알약 ${pills}`); + } + await clearPick(); + + if (MODE === "names") { + const got = items().map(txt); + out.rows = got.length; + out.expected = rows.length; + rows.forEach((r, i) => got[i] !== r.text && fail(`${i}: ${got[i]} ≠ ${r.text}`)); + out.names = got; + } else if (MODE === "list") { + const tagCs = new Set(tags().map((t) => t.c.toFixed(2))); + for (const [i, r] of rows.entries()) { + items()[i].click(); + await w(isB06 ? 1500 : 900); + const sel = selRows(); + if (sel.length !== 1 || sel[0] !== items()[i]) fail(`${r.text}: 줄 강조 ${sel.map(txt)}`); + if (typeSel()?.value !== r.type) fail(`${r.text}: 폼 종류 ${typeSel()?.value}`); + const pills = litPills(); + if (pills.length !== 1 || pills[0] !== r.pill) fail(`${r.text}: 알약 ${pills}`); + if (!isB06) { + const want = [...tagCs].filter((c) => Math.abs(c - r.anchor) < 0.01); + const got = lit3D(); + if (want.join() !== got.join()) fail(`${r.text}: 3D ${got} ≠ ${want}`); + } else { + // 카드 = 기준점에 가장 가까운 측점 하나(카드 글 「측점 누가거리m …」) + const card = cardSel(); + if (card.length !== 1 || Math.abs(card[0] - r.anchor) > interval / 2) + fail(`${r.text}: 카드 ${card}`); + } + items()[i].click(); // 다시 누름 = 풂 + await w(900); + if (selRows().length || litPills().length || (!isB06 && lit3D().length)) + fail(`${r.text}: 풂 뒤 남음 ${selRows().length}/${litPills()}/${lit3D()}`); + } + out.checked = rows.length; + } else if (MODE === "pill") { + for (const r of rows) { + const pill = marks().find((m) => m.dataset.structureId === r.pill); + if (!pill) { + fail(`${r.text}: 알약 없음`); + continue; + } + pill.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(isB06 ? 1500 : 900); + const sel = selRows(); + if (sel.length !== 1 || txt(sel[0]) !== r.text) fail(`${r.text}: 줄 ${sel.map(txt)}`); + else if (!inView(sel[0])) fail(`${r.text}: 목록 칸 밖`); + if (litPills().join() !== r.pill) fail(`${r.text}: 알약 ${litPills()}`); + await clearPick(); + } + out.checked = rows.length; + } else if (MODE === "card") { + // 측점마다 카드 하나 — 기준점이 그 측점인 줄 중 하나가 켜져야 함(같은 측점 여럿은 52-15 규칙) + const cards = [...document.querySelectorAll(".b06-cross-card")]; + let n = 0; + for (const card of cards) { + const at = Number(txt(card).split(" ")[1]?.replace("m", "")); + const here = rows.filter((r) => Math.abs(r.anchor - at) < 0.51); + if (!here.length) continue; + // 관 시설 범위 안 카드는 관이 먼저(계곡 통과 시설 우선 · resolveSelection) + here.push(...rows.filter((r) => r.pill.startsWith("pipe-") && Math.abs(r.anchor - at) < 5)); + n += 1; + card.scrollIntoView({ block: "nearest" }); + await w(300); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(1500); + const sel = selRows(); + const hit = here.find((r) => sel.length === 1 && txt(sel[0]) === r.text); + if (!hit) fail(`${at}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`); + else if (litPills().join() !== hit.pill) fail(`${at}: 알약 ${litPills()} ≠ ${hit.pill}`); + if (sel.length && !inView(sel[0])) fail(`${at}: 목록 칸 밖`); + await clearPick(); + } + out.checked = n; + } else if (MODE === "three") { + const S = window.__corridorScene; + const canvas = [...document.querySelectorAll("canvas")] + .filter(vis) + .sort((a, b) => b.width * b.height - a.width * a.height)[0]; + const done = new Set(); + let n = 0; + // 종단 도크가 캔버스 아래를 덮음 — 단계 동안 접고 끝에 다시 폄 + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + handle?.click(); + await w(1500); + const o = S.toScene(0, 0, 0); // 장면 → 모델: x = x − o.x · y = −(z − o.z) · z = y − o.y + for (const t of tags()) { + const key = t.c.toFixed(2); + if (done.has(key)) continue; + const box = new S.THREE.Box3().setFromObject(t.o); + const c = box.getCenter(new S.THREE.Vector3()); + S.lookAt(c.x - o.x, -(c.z - o.z), c.y - o.y, 25, "top"); // 그 부재 위에서 내려다봄 + S.camera.updateMatrixWorld(); + const p = c.clone().project(S.camera); + const rect = canvas.getBoundingClientRect(); + const x = rect.left + ((p.x + 1) / 2) * rect.width; + const y = rect.top + ((1 - p.y) / 2) * rect.height; + const top = document.elementFromPoint(x, y); + if (top !== canvas) continue; // 패널에 가린 부재는 건너뜀 + const ray = new S.THREE.Raycaster(); + ray.setFromCamera(new S.THREE.Vector2(p.x, p.y), S.camera); + const first = ray.intersectObject(S.scene, true).find((h) => h.object.isMesh); + if (first?.object.userData?.chainageM !== t.c) continue; // 앞에 다른 물체 + done.add(key); + n += 1; + const ev = (type) => + new PointerEvent(type, { + bubbles: true, + clientX: x, + clientY: y, + button: 0, + pointerId: 7, + isPrimary: true, + }); + window.dispatchEvent(ev("pointerdown")); + canvas.dispatchEvent(ev("pointerdown")); + canvas.dispatchEvent(ev("pointerup")); + await w(1200); + const sel = selRows(); + const here = rows.filter((r) => Math.abs(r.anchor - t.c) < 0.51); + if (!here.some((r) => sel.length === 1 && txt(sel[0]) === r.text)) + fail(`3D ${key}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`); + else if (!inView(sel[0])) fail(`3D ${key}: 목록 칸 밖`); + if (!lit3D().includes(key)) fail(`3D ${key}: 부재 강조 ${lit3D()}`); + await clearPick(); + } + handle?.click(); + await w(1500); + out.tagged = new Set(tags().map((t) => t.c.toFixed(2))).size; + out.checked = n; + } else if (MODE === "misc") { + const rules = [...document.styleSheets].flatMap((s) => { + try { + return [...s.cssRules].map((r) => r.selectorText ?? ""); + } catch { + return []; + } + }); + if (!rules.includes(".b05-route__irregular-item:hover")) fail("목록 줄 마우스 올림 규칙 없음"); + if (!rules.includes(".b05-structure__mark:hover")) fail("알약 마우스 올림 규칙 없음"); + const l = list(); + out.scroll = { scrollH: l.scrollHeight, clientH: l.clientHeight }; + if (l.scrollHeight <= l.clientHeight) fail("목록이 칸보다 짧음 — 스크롤 시험 안 됨"); + for (const r of [rows[rows.length - 1], rows[0], rows[Math.floor(rows.length / 2)]]) { + marks() + .find((m) => m.dataset.structureId === r.pill) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(isB06 ? 1500 : 900); + const sel = selRows()[0]; + if (!sel || !inView(sel)) fail(`${r.text}: 스크롤 뒤 안 보임`); + await clearPick(); + } + out.checked = 5; + } else if (MODE === "edit") { + // 추가(반사경 2+10 · 폼) → 차례 · 강조 풀림 → 그 줄 고르고 삭제 → 처음과 같음 → 옹벽 고르고 [저장] + const before = items().map(txt); + const sec = document.querySelector(".b05-structure-section"); + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const [g, t] = sec.querySelectorAll(".b05-structure__grid select"); + fire(g, "E"); + await w(300); + fire(t, "mirror"); + await w(800); + const anchor = sec + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(anchor[0], "2"); + fire(anchor[1], "10"); + await w(300); + sec.querySelector(".is-primary").click(); // 추가 + await w(1500); + const added = items().map(txt); + out.added = added.slice(0, 3); + if (added.length !== before.length + 1) fail(`추가 뒤 줄 수 ${added.length}`); + if (added[1] !== "2+10 반사경 1" || !added.includes("9+0 반사경 2")) + fail(`추가 차례 · 번호 ${added.slice(0, 3)} · 9+0 ${added.find((x) => x.startsWith("9+0"))}`); + if (selRows().length || litPills().length) fail("추가 뒤 강조 남음"); + items()[1].click(); + await w(900); + if (txt(selRows()[0] ?? list()) !== "2+10 반사경 1") fail("새 줄 고르기"); + if (litPills().length !== 1) fail(`새 줄 알약 ${litPills()}`); + sec.querySelector(".is-danger").click(); // 삭제 + await w(1500); + const removed = items().map(txt); + if (removed.join("|") !== before.join("|")) fail("삭제 뒤 목록이 처음과 다름"); + if (selRows().length || litPills().length) fail("삭제 뒤 강조 남음"); + // 옹벽 고른 채 [저장] — 저장 뒤 차례 그대로 · 강조 그대로 + items() + .find((li) => /옹벽$/.test(txt(li))) + .click(); + await w(900); + [...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click(); + for (let k = 0; k < 120 && !document.querySelector(".ui-toast, [class*='toast']"); k++) + await w(500); + await w(3000); + out.toast = document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null; + const saved = items().map(txt); + if (saved.join("|") !== before.join("|")) fail("저장 뒤 목록 차례가 다름"); + out.afterSave = selRows().map(txt); + if (out.afterSave.length !== 1 || !/옹벽$/.test(out.afterSave[0])) fail("저장 뒤 강조 풀림"); + out.checked = 9; + } + window.requestAnimationFrame = raf; + out.failCount = out.fails.length; + sessionStorage.setItem(`__532_out_${MODE}_${out.view}`, JSON.stringify(out)); // 긴 단계는 여기서 읽음 + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/54-2_도움말.js b/resources/tester/ui_checks/54-2_도움말.js new file mode 100644 index 000000000..f3592c5b0 --- /dev/null +++ b/resources/tester/ui_checks/54-2_도움말.js @@ -0,0 +1,114 @@ +/* 54-2 — [?] 설명서: B02~B09 좌측 패널 아래 단추 묶음 맨 왼쪽 · 모달 = 왼쪽 차례 · 오른쪽 파일 글 · + * 구조물 종류를 고르면 그 항목 먼저 · 패널 안 설명 글 0 + * 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로 + * `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번 + * 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로) + * 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 → + * `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null) + * 기대 수치: + * - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) · + * modal = true · toc[0] = 「… 설명서」 · text = 그 설명서 제목 글 + * - B03 toc = 설명서 + 필요한 파일 · 고르는 방법 · 알아 둘 것 · B06 toc 에 「표준 횡단면 설정」 + * - pipeTopic = 「구조물 · 배관」(B05 에서 배관을 고른 채 [?]) · leftovers = 0(옮긴 글이 패널에 없음) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + const v = fn(); + if (v) return v; + await w(200); + } + return null; + }; + const MOVED = [ + "[저장]은 이 폼에 있는 칸만", + "에서 관리 — 여기서 넣어도", + "사방기술교본", + "표준 편절편성 단면 모식도", + "L형 측구는 각 횡단면도에서", + "끌어 놓으면 확장자로", + "shapefile 한 벌", + "작업 좌표계는 라이다 PRJ", + "칸이 생기기 전의 동작", + "소광리 도면 H=2.0", + ]; + const start = location.hash; + const vis = (n) => n && n.offsetParent !== null; + const leftText = () => + document.querySelector(".ui-workflow-overlay__panel--title")?.innerText ?? ""; + const openModal = async (btn) => { + btn.click(); + const box = await until(() => document.querySelector(".ui-help .ui-help__text h2")); + const res = { + modal: !!box, + toc: [...document.querySelectorAll(".ui-help__link")].map((b) => b.innerText.trim()), + text: box?.innerText ?? null, + active: document.querySelector(".ui-help__text")?.dataset.helpKey ?? null, + }; + const temp = [...document.querySelectorAll(".ui-help__link")].find((b) => + b.dataset.helpKey?.startsWith("99_"), + ); + if (temp) { + temp.click(); + res.temp = document.querySelector(".ui-help__text p")?.innerText ?? null; + } + document.querySelector(".ui-help .ui-modal__top button")?.click(); + await w(200); + res.closed = !document.querySelector(".ui-help"); + return res; + }; + const out = { pages: [] }; + // orca eval 은 한 번에 1분쯤에서 끊김 — 앞에 `window.__help542 = ["b05-profile", …];` 를 붙이면 그 화면만 + const routes = window.__help542 ?? [ + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ]; + let prev = null; + for (const route of routes) { + location.hash = `#/${route}`; + await w(3000); // 불러오는 동안의 임시 틀(B05 등)이 실제 화면으로 바뀔 때까지 + // 앞 화면 단추가 빠지고 이 화면 단추가 설 때까지 + const btn = await until(() => + [...document.querySelectorAll(".ui-help-btn")].find((b) => vis(b) && b !== prev), + ); + prev = btn; + await w(1500); + const row = btn?.parentElement; + const res = { + page: route.slice(0, 3).toUpperCase(), + btn: document.querySelectorAll(".ui-help-btn").length, + first: !!row && row.firstElementChild === btn, + inLeft: !!btn?.closest(".ui-sidebar-actions") || !!btn?.closest(".b02-proj__actions"), + leftovers: MOVED.filter((s) => leftText().includes(s)), + }; + if (btn) Object.assign(res, await openModal(btn)); + if (route === "b05-profile") { + // 배관 하나를 골라 [?] — 구조물 종류 항목이 먼저 열림(이미 골라져 있으면 그대로 · 끝나면 처음 상태로) + const picked = () => document.querySelector('[data-help-topic="구조물_배관"]'); + const was = !!picked(); + const item = await until(() => + [...document.querySelectorAll(".b05-route__irregular-item")].find((li) => + li.innerText.includes("배관"), + ), + ); + if (!was) item?.click(); + await until(picked, 8000); + await w(800); + res.leftoversPipe = MOVED.filter((s) => leftText().includes(s)); + const again = await openModal([...document.querySelectorAll(".ui-help-btn")].find(vis)); + res.pipeTopic = again.text; + if (!was) item?.click(); // 고름 풂 + } + out.pages.push(res); + } + location.hash = start; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/54-3_배관_횡단그림.js b/resources/tester/ui_checks/54-3_배관_횡단그림.js new file mode 100644 index 000000000..71ac4f824 --- /dev/null +++ b/resources/tester/ui_checks/54-3_배관_횡단그림.js @@ -0,0 +1,194 @@ +/* 54-3 — B06 횡단도 배관 그림: 집수정 높이 → 형상 · 흙막이 여섯 형태 = 외곽 같음 · 안 무늬만 다름 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub7_검증) · 목록에 「배관 1」(4+5) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m543 = "단계";` + * basin → 유입 구조 집수정 · I · L · ㄷ형마다 높이 1.0 → 2.0 → 그림 높이(m, SVG 실좌표 ÷ 세로 눈금) + * soil → 유입 · 유출 흙막이 형태 여섯 → 벽 외곽 꼭짓점(SVG) 같음 · 무늬(해칭 묶음) 서로 다름 + * extra → 추가 흙막이(다단) — 상세의 배관 측점마다 기하를 형태 여섯으로 돌려 외곽 꼭짓점 같음 + * 쓰는 것: 좌측 폼 값(캐시만 · 저장 안 함) — 단계 끝에 원래 값으로 되돌림 + * 기대 수치: basin 형태마다 h1 ≈ 1.0 · h2 ≈ 2.0 · ratio ≈ 2.0 / soil sameOutline true · distinctHatch 6 / + * extra sameLean true · sameOutlineAtSameHeight true(다단이 선 측점 1곳 이상) · heightCapped = 지형 맞춤 키가 + * 형태 높이 한계에 걸린 측점(키만 다름) + */ +(async () => { + const MODE = window.__m543 || "basin"; + 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 ctl = (c, item) => + spec() + ?.querySelector(`[data-spec-container="${c}"] [data-spec-item="${c}.${item}"]`) + ?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(900); + }; + const pickPipe = async () => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + if (spec()) return; + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(800); + }; + const card = () => document.querySelector(".b06-cross-card--selected svg"); + /** 세로 눈금 — 같은 x 에 놓인 숫자 글(표고)에서 1m 당 픽셀. */ + const pxPerM = () => { + const nums = [...card().querySelectorAll("text")] + .filter((t) => /^-?\d+(\.\d+)?$/.test(t.textContent.trim())) + .map((t) => ({ + v: Number(t.textContent), + x: t.getAttribute("x"), + y: Number(t.getAttribute("y")), + })); + const byX = {}; + for (const n of nums) (byX[n.x] ??= []).push(n); + const col = Object.values(byX) + .filter((g) => new Set(g.map((n) => n.y)).size > 1) + .sort((a, b) => b.length - a.length)[0]; + const a = col[0]; + const b = col[col.length - 1]; + return Math.abs(b.y - a.y) / Math.abs(b.v - a.v); + }; + const pts = (poly) => + poly + .getAttribute("points") + .trim() + .split(/\s+/) + .map((p) => p.split(",").map(Number)); + const minY = (poly) => Math.min(...pts(poly).map((p) => p[1])); + const round = (v) => Math.round(v * 100) / 100; + const out = { mode: MODE }; + await pickPipe(); + + if (MODE === "basin") { + const keep = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + await set("inlet", "structure", "집수정"); + out.shapes = {}; + for (const shape of ["I", "L", "U"]) { + await set("inlet", "form", shape); + const hs = []; + for (const h of ["1.0", "2.0"]) { + await set("inlet", "height", h); + const k = pxPerM(); + const parts = [...card().querySelectorAll(".b06-chart__culvert-basin")]; + const title = (p) => p.querySelector("title")?.textContent ?? ""; + const wall = parts.find((p) => / 벽 /.test(title(p))); + const floor = parts.find((p) => / 바닥 /.test(title(p))); + const label = [...card().querySelectorAll(".b06-chart__culvert-label")].find((t) => + t.textContent.startsWith("집수정"), + ); + // ㄴ · ㄷ = 바닥 윗면 ~ 벽 상단 · I = 관 유입 기준(라벨 자리 + 0.2) ~ 벽 상단 + const base = floor ? minY(floor) : Number(label.getAttribute("y")) - 11 - 0.2 * k; + hs.push({ name: title(wall).match(/\((.+?)\)/)?.[1], h: round((base - minY(wall)) / k) }); + } + out.shapes[shape] = { + name: hs[0].name, + h1: hs[0].h, + h2: hs[1].h, + ratio: round(hs[1].h / hs[0].h), + }; + } + await set("inlet", "structure", keep[0]); + await set("inlet", "form", keep[1]); + await set("inlet", "height", keep[2]); + out.restored = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + } + + if (MODE === "soil") { + for (const [side, word] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + // 구조가 비면 형태 칸이 안 섬 — 흙막이로 고른 뒤 끝에 되돌림 + const keepStructure = ctl(side, "structure").value; + if (keepStructure !== "기슭막이") await set(side, "structure", "기슭막이"); + const forms = [...ctl(side, "form").options].map((o) => o.value).filter(Boolean); + out.forms = forms; + const keep = ctl(side, "form").value; + const rows = []; + for (const form of forms) { + await set(side, "form", form); + const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) => + (p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`), + ); + const hatch = poly.nextElementSibling?.nextElementSibling; + rows.push({ + form, + title: poly.querySelector("title").textContent.slice(0, 40), + outline: pts(poly) + .map((p) => p.map(round).join(",")) + .join(" "), + hatch: `${hatch?.childElementCount}:${hatch?.innerHTML.length}`, + }); + } + await set(side, "form", keep); + if (keepStructure !== "기슭막이") await set(side, "structure", keepStructure); + out[side] = { + sameOutline: rows.every((r) => r.outline === rows[0].outline), + distinctHatch: new Set(rows.map((r) => r.hatch)).size, + outline: rows[0].outline, + rows: rows.map((r) => [r.form, r.hatch, r.title]), + restored: ctl(side, "structure").value === keepStructure, + }; + } + } + + if (MODE === "extra") { + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const geom = await import(modUrl("/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts")); + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const detail = JSON.parse(sessionStorage.getItem(key)); + const forms = geom.REVET_FORMS; + out.checked = 0; + out.sameLean = true; + out.sameOutlineAtSameHeight = true; + // 지형 맞춤 키는 형태 높이 한계(메 2.0 · 찰 3.0 · 콘 5.0 — 교본)에서 멈춤 — 같은 키끼리만 외곽 대조 + out.heightCapped = []; + for (const section of detail.cross_sections ?? detail.value?.cross_sections ?? []) { + if (!section.culvert || section.culvert.hidden_pipe) continue; + const rows = forms.map((m) => { + const walls = + geom.computeCulvertLayout(section, section.samples, { extras: [{ m }, { m }] }) + ?.extraWalls ?? []; + return { + m, + lean: walls.map((wall) => wall.lean).join(","), + height: walls.map((wall) => round(wall.height)).join(","), + outline: JSON.stringify( + walls.map((wall) => wall.points.map((p) => [round(p.offset), round(p.elevation)])), + ), + }; + }); + if (rows[0].outline === "[]") continue; + out.checked += 1; + if (!rows.every((r) => r.lean === rows[0].lean)) out.sameLean = false; + for (const r of rows) { + const twin = rows.find((o) => o.height === r.height); + if (twin.outline !== r.outline) out.sameOutlineAtSameHeight = false; + } + const heights = [...new Set(rows.map((r) => r.height))]; + if (heights.length > 1) + out.heightCapped.push({ + chainage: section.chainage_m, + byForm: rows.map((r) => `${r.m}=${r.height}`), + }); + } + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js new file mode 100644 index 000000000..92cc1c07c --- /dev/null +++ b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js @@ -0,0 +1,211 @@ +/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";` + * setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄 + * cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX → + * 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸 + * save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX + * reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길) + * restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음 + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음 + * 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage + * 기대 수치: 모든 단계 fails 0 · restore same true + */ +(async () => { + const MODE = window.__m544 || "cycle"; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const AT = 85.05; + const pid = localStorage.getItem("frd_current_project_id"); + const api = async (u) => (await fetch(u, { credentials: "include" })).json(); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const NAME = { + pipe: "배관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: "물넘이포장", + }; + const CHIP = { + pipe: "배수관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: null, + }; + /** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */ + const OWN = { + pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽) + box_culvert: ["body_width_m", "body_height_m"], + ford_bridge: ["pipe_count"], + ford_pavement: [], + }; + const typeSel = () => + document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li))); + const card = () => + [...document.querySelectorAll(".b06-cross-card")] + .map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) })) + .sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c; + const pill = () => + [...document.querySelectorAll(".b05-structure__mark")].find( + (m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`, + ); + const draft = () => { + const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`); + const v = raw ? JSON.parse(raw) : null; + return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null; + }; + const openDock = async () => { + if (pill()) return; + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + for (let k = 0; k < 20 && !pill(); k++) await w(300); + }; + const pick = async () => { + for (let k = 0; k < 40 && !row(); k++) await w(500); + if (!row()?.classList.contains("is-selected")) { + row()?.click(); + await w(2500); + } + }; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + /** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */ + const shapeOf = (c) => { + const svg = c.querySelector("svg"); + const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class")); + const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent); + if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert"; + if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge"; + if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe"; + return "none"; + }; + const check = (to, label) => { + const c = card(); + const got = { + shape: c ? shapeOf(c) : "카드 없음", + chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [], + notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [], + row: row() ? txt(row()) : null, + pill: pill() ? `${txt(pill())}|${pill().title}` : null, + type: typeSel().value, + facility: draft()?.facility, + keys: Object.keys(draft()?.options ?? {}), + }; + const want = to === "ford_pavement" ? "none" : to; + if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`); + if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length) + fail(`${label}: 제목 ${got.chip}`); + if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장"))) + fail(`${label}: 물넘이 알림 없음 ${got.notes}`); + if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v)))) + fail(`${label}: 옛 종류 알림 ${got.notes}`); + if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`); + if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`); + if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`); + if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to) + fail(`${label}: 초안 종류 ${got.facility}`); + for (const [kind, keys] of Object.entries(OWN)) + if (kind !== to && keys.some((k) => got.keys.includes(k))) + fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`); + return got; + }; + const change = async (to) => { + const t = typeSel(); + t.value = to; + t.dispatchEvent(new Event("change", { bubbles: true })); + await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to)); + await w(600); + }; + const save = async () => { + [...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click(); + await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000); + await w(4000); + return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null; + }; + const out = { mode: MODE, fails: [] }; + const fail = (what) => out.fails.push(what); + + if (MODE === "setup") { + const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + sessionStorage.setItem("__544_orig", JSON.stringify(pipes)); + out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + await pick(); + await openDock(); + out.start = check(typeSel().value, "처음"); + } else if (MODE === "cycle") { + await pick(); + await openDock(); + const path = [ + "pipe", + "box_culvert", + "ford_bridge", + "ford_pavement", + "pipe", + "ford_bridge", + "box_culvert", + "ford_pavement", + "ford_bridge", + "pipe", + "ford_pavement", + "box_culvert", + "pipe", + ]; + if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`); + out.steps = []; + for (let i = 1; i < path.length; i++) { + const label = `${i} ${path[i - 1]}→${path[i]}`; + await change(path[i]); + const got = check(path[i], label); + out.steps.push( + `${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`, + ); + } + out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size; + } else if (MODE === "save") { + await pick(); + await openDock(); + await change("box_culvert"); + out.toast = await save(); + await openDock(); + out.after = check("box_culvert", "저장 뒤"); + const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility; + if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`); + } else if (MODE === "reopen") { + await pick(); + await openDock(); + out.reopened = check("box_culvert", "다시 연 BOX"); + await change("pipe"); + out.toPipe = check("pipe", "저장 뒤 BOX→배관"); + } else if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null"); + if (!orig) fail("처음 정본 없음 — setup 먼저"); + else { + const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput) + sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points)); + location.reload(); + return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"` + } + } else if (MODE === "restore2") { + await pick(); + out.toast = await save(); + const orig = JSON.parse(sessionStorage.getItem("__544_orig")); + const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + const pick2 = (list) => + list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]); + out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig)); + if (!out.same) fail("되돌린 정본이 처음과 다름"); + out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + } + window.requestAnimationFrame = raf; + out.failCount = out.fails.length; + sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out)); + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css new file mode 100644 index 000000000..7c9f3f69e --- /dev/null +++ b/ui_template/ui_template_help.css @@ -0,0 +1,84 @@ +/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */ +.ui-sidebar-actions .ui-help-btn, +.ui-help-btn { + flex: none; + width: auto; + min-width: 2.25rem; + padding-inline: var(--spacing-12); +} + +.ui-help.ui-modal { + width: min(960px, 92vw); + height: var(--modal-max, 80vh); +} + +.ui-help .ui-modal__body { + flex: 1 1 auto; + min-height: 0; +} + +.ui-help__grid { + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--spacing-16); + height: 100%; + min-height: 0; +} + +.ui-help__toc { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-right: var(--spacing-8); + border-right: 1px solid var(--color-border); + overflow-y: auto; +} + +.ui-help__link { + padding: var(--spacing-4) var(--spacing-8); + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.ui-help__link:hover, +.ui-help__link.is-active { + background: color-mix(in srgb, var(--color-border) 60%, transparent); + color: var(--color-text-body); +} + +.ui-help__text { + overflow-y: auto; + line-height: 1.6; +} + +.ui-help__text h2 { + margin: 0 0 var(--spacing-12); + font-size: 1.4em; +} + +.ui-help__text h3 { + margin: var(--spacing-16) 0 var(--spacing-4); + font-size: 1.1em; +} + +.ui-help__text h4, +.ui-help__text h5 { + margin: var(--spacing-12) 0 var(--spacing-4); + font-size: 1em; +} + +.ui-help__text p, +.ui-help__text ul, +.ui-help__text ol { + margin: 0 0 var(--spacing-8); +} + +.ui-help__text ul, +.ui-help__text ol { + padding-left: 1.4em; +} diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts new file mode 100644 index 000000000..712310b81 --- /dev/null +++ b/ui_template/ui_template_help.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * ui_template_help.ts + * [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용. + * 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜). + * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 + * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). + * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. + * ========================================================================== */ + +import "./ui_template_help.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el } from "./ui_template_elements"; +import { openModal } from "./ui_template_modal"; + +interface HelpItem { + key: string; + title: string; + text: string; +} + +const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" }; + +function currentPage(): string { + const n = /^#\/b0(\d)/i.exec(location.hash)?.[1]; + return n ? `B0${n}` : ""; +} + +function escapeHtml(text: string): string { + return text.replace( + /[&<>"]/g, + (c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`, + ); +} + +function inline(text: string): string { + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */ +export function renderHelpMarkdown(text: string): string { + const out: string[] = []; + let list: "ul" | "ol" | null = null; + let para: string[] = []; + const flush = (): void => { + if (para.length) out.push(`

    ${para.map(inline).join("
    ")}

    `); + para = []; + if (list) out.push(``); + list = null; + }; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + const head = /^(#{1,4})\s+(.*)$/.exec(line); + const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line); + if (!line) flush(); + else if (head) { + flush(); + out.push(`${inline(head[2])}`); + } else if (item) { + const kind = item[1] ? "ul" : "ol"; + if (para.length || list !== kind) flush(); + if (!list) out.push(`<${kind}>`); + list = kind; + out.push(`
  • ${inline(item[2])}
  • `); + } else { + if (list) flush(); + para.push(line); + } + } + flush(); + return out.join(""); +} + +function visibleTopics(): string[] { + const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document; + return [...panel.querySelectorAll("[data-help-topic]")] + .filter((node) => node.offsetParent !== null) + .map((node) => node.dataset.helpTopic ?? ""); +} + +async function fetchItems(page: string): Promise { + const response = await fetch(`${API_BASE_URL}/help/${page}`, { + credentials: "include", + cache: "no-store", + }); + return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : []; +} + +/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */ +const PAIR: Record = { B05: "B06", B06: "B05" }; + +async function openHelp(): Promise { + const page = currentPage(); + const pair = PAIR[page]; + const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]); + const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))]; + const topics = visibleTopics(); + const first = [...topics].reverse().find((key) => items.some((item) => item.key === key)); + openModal({ + title: `${TEXT.title} · ${page}`, + closeLabel: TEXT.close, + dialogClass: "ui-help", + mount: (body) => { + const article = el("article", { className: "ui-help__text" }); + const toc = el("nav", { className: "ui-help__toc" }); + const show = (key: string): void => { + const item = items.find((entry) => entry.key === key); + article.innerHTML = item ? renderHelpMarkdown(item.text) : `

    ${TEXT.empty}

    `; + article.dataset.helpKey = key; + toc + .querySelectorAll("button") + .forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key)); + }; + for (const item of items) { + const link = el("button", { + className: "ui-help__link", + text: item.title, + attrs: { type: "button" }, + }); + link.dataset.helpKey = item.key; + link.addEventListener("click", () => show(item.key)); + toc.append(link); + } + body.append(el("div", { className: "ui-help__grid", children: [toc, article] })); + show(first ?? items[0]?.key ?? ""); + }, + }); +} + +export function createHelpButton(): HTMLButtonElement { + const button = createButton({ + label: TEXT.button, + variant: "ghost", + onClick: () => void openHelp(), + }); + button.classList.add("ui-help-btn"); + button.title = button.ariaLabel = TEXT.tip; + return button; +} + +/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ +export function placeHelpButton(actions: HTMLElement): void { + if (actions.querySelector(".ui-help-btn")) return; + (actions.querySelector("[data-help-row]") ?? actions).prepend(createHelpButton()); +} diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 69fe4d017..ddf13ae38 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -228,46 +228,6 @@ export const ui_locales_b1 = { "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], - /* --- 좌측 안내 패널 (2026-09-03) --- */ - B03_Guide_Files_Title: ["필요한 파일", "Files you need"], - B03_Guide_Files_Route: [ - "계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", - "Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", - ], - B03_Guide_Files_Terrain: [ - "지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.", - "Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.", - ], - B03_Guide_Files_Optional: [ - "지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.", - "The terrain TIF (DEM) is optional — surface analysis runs without it.", - ], - B03_Guide_How_Title: ["고르는 방법", "How to pick"], - B03_Guide_How_Drop: [ - "여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.", - "Drop several files at once — each is assigned to a card by its extension.", - ], - B03_Guide_How_Card: [ - "카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.", - "Use a card's [Select] to set one slot, or ✕ to clear it.", - ], - B03_Guide_How_Temp: [ - "[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.", - "[Temporary storage] pulls files you staged on the dashboard.", - ], - B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"], - B03_Guide_Notes_Replace: [ - "이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.", - "Uploading again to a finished project replaces the existing analysis.", - ], - B03_Guide_Notes_Crs: [ - "작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.", - "The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.", - ], - B03_Guide_Notes_LasFree: [ - "라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.", - "With no LiDAR, turn on [Design without LAS] and work from sheet contours.", - ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], B03_File_Upload_Button: ["파일 업로드", "Upload files"], diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bcfd5c848..7dec8e9ff 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -528,10 +528,6 @@ export const ui_locales_b2 = { B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"], B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"], B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"], - B06_Std_LType_Note: [ - "L형 측구는 각 횡단면도에서 선택합니다.", - "L-type ditch is chosen per cross-section drawing.", - ], B06_Std_Reset: ["기본값 복원", "Restore defaults"], B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"], B06_Std_ApplyAll_Success: [ @@ -563,10 +559,6 @@ export const ui_locales_b2 = { B06_Std_Diagram_Fill: ["성토경사", "Fill slope"], B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"], B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"], - B06_Std_Diagram_Caption: [ - "표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).", - "Standard cut-fill section schematic — shows where each value applies (not to scale).", - ], /* --- B07_DesignDetail 상세 설계 --- */ B07_Design_Title: ["상세설계", "Detail Design"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index e25fb4236..d1201a47c 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,6 +1,7 @@ import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; +import { placeHelpButton } from "./ui_template_help"; import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. @@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void { while (container.firstChild && container.firstChild !== actions) scroll.append(container.firstChild); container.prepend(scroll); + placeHelpButton(actions); body.classList.add("ui-sidebar-split"); // body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장). for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) { diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 08b3604cb..fb5a9c0e5 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -162,6 +162,12 @@ export function createStepBar( return bar; } +function helpOnlyRow(): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-sidebar-actions"; + return row; +} + export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle { const root = document.createElement("div"); root.className = "ui-workflow-layout"; @@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }); const overlays = createWorkflowOverlays({ title: options.title, - optionsContent: options.leftPanel, + // 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2) + optionsContent: options.leftPanel ?? helpOnlyRow(), progressContent, onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen), });