diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts index a3520dbf2..302caa448 100644 --- a/A00_Common/design/design_structure_road.ts +++ b/A00_Common/design/design_structure_road.ts @@ -2,9 +2,10 @@ * design_structure_road.ts * 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`). * - * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. - * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. - * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * 시작 · 종료 측점(패널 위치 줄 · 기준 측점 · 전 · 후 · 길이 입력 없음) + 필수 컨테이너 「상세 내용」 한 벌 = + * 포장사양 고르기 → 길이(m · 시작~종료 출력) → 높이(m · 두께 손 입력). 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. + * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m` · `widening_off`(확폭 끈 구간 · 87-3) · + * 옛 `length_m` · `before_m` · `after_m` 은 저장 때 뺌(`dropped`). * 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) · * 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`. * import 없음 — 시험이 이 파일만 옮겨 돌림. @@ -12,8 +13,11 @@ import type { SpecChoice, StructureSpec } from "./design_structure_spec"; -/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */ -export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }]; +/** 포장사양 선택지 — 근거 별표2 · 별표3 · 임도기술교본 3장. */ +export const PAVE_SPEC_CHOICES: SpecChoice[] = ["콘크리트", "아스콘", "쇄석"].map((v) => ({ + value: v, + label: v, +})); export const PAVEMENT_SPEC: StructureSpec = { typeId: "pavement_concrete", @@ -31,6 +35,7 @@ export const PAVEMENT_SPEC: StructureSpec = { key: "pave_spec", choices: PAVE_SPEC_CHOICES, }, + { id: "length", label: "길이 (m)", kind: "auto" }, { id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 }, ], }, @@ -44,6 +49,8 @@ export const PAVEMENT_SPEC: StructureSpec = { "separation_sheet", "wire_mesh", ], + dropped: ["length_m", "before_m", "after_m"], + byRange: true, }; export const ETC_SPEC: StructureSpec = { diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 413f8135f..3ba11d1d1 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -69,6 +69,10 @@ export interface StructureSpec { containers: SpecContainer[]; /** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */ retired: string[]; + /** 저장 때 지우는 옛 키 — 화면에도 안 그림(옛 항목을 [수정]하면 빠짐). */ + dropped?: string[]; + /** 범위 직접 입력형 — 시작 · 종료 측점만 받고 기준 측점 칸 없음(알약 = 시점 · 길이 · 전 · 후 입력 없음). */ + byRange?: boolean; } /** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 9ddf07da9..c85bb404a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -49,6 +49,7 @@ import { type WallPath, } from "./B05_Profile_UI_Corridor_Structures_Path"; import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -274,7 +275,8 @@ export function buildCorridorStructures( const solids: CorridorStructure[] = []; for (const section of crossSections) { const layout = culvertLayoutOf(section); - const fordLayout = fordLayoutOf(section); + // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) + const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout); // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). @@ -449,12 +451,11 @@ export function buildCorridorStructures( if (fordLayout) { // 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다. const half = Math.max(fordLayout.ford.span_m, 0.5) / 2; - const invertPoints = fordLayout.pipe.outer; - const fordBore = pipeBore( - fordLayout.ford.diameter_m, - { offset: invertPoints[3].offset, elevation: invertPoints[3].elevation }, - { offset: invertPoints[2].offset, elevation: invertPoints[2].elevation }, - ); + // 관 끝 = 윗 꼭짓점 자리(흙막이 사면에서 안 튀어나옴 · 89-1) · 높이 = invert + const [topLeft, topRight, invertRight, invertLeft] = fordLayout.pipe.outer; + const pipeStart = { offset: topLeft.offset, elevation: invertLeft.elevation }; + const pipeEnd = { offset: topRight.offset, elevation: invertRight.elevation }; + const fordBore = pipeBore(fordLayout.ford.diameter_m, pipeStart, pipeEnd); // 관은 련수만큼 폭 안에 등간격 — 구멍도 관마다 하나씩 낸다(2026-08-25 사용자 ⑥). const count = Math.max(fordLayout.ford.pipe_count, 1); const pipeChainages = Array.from( @@ -518,7 +519,6 @@ export function buildCorridorStructures( ), ); // 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련). - const invert = fordLayout.pipe.outer; for (const at of pipeChainages) { const frame = frameAt(at); solids.push({ @@ -526,8 +526,8 @@ export function buildCorridorStructures( kind: "pipe", key: "pipe", pipe: { - start: [invert[3].offset, invert[3].elevation], - end: [invert[2].offset, invert[2].elevation], + start: [pipeStart.offset, pipeStart.elevation], + end: [pipeEnd.offset, pipeEnd.elevation], diameterM: fordLayout.ford.diameter_m, wallThicknessM: pipeWallThicknessM( fordLayout.ford.pipe_kind, diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 7aa35eb32..7a7c33571 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -161,6 +161,22 @@ export function buildStructuresForm(options: { optionRow, { get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne }, options.onFacilityChange, + // 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」) + (_c, item) => { + if (item.id !== "length") return ""; + const step = options.getInterval(); + const start = startFields.read(step, false); + const end = endFields.read(step, false); + return start !== null && end !== null && end > start + ? String(Number((end - start).toFixed(2))) + : ""; + }, + ); + // 시작 · 종료를 고치면 길이 출력을 다시 그림 + [startFields, endFields].forEach((fields) => + [fields.station, fields.remainder].forEach((input) => + input.addEventListener("change", () => structureSpec.refresh()), + ), ); groupBoxes.watch(structureSpec.paintLinks); const staleBox = document.createElement("fieldset"); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index ec97561b0..b29842a7e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -56,6 +56,7 @@ import { DEFAULT_INTERVAL_LENGTH_M, groupOptions, hasComputedRange, + isRangeOnly, typeOptions, readBeforeOption, readLengthOption, @@ -238,7 +239,7 @@ export function createStructuresSection( // [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김) root.dataset.helpTopic = type ? `구조물_${type.name}` : ""; // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 - anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); + anchorFields.wrap.hidden = isInterval && (!showsAnchor(type!) || isRangeOnly(type)); anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43) syncRangeDisplay(); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 222588462..5490be851 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -314,7 +314,10 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis return; } // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. - anchor = ctx.anchorFields.read(step, false) ?? start; + // 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점. + anchor = STRUCTURE_SPECS[type.type_id]?.byRange + ? start + : (ctx.anchorFields.read(step, false) ?? start); if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); ctx.anchorFields.station.focus(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index fec141a06..f97aaea72 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -20,6 +20,7 @@ import { } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -209,6 +210,10 @@ export function readBeforeOption(inputs: OptionEntry[]): number { return length !== null && before > length ? length : before; } +/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */ +export const isRangeOnly = (type: { type_id: string } | null): boolean => + !!type && !!STRUCTURE_SPECS[type.type_id]?.byRange; + /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 * 준다(2026-08-19 사용자 지시). */ @@ -217,6 +222,7 @@ export function hasComputedRange(type: StructureType | null): boolean { !!type && !type.managed_by && type.placement === "interval" && + !isRangeOnly(type) && type.options.some((option) => option.key === "length_m" && isB05Option(option)) ); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index e03819c49..e5cb9320d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -12,6 +12,7 @@ import type { StructureType } from "./B05_Profile_Api_Structures"; import { createSpecForm, type SpecForm, + type SpecFormHooks, type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; @@ -29,8 +30,9 @@ export function createStructureSpecForm( optionRow: HTMLElement, links: SpecLinks, onChange: () => void, + auto?: SpecFormHooks["auto"], ): SpecForm { - const form = createSpecForm(links, { onChange }); + const form = createSpecForm(links, { onChange, auto }); form.root.hidden = true; forms.set(optionRow, form); const lock = (): void => { @@ -49,7 +51,7 @@ export function createStructureSpecForm( /** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ export function specOwnedKeys(type: StructureType | null): Set { const spec = structureSpecOf(type); - return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []); + return new Set(spec ? [...specKeys(spec).keys, ...spec.retired, ...(spec.dropped ?? [])] : []); } /** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ @@ -85,5 +87,5 @@ export function specRenderedKeys( if (!spec) return []; const { keys, prefixes } = specKeys(spec); const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); - return [...keys, ...own]; + return [...keys, ...own, ...(spec.dropped ?? [])]; } diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ae320539e..967019212 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -60,6 +60,7 @@ export type { /** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */ import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment"; +import type { PavementEntry } from "@util/common_util_structure_pavement"; export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { @@ -315,6 +316,8 @@ export interface CrossSection extends SectionStation { ford_pavement?: FordPavementSpec; /** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */ revetment?: RevetmentSpec; + /** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */ + pavement?: PavementEntry[]; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 5190e78c2..a1963de5a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -77,6 +77,7 @@ from B06_Section.B06_Section_Schema import ( from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_pavement import attach_pavement_structures from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state from config.config_db import get_db_pool, run_with_connection @@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) + attach_pavement_structures(root, cross_sections) # 노면공 형상(88) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -497,6 +499,7 @@ async def regenerate_sections( # 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) attach_wall_structures(project_root, result["cross_sections"]) + attach_pavement_structures(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 2eb75a1db..85fd95747 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -34,6 +34,11 @@ import { wallSpecsFrom, type WallStructureInput, } from "@util/common_util_structure_walls"; +import { + attachPavementSpecs, + pavementSpecsFrom, + type PavementStructureInput, +} from "@util/common_util_structure_pavement"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -65,6 +70,15 @@ async function withDraftWalls( await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); const types = await fetchStructureTypes().catch(() => []); + // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) + const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id)); + if (paveTypes.size) { + for (const section of detail.cross_sections) delete section.pavement; + attachPavementSpecs( + detail.cross_sections as unknown as Array>, + pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes), + ); + } const names = new Map( types // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index be2f43718..640af0277 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -210,8 +210,12 @@ export function withoutGuardedFill( if (!layout || !culvert?.culvert.body) return layout; const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward); const mid = layout.label.at.offset; - const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] => - points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p)); + const reach = ( + points: OffsetPoint[], + outward: number, + offset: (p: OffsetPoint) => number, + ): OffsetPoint[] => + points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset: offset(p) } : p)); if ("box" in layout && "topSlab" in layout) { const sides = layout.sides.map((side) => guarded(side.role === "left" ? 1 : -1) @@ -226,7 +230,7 @@ export function withoutGuardedFill( const to = wall?.bottomBack.offset ?? side.offset; if ((to - side.offset) * outward < 1e-6) continue; [topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) => - reach(p, outward, to), + reach(p, outward, () => to), ); } const run = @@ -237,19 +241,19 @@ export function withoutGuardedFill( const sides = ford.sides.map((side) => guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side, ); - // 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80) + // 관 · 바닥판이 흙막이까지 따라감 — 관 끝 꼭짓점 = 그 높이의 흙막이 전면(사면 이용 · 89-1) · + // 아래 꼭짓점(invert)은 종전 자리라 1 m 맞춤 그대로 · 바닥판 끝 = 배면(80) let { pipe, slabBridge } = ford; for (const side of ford.sides) { const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward); if (!wall) continue; - const front = - wall.points[2].offset + - wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation); + const faceAt = (p: OffsetPoint): number => + wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - p.elevation); pipe = { - outer: reach(pipe.outer, side.outward, front), - inner: reach(pipe.inner, side.outward, front), + outer: reach(pipe.outer, side.outward, faceAt), + inner: reach(pipe.inner, side.outward, faceAt), }; - slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset); + slabBridge = reach(slabBridge, side.outward, () => wall.bottomBack.offset); } if (pipe === ford.pipe) return { ...ford, sides } as T; const [right, left] = pipe.outer.slice(2); diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts index b35b73912..dca4ed947 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -1,3 +1,4 @@ +import type { PavementEntry } from "@util/common_util_structure_pavement"; import type { CrossDesign } from "./B06_Section_Api_Fetch"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -29,26 +30,80 @@ export function appendRockBoundaryOverlay( } } +type Edge = { offset_m: number; elevation_m: number }; + +/** 포장사양 → 무늬(88) — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌. 그 밖 = 채움만. */ +const PAVE_MARKS: Record = { + 콘크리트: { size: 6, marks: '' }, + 아스콘: { + size: 5, + marks: '', + }, + 쇄석: { + size: 9, + marks: + '' + + '', + }, +}; +/** 무늬 id 는 문서 안에서 유일 — 카드 · 다시 그리기마다 새 번호(물넘이 빗금과 같은 방식). */ +let paveSeq = 0; + +/** 확폭 끈 구간 — 규격 폭 끝(좌 = left − 확폭좌 · 우 = right + 확폭우 · 표고는 두 끝 사이 직선). */ +function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) { + const { left, right } = edges; + const span = right.offset_m - left.offset_m || 1; + const at = (offset: number): Edge => ({ + offset_m: offset, + elevation_m: + left.elevation_m + ((right.elevation_m - left.elevation_m) * (offset - left.offset_m)) / span, + }); + return { + left: at(left.offset_m - (design.widening_left_m ?? 0)), + right: at(right.offset_m + (design.widening_right_m ?? 0)), + }; +} + +/** + * 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 · + * 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만. + */ export function appendPavementOverlay( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, + pavement?: readonly PavementEntry[] | null, ): void { const edges = design.carriageway_edges ?? design.road_edges; - if (!design.paved || !edges) return; - const thickness = design.pavement_thickness_m ?? 0.2; - const { left, right } = edges; - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute( - "points", - [ - `${x(left.offset_m)},${y(left.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m - thickness)}`, - `${x(left.offset_m)},${y(left.elevation_m - thickness)}`, - ].join(" "), - ); - polygon.setAttribute("class", "b06-chart__pavement"); - svg.append(polygon); + if (!edges) return; + const layers = pavement?.length ? pavement : design.paved ? [null] : []; + let depth = 0; + for (const entry of layers) { + const thickness = entry?.thickness_m ?? design.pavement_thickness_m ?? 0.2; + const { left, right } = entry && !entry.widening_on ? standardEdges(edges, design) : edges; + const points = [ + `${x(left.offset_m)},${y(left.elevation_m - depth)}`, + `${x(right.offset_m)},${y(right.elevation_m - depth)}`, + `${x(right.offset_m)},${y(right.elevation_m - depth - thickness)}`, + `${x(left.offset_m)},${y(left.elevation_m - depth - thickness)}`, + ].join(" "); + depth += thickness; + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute("points", points); + polygon.setAttribute("class", "b06-chart__pavement"); + svg.append(polygon); + const spec = entry?.spec; + const mark = spec ? PAVE_MARKS[spec] : undefined; + if (!spec || !mark) continue; + const id = `b06-pave-hatch-${(paveSeq += 1)}`; + const defs = document.createElementNS(SVG_NS, "defs"); + defs.innerHTML = `${mark.marks}`; + const hatch = document.createElementNS(SVG_NS, "polygon"); + hatch.setAttribute("points", points); + hatch.setAttribute("class", "b06-chart__pavement-hatch"); + hatch.setAttribute("fill", `url(#${id})`); + hatch.dataset.paveSpec = spec; + svg.append(defs, hatch); + } } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f21cc38ca..c3c4217a3 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -145,15 +145,30 @@ export function appendFordOverlay( // 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다 // (배수관 기슭막이 `culvert-revet-hit`와 같은 수법). if (onSelectWall) { - // 본체(바닥판 · 관) 판정면 — 벽 판정면보다 먼저 깔아 겹친 자리는 벽이 먹는다(66-1). - for (const points of [layout.slabBridge, layout.pipe.outer]) { - const hit = polygon(points, "b06-chart__culvert-revet-hit", "세월교 본체 선택"); - hit.addEventListener("click", (event) => { - event.stopPropagation(); - onSelectWall("body"); - }); - layer.append(hit); - } + // 본체 판정면 = 바닥판 밑 ~ 측벽 상단 최고 사각형 하나(관 위 성토 · 벽-관 사이 포함 · 89-2) — + // 측벽 판정면 · 흙막이(뒤에 그리는 배관 오버레이)가 위에 얹혀 겹친 자리는 그쪽이 먹는다(66-1). + const offsets = [ + ...layout.slabBridge, + ...layout.sides.flatMap((side) => side.parts.flatMap((part) => part.points)), + ].map((point) => point.offset); + const top = Math.max(...layout.sides.map((side) => side.top.elevation)); + const bottom = Math.min(...layout.slabBridge.map((point) => point.elevation)); + const [from, to] = [Math.min(...offsets), Math.max(...offsets)]; + const bodyHit = polygon( + [ + { offset: from, elevation: top }, + { offset: to, elevation: top }, + { offset: to, elevation: bottom }, + { offset: from, elevation: bottom }, + ], + "b06-chart__culvert-revet-hit", + "세월교 본체 선택", + ); + bodyHit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectWall("body"); + }); + layer.append(bodyHit); // 측벽 판정면 = ㄴ(벽 + 바닥판)을 감싸는 가상 사각형(79-6) — 얇은 벽만 잡던 것을 넓힘. for (const side of layout.sides) { const points = side.parts.flatMap((part) => part.points); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index df688b16b..55bc589db 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -429,7 +429,8 @@ export function createCrossSectionCard( x, toDisplayY, ); - if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); + if (!fordPaved) + appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자). appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d240917ba..390b8c475 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -185,6 +185,22 @@ stroke-width: 1; } +/* 노면공 포장사양 무늬(88): 채움 박스 위에 무늬만 한 겹 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */ +.b06-chart__pavement-hatch { + stroke: none; +} + +.b06-chart__pave-mark { + fill: none; + stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent); + stroke-width: 1; +} + +.b06-chart__pave-mark--dot { + fill: color-mix(in srgb, var(--color-text-body) 70%, transparent); + stroke: none; +} + /* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */ .b06-chart__revetment { fill: color-mix(in srgb, #9b6bdc 22%, transparent); diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index aa57fc4ea..ff43a5cff 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -415,7 +415,7 @@ function drawDesign( ): void { const { design, culvert, box, ford, own } = layouts; const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); - if (!paved) appendPavementOverlay(svg, design, x, y); + if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement); appendCrossDesignOverlay( svg, design, diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index 71cf43bae..fd18f0489 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -40,8 +40,20 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: return found, missing +def _labels(whole: Any, spec: dict) -> dict[str, str]: + """고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌.""" + table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ" + rows = whole.numbers.get(table, {}) + out = {} + for value in spec.get("고르기") or []: + row = rows.get(str(value)) if isinstance(value, str) else None + if row: + out[value] = " ".join(str(row[k]) for k in ("이름", "규격") if row.get(k)) + return out + + def _listed(logics: list[dict]) -> list[dict[str, Any]]: - """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] out = [] for logic in logics: @@ -51,6 +63,9 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: one = {k: spec[k] for k in _SPEC if k in spec} one.setdefault("설명", None) one.setdefault("단위", None) + labels = _labels(whole, spec) + if labels: + one["고르기이름"] = labels specs.append({**one, "자동값": auto.get(spec["이름"])}) out.append( { diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index e70f8b978..b83422733 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -13,6 +13,7 @@ import { choicesOf, inputKey, isOutside, + labelOf, rangeOf, storedOf, type ValueDoc, @@ -65,7 +66,12 @@ export function createValueControl( if (control instanceof HTMLSelectElement) { control.append( ...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ...choices.map((c) => + el("option", { + attrs: { value: c.값, ...(c.이름 !== c.값 ? { title: c.값 } : {}) }, + text: c.이름, + }), + ), ); } const empty = value === null || value === ""; @@ -194,10 +200,15 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand }; if (opts.mode === "view") { const text = isEmpty(stored.값) ? auto : stored.값; + const label = text === null ? "—" : labelOf(input, text); const shown = el("span", { className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + text: label, + attrs: isEmpty(stored.값) + ? { title: VALUE_TEXT.auto } + : label !== String(text) + ? { title: String(text) } + : {}, }); paintState(); return el("div", { className: "m02-inputval__cell", children: [shown, state] }); diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts index b49ea4771..5d8c61de2 100644 --- a/M02_MasterTemplete/M02_Labor_Values_Model.ts +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -14,9 +14,14 @@ const text = (value: unknown): string => ? JSON.stringify(value) : String(value); +/** 값 → 보이는 글(85-1) — 원문번호면 「이름 규격」 · 그 밖은 값 그대로 */ +export const labelOf = (input: ItemInput, value: unknown): string => + input.고르기이름?.[text(value)] ?? text(value); + /** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + if (Array.isArray(input.고르기) && input.고르기.length) + return input.고르기.map((one) => labelOf(input, one)).join(" / "); const range = input.범위; if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; if (range && typeof range === "object") { @@ -60,7 +65,11 @@ export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: const object = typeof one === "object" && one !== null ? (one as Record) : null; const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + return { + 값: String(raw), + 이름: object ? String(object.이름 ?? raw) : labelOf(input, one), + raw, + }; }); } @@ -112,7 +121,7 @@ export function valueLine( for (const input of inputs) { const value = values?.[input.이름]; if (value !== undefined && value !== "") { - parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + parts.push(`${input.이름} ${labelOf(input, value)}${input.단위 ? input.단위 : ""}`); } else if (autoText(input) === "") empty += 1; } return { text: parts.join(" · "), empty }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index b52eb7eac..6504d18d6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -330,6 +330,8 @@ export interface ItemInput { 설명?: string; 단위?: string; 고르기?: unknown[]; + /** 고르기 원문번호 → 보이는 글(85-1 · 기계 「이름 규격」 · 직종 이름) */ + 고르기이름?: Record; 범위?: unknown; 자동값?: unknown; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index a0e9eb039..ea605c524 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { @@ -68,7 +69,7 @@ const TEXT = { noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", readFailed: "{value} 파일을 못 읽음 — {why}", - discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", + discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", take: "가져오기", @@ -179,6 +180,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); + const saveAll = async (): Promise => { + for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); + }; const reload = (): void => void load(column ? { id: column, label } : null, true); /** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */ @@ -461,7 +465,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { async function load(col: { id: string; label: string } | null, again = false): Promise { // 카드 고침을 저장 안 했으면 버릴지 물음(한 번) — 취소면 보던 열 그대로 - if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(TEXT.discard))) + if ( + !again && + anyDirty() && + col?.id !== column && + (await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === "stay" + ) return; const mine = ++seq; drop(); @@ -530,9 +539,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }, column: () => column, dirty: anyDirty, - saveAll: async () => { - for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); - }, + saveAll, discard: () => { drop(); opts.onDirty?.(false); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 9c2ab01b1..4b464ba3d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -98,7 +98,7 @@ export interface LaborDiagramSource { 로직: string; 이름: string; 결과단위?: string; - 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + 입력: { 이름: string; 단위?: string; 자동값?: unknown; 고르기이름?: Record }[]; }[]; /** calc 답 · 아직이면 null */ calc: { @@ -137,10 +137,11 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { inputs: (def?.입력 ?? []).map((x) => { const mine = r.입력값[x.이름]; const has = mine !== undefined && mine !== ""; + const raw = has ? String(mine) : autoOf(x.자동값); return { name: x.이름, unit: x.단위 ?? "", - value: has ? String(mine) : autoOf(x.자동값), + value: x.고르기이름?.[raw] ?? raw, // 85-1 원문번호 → 「이름 규격」 ...(has || !autoOf(x.자동값) ? {} : { auto: true }), }; }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 8bf976944..d11baa92a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -14,7 +14,8 @@ * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). * ========================================================================== */ -import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { clearPart, @@ -252,9 +253,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane if ((col?.id ?? null) !== column && cards.dirty()) { // 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림 const from = column; - void showConfirmDialog(TEXT.discardCards).then((ok) => { - if (!ok) return void (from && opts.keepColumn?.(from)); - cards.discard(); // 한 번 물었음 — 카드가 다시 묻지 않게 + void leaveOrStay(TEXT.discardCards, { + save: cards.saveAll, + stillDirty: cards.dirty, + discard: cards.discard, // 한 번 물었음 — 카드가 다시 묻지 않게 + }).then((got) => { + if (got === "stay") return void (from && opts.keepColumn?.(from)); void load(col); }); return; @@ -272,7 +276,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); // 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음 - if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) return; + if ( + cards.dirty() && + (await leaveOrStay(TEXT.discardCards, { save: cards.saveAll, stillDirty: cards.dirty })) === + "stay" + ) + return; const target = `${toNo} ${columnHead(to)}`; const here = { layer: getActiveLayer(), owner: getActiveOwner() }; const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index 73c701f68..37442f0e7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Dialogs.ts - * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기. * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). * ========================================================================== */ @@ -183,36 +183,3 @@ export function askFolder( }); }); } - -export type Settle = "save" | "discard" | "cancel"; - -/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */ -export function askSettle(): Promise { - return new Promise((resolve) => { - let done = false; - const finish = (value: Settle): void => { - if (done) return; - done = true; - resolve(value); - }; - openModal({ - title: L("M02_TreeUnsaved"), - closeLabel: L("M02_TreeCancel"), - dialogClass: "m02-modal m02-treedlg", - beforeClose: () => (finish("cancel"), true), - mount: (body, close) => { - const choose = (value: Settle) => () => { - finish(value); - close(); - }; - const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" }); - const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" }); - const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" }); - save.addEventListener("click", choose("save")); - discard.addEventListener("click", choose("discard")); - cancel.addEventListener("click", choose("cancel")); - body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] })); - }, - }); - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..a319ab1d8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -6,6 +6,7 @@ import { setLeaveGuard } from "../A00_Common/router"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; import { @@ -26,7 +27,6 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; -import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; @@ -202,19 +202,26 @@ export function createMain( event.returnValue = ""; }; window.addEventListener("beforeunload", guard); - const confirmLeave = async (): Promise => { - if (!busy()) return true; // 카드 고침(도면 · 엑셀)도 같은 물음 - if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) { - // 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 - host - .querySelector(".ss-input, .ui-sheet, iframe") - ?.focus({ preventScroll: true }); - return false; - } - dirty = false; - toDrawing.drop(); - return true; + /** + * 저장 안 한 고침 물음(떠나기 · 나무 고치기 한 몸) — 단추 셋 [취소] [버리고 넘어감] [저장 후 넘어감]. + * "clean" = 고침 없음 · "saved" · "discarded" · false = 머묾(취소 · 저장 실패 · 판 다름 · 카드 저장 실패). + * 열린 양식이 없는 화면(옵션 정의 등)은 저장할 것이 없어 2단추 그대로. + */ + const askUnsaved = async (message: string): Promise => { + if (!busy()) return "clean"; // 카드 고침(도면 · 엑셀)도 같은 물음 + const drop_ = (): void => { + dirty = false; + toDrawing.drop(); + }; + let got: "saved" | "discarded" | "stay"; + if (sel) got = await leaveOrStay(message, { save: doSave, stillDirty: busy, discard: drop_ }); + else got = (await showConfirmDialog(message)) ? (drop_(), "discarded") : "stay"; + if (got !== "stay") return got; + // 머묾 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 + host.querySelector(".ss-input, .ui-sheet, iframe")?.focus({ preventScroll: true }); + return false; }; + const confirmLeave = async (): Promise => !!(await askUnsaved(L("M02_DiscardConfirm"))); setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음 const showNotice = (nodes: (HTMLElement | string)[]): void => { @@ -573,15 +580,9 @@ export function createMain( } async function settle(): Promise { - if (!busy()) return "clean"; - const choice = await askSettle(); - if (choice === "cancel") return false; - if (choice === "save") { - await doSave(); - return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침 - } - dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음 - if (sel) await open(sel); + const got = await askUnsaved(L("M02_TreeUnsaved")); + if (got === false || got === "clean") return got; + if (got === "discarded" && sel) await open(sel); // 버림 — 화면을 서버 그대로 다시 읽음 return "changed"; } diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 4d41ea46b..b0d13902e 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -194,11 +194,11 @@ def _label(key: str, value: Any) -> str: def _length(item: dict[str, Any]) -> float | None: - stated = _number(item["options"].get("length_m")) - if stated: - return stated + # 구간형은 시작~종료가 먼저(노면공 87-2) — 옛 length_m 은 범위를 고쳐도 안 따라옴 start, end = _number(item.get("start_m")), _number(item.get("end_m")) - return abs(end - start) if start is not None and end is not None else None + if start is not None and end is not None: + return abs(end - start) + return _number(item["options"].get("length_m")) or None def _source_value(source: dict[str, Any], item: dict[str, Any]) -> float | None: diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py new file mode 100644 index 000000000..26d12b7d6 --- /dev/null +++ b/common_util/common_util_structure_pavement.py @@ -0,0 +1,116 @@ +"""노면공(G군) 구조물 정본 → 횡단 측점 포장 제원(`section.pavement`). + +범위(start~end · ±0.02 m) 안 측점마다 `[{type_id, spec, thickness_m, start_m, end_m, widening_on}]`. +두께 = `options.height_m` · 무늬 = `options.pave_spec` · 확폭 끈 구간 = `options.widening_off`("s-e,s-e"). +그림 · 정보만 — 토공 면적 · 포장 켜기(`design.paved`)는 건드리지 않음. + +**짝**: `common_util/common_util_structure_pavement.ts` — 브라우저는 저장 전 목록으로 같은 제원을 얹음. +거울 시험 `resources/tester/test_b06_structure_pavement_mirror.py`. +""" + +import logging +import math +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값. +_EDGE_TOLERANCE_M = 0.02 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if math.isfinite(value) else None + + +def widening_off_ranges(text: Any) -> list[tuple[float, float]]: + """「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬).""" + ranges: list[tuple[float, float]] = [] + for part in str(text or "").split(","): + bounds = part.split("-") + if len(bounds) != 2 or not bounds[0].strip() or not bounds[1].strip(): + continue + try: + start, end = float(bounds[0]), float(bounds[1]) + except ValueError: + continue + if math.isfinite(start) and math.isfinite(end): + ranges.append((min(start, end), max(start, end))) + return ranges + + +def pavement_specs_from( + structures: list[dict[str, Any]], type_ids: set[str] +) -> list[dict[str, Any]]: + """G군 구조물 → 구간 제원. 범위가 없으면(시작 · 끝 · 기준 모두 빔) 건너뜀.""" + specs: list[dict[str, Any]] = [] + for item in structures: + if item.get("type_id") not in type_ids: + continue + anchor = _num(item.get("chainage_m")) + start = _num(item.get("start_m")) + end = _num(item.get("end_m")) + start = anchor if start is None else start + end = anchor if end is None else end + if start is None or end is None: + continue + options = item.get("options") or {} + spec = options.get("pave_spec") + specs.append( + { + "type_id": item["type_id"], + "spec": spec if isinstance(spec, str) and spec else None, + "thickness_m": _num(options.get("height_m")), + "start_m": min(start, end), + "end_m": max(start, end), + "widening_off": widening_off_ranges(options.get("widening_off")), + } + ) + return specs + + +def _inside(chainage: float, start: float, end: float) -> bool: + return start - _EDGE_TOLERANCE_M <= chainage <= end + _EDGE_TOLERANCE_M + + +def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, Any]]) -> int: + """범위 안 측점에 `pavement` 목록을 얹음. 얹은 측점 수를 돌려줌.""" + attached = 0 + for section in sections: + chainage = _num(section.get("chainage_m")) + if chainage is None: + continue + found = [ + { + "type_id": spec["type_id"], + "spec": spec["spec"], + "thickness_m": spec["thickness_m"], + "start_m": spec["start_m"], + "end_m": spec["end_m"], + "widening_on": not any( + _inside(chainage, start, end) for start, end in spec["widening_off"] + ), + } + for spec in specs + if _inside(chainage, spec["start_m"], spec["end_m"]) + ] + if found: + section["pavement"] = found + attached += 1 + return attached + + +def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + + try: + type_ids = {key for key, value in structure_type_map().items() if value.group == "G"} + structures = [item.model_dump() for item in load_structures(str(project_root))[1]] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감 + logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)") + return 0 + return attach_pavement_specs(cross_sections, pavement_specs_from(structures, type_ids)) diff --git a/common_util/common_util_structure_pavement.ts b/common_util/common_util_structure_pavement.ts new file mode 100644 index 000000000..e4a6a0539 --- /dev/null +++ b/common_util/common_util_structure_pavement.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * common_util_structure_pavement.ts + * 노면공(G군) 구조물 → 횡단 측점 포장 제원(`section.pavement`). + * 파이썬 `common_util/common_util_structure_pavement.py` 의 **짝** — 거울 시험 + * `resources/tester/test_b06_structure_pavement_mirror.py`. + * + * 서버는 저장분으로 · 브라우저는 저장 전 목록으로 같은 제원을 얹음. + * 두께 = options.height_m · 무늬 = options.pave_spec · 확폭 끈 구간 = options.widening_off. + * ========================================================================== */ + +/** 구조물 정본 한 건(필요한 칸만) — `structures.json` 과 같은 이름. */ +export interface PavementStructureInput { + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; + options?: Record | null; +} + +/** 측점에 얹는 포장 한 건 — `section.pavement[]`. */ +export interface PavementEntry { + type_id: string; + /** 포장사양 — 콘크리트 · 아스콘 · 쇄석(비면 표준 그림). */ + spec: string | null; + /** 두께(m) — 노면선 아래로. 비면 표준 횡단 포장 두께. */ + thickness_m: number | null; + start_m: number; + end_m: number; + /** false = 확폭 끈 구간(규격 폭 끝까지만). */ + widening_on: boolean; +} + +export interface PavementSpec extends Omit { + widening_off: Array<[number, number]>; +} + +/** 파이썬 `_EDGE_TOLERANCE_M` — C군 벽과 같은 값. */ +const EDGE_TOLERANCE_M = 0.02; + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬). */ +export function wideningOffRanges(text: unknown): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const part of String(text ?? "").split(",")) { + const bounds = part.split("-"); + if (bounds.length !== 2 || !bounds[0].trim() || !bounds[1].trim()) continue; + const start = Number(bounds[0]); + const end = Number(bounds[1]); + if (Number.isFinite(start) && Number.isFinite(end)) + ranges.push([Math.min(start, end), Math.max(start, end)]); + } + return ranges; +} + +/** G군 구조물 → 구간 제원(파이썬 `pavement_specs_from`). */ +export function pavementSpecsFrom( + structures: readonly PavementStructureInput[], + typeIds: ReadonlySet, +): PavementSpec[] { + const specs: PavementSpec[] = []; + for (const item of structures) { + if (!typeIds.has(item.type_id)) continue; + const anchor = num(item.chainage_m); + const start = num(item.start_m) ?? anchor; + const end = num(item.end_m) ?? anchor; + if (start === null || end === null) continue; + const options = (item.options ?? {}) as Record; + const spec = options.pave_spec; + specs.push({ + type_id: item.type_id, + spec: typeof spec === "string" && spec ? spec : null, + thickness_m: num(options.height_m), + start_m: Math.min(start, end), + end_m: Math.max(start, end), + widening_off: wideningOffRanges(options.widening_off), + }); + } + return specs; +} + +const inside = (chainage: number, start: number, end: number): boolean => + start - EDGE_TOLERANCE_M <= chainage && chainage <= end + EDGE_TOLERANCE_M; + +/** 범위 안 측점에 `pavement` 목록을 얹음(파이썬 `attach_pavement_specs`). 얹은 측점 수. */ +export function attachPavementSpecs( + sections: Array>, + specs: readonly PavementSpec[], +): number { + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m); + if (chainage === null) continue; + const found: PavementEntry[] = specs + .filter((spec) => inside(chainage, spec.start_m, spec.end_m)) + .map((spec) => ({ + type_id: spec.type_id, + spec: spec.spec, + thickness_m: spec.thickness_m, + start_m: spec.start_m, + end_m: spec.end_m, + widening_on: !spec.widening_off.some(([start, end]) => inside(chainage, start, end)), + })); + if (found.length) { + section.pavement = found; + attached += 1; + } + } + return attached; +} diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 32a69a793..e6e792773 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 19, + "판": 20, "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`가 새 키로 옮긴다.", "구조물군": { @@ -3177,25 +3177,14 @@ ], "options": [ { - "key": "length_m", - "label": "길이", + "key": "height_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 + "default": 0.2, + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음" }, { "key": "thickness_cm", diff --git a/resources/master_template/구조물정의_이력/판_19.json b/resources/master_template/구조물정의_이력/판_19.json new file mode 100644 index 000000000..32a69a793 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_19.json @@ -0,0 +1,3803 @@ +{ + "판": 19, + "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, + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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_basin_height_m", + "label": "집수정 높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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": 1.5, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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": 1.5, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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_slope_deg", + "label": "경사각도", + "input": "number", + "unit": "°", + "default": 3, + "required": false, + "default_basis": "물넘이 포장 경사각 기본 3° — 사용자 확정값(PLAN 65-5)" + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 10 m — 사용자 확정 10-7(엔진 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": 2, + "required": false, + "default_basis": "세월교 배관 수량 기본 2련 — 사용자 확정값(PLAN 65-4)" + }, + { + "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": "wall_form", + "label": "형태", + "input": "select", + "choices": [ + "콘크리트" + ], + "default": "콘크리트", + "default_basis": "표 09 — 선택지 하나(콘크리트)", + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "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": "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": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "empty_means": "비우면 통나무·목재틀로 그림(횡단 기본 형태) — B05 로 옮긴 칸이라 필수로 안 막음(옛 흙막이 저장 보호)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "b05", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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": "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": "width_m", + "label": "소단 폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "소단 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비워도 놓임 — 아직 계산(소단 간격 · 횡단)에 안 쓰임(표 09 새 칸)" + }, + { + "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": "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": "site", + "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.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=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "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)", + "outlet.berm": "수평공간 (m)", + "soil_add": "흙막이", + "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=돌붙임": "돌붙임" + } + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.count": "수량 (련)", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "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)", + "outlet.berm": "수평공간 (m)", + "soil_add": "흙막이", + "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=돌붙임": "돌붙임" + } + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + }, + "soil_guard": { + "이름": { + "form=돌쌓기(찰)": "돌 쌓기(찰)", + "form=돌쌓기(메)": "돌 쌓기(메)", + "form=콘크리트": "콘크리트", + "form=돌망태": "돌망태", + "form=통나무·목재틀": "통나무/목재틀", + "form=바자": "바자" + } + } + }, + "표시": { + "pipe": { + "pipe_kind": false + }, + "soil_guard": { + "side": false, + "tiers": false, + "lift_m": false, + "shift_m": false, + "thickness_top_m": false, + "thickness_bottom_m": false, + "back_len_cm": false, + "stone_kind": false, + "stone_supply": false, + "stone_coeff_basis": false, + "fill_concrete_mpa": false, + "face_slope_ratio": false, + "foundation": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "retaining_wall": { + "form": false, + "side": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "berm": { + "length_m": false, + "before_m": false, + "after_m": false, + "interval_m": false, + "slope_deg": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } + ], + "retaining_wall": [ + { + "id": "gretaindetail", + "이름": "옹벽", + "옵션": [ + "wall_form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "masonry_wet": [ + { + "id": "gmasonwetdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "masonry_dry": [ + { + "id": "gmasondrydetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "soil_guard": [ + { + "id": "gsoilgdetail", + "이름": "흙막이", + "옵션": [ + "form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "boulder_masonry": [ + { + "id": "gboulderdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "stone_cm", + "bond", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "berm": [ + { + "id": "gbermdetail", + "이름": "상세 내용", + "옵션": [ + "width_m", + "height_m" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index e625ac36b..eda160d00 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -46,11 +46,14 @@ if (process.argv[2] === "road") { 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), + items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]), state, edited: s.writeOptions(spec, edited), cleared: s.writeOptions(spec, cleared), keys: s.specKeys(spec), retired: spec.retired, + dropped: spec.dropped, + byRange: spec.byRange, }), ); process.exit(0); diff --git a/resources/tester/helper_80_ford_pipe.cjs b/resources/tester/helper_80_ford_pipe.cjs index 552e817d0..4957556cc 100644 --- a/resources/tester/helper_80_ford_pipe.cjs +++ b/resources/tester/helper_80_ford_pipe.cjs @@ -65,6 +65,8 @@ function pipeFit(edit) { const { ford, culvert } = computeStoredLayouts(section, list); const [right, left] = ford.pipe.outer.slice(2); const ends = { 1: left.offset, [-1]: right.offset }; + const [topLeft, topRight] = ford.pipe.outer; + const tops = { 1: topLeft, [-1]: topRight }; const slab = ford.slabBridge.map((p) => p.offset); const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) }; return { @@ -80,6 +82,13 @@ function pipeFit(edit) { role: side.role, guard: true, pipeGap: r3(ends[side.outward] - front), + // 관 윗모서리 ↔ 그 높이의 흙막이 전면(사면 이용 · 89-1) + topGap: r3( + tops[side.outward].offset - + (wall.points[2].offset + + wall.outward * wall.lean * (wall.topJoint.elevation - tops[side.outward].elevation)), + ), + lean: wall.lean, slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset), }; }), diff --git a/resources/tester/test_52_10_structure_spec_road.py b/resources/tester/test_52_10_structure_spec_road.py index 395211b1f..5d1424837 100644 --- a/resources/tester/test_52_10_structure_spec_road.py +++ b/resources/tester/test_52_10_structure_spec_road.py @@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out): def test_포장사양_선택지는_명세_한_곳(out): - assert out["choices"] == ["콘크리트"] + assert out["choices"] == ["콘크리트", "아스콘", "쇄석"] + + +def test_상세내용_차례_포장사양_길이_출력_높이(out): + assert out["items"] == [ + ["spec", "포장사양", "select"], + ["length", "길이 (m)", "auto"], + ["height", "높이 (m)", "number"], + ] def test_저장_키_왕복_옛_칸은_명세_밖(out): @@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out): 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"] # 구간 길이 · 전 · 후는 정의 칸 그대로 + # 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1) + assert out["byRange"] is True + assert out["dropped"] == ["length_m", "before_m", "after_m"] + assert "length_m" not in out["retired"] assert set(out["retired"]) == { "thickness_cm", "width_m", @@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸(): 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"] + keys = [o["key"] for o in pave["options"]] + assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력 + height = next(o for o in pave["options"] if o["key"] == "height_m") + assert height["default"] == 0.2 diff --git a/resources/tester/test_80_ford_pipe.py b/resources/tester/test_80_ford_pipe.py index eead57fc1..f101a302f 100644 --- a/resources/tester/test_80_ford_pipe.py +++ b/resources/tester/test_80_ford_pipe.py @@ -47,6 +47,17 @@ def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> N assert side["slabGap"] == pytest.approx(0, abs=1e-3) +def test_관_윗모서리도_흙막이_전면_사면에(fit: dict) -> None: + """89-1 — 흙막이 사면 이용: 윗모서리가 그 높이 전면 자리라 벽 밖으로 안 튀어나감.""" + leaned = 0 + for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")): + for side in case["sides"]: + if side["guard"]: + assert side["topGap"] == pytest.approx(0, abs=1e-3) + leaned += side["lean"] > 0 + assert leaned + + def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None: base = fit["both"]["lengthM"] assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3) diff --git a/resources/tester/test_b06_structure_pavement_mirror.py b/resources/tester/test_b06_structure_pavement_mirror.py new file mode 100644 index 000000000..ad33bded7 --- /dev/null +++ b/resources/tester/test_b06_structure_pavement_mirror.py @@ -0,0 +1,131 @@ +"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험. + +`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가 +같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림. +""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +from common_util.common_util_structure_pavement import ( # noqa: E402 + attach_pavement_specs, + pavement_specs_from, +) + +STRUCTURES = [ + { + "type_id": "pavement_concrete", + "placement": "interval", + "chainage_m": None, + "start_m": 100.0, + "end_m": 160.0, + "options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"}, + }, + { + # 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로. + "type_id": "gravel_surfacing", + "placement": "interval", + "start_m": 150.0, + "end_m": 130.0, + "options": {"pave_spec": "", "height_m": "0.3"}, + }, + {"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}}, + {"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}}, + { + "type_id": "pavement_concrete", + "chainage_m": 300.0, + "options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"}, + }, +] +TYPE_IDS = ["pavement_concrete", "gravel_surfacing"] +CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const sections = input.chainages.map((chainage_m) => ({ chainage_m })); +attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids))); +writeFileSync(process.argv[3], JSON.stringify(sections)); +""" + + +def _python_result() -> list[dict]: + sections = [{"chainage_m": value} for value in CHAINAGES] + attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS))) + return sections + + +def _ts_result(tmp_path: Path) -> list[dict]: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text( + json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_pavement_specs_mirror(): + with tempfile.TemporaryDirectory() as workdir: + ts = _ts_result(Path(workdir)) + py = _python_result() + assert ts == py + by = {section["chainage_m"]: section.get("pavement") for section in py} + # 범위 밖 · 경계 오차(0.02 m) 안 + assert by[99.97] is None and by[160.05] is None and by[400.0] is None + assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"] + assert by[160.02][0]["thickness_m"] == 0.25 + # 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림 + assert by[119.99][0]["widening_on"] is False + assert by[100.0][0]["widening_on"] is True + # 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null + assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"] + gravel = by[145.0][1] + assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0) + assert gravel["thickness_m"] is None and gravel["spec"] is None + # 기준 측점만 있는 옛 항목 = 그 측점 한 점 + assert by[300.0] == [ + { + "type_id": "pavement_concrete", + "spec": "아스콘", + "thickness_m": 0.05, + "start_m": 300.0, + "end_m": 300.0, + "widening_on": False, + } + ] diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index 42caf4113..5553e17e8 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품(): def test_저장_안_한_카드_고침_물음(): struct = _text(M02 / "M02_MasterTemplete_Structure.ts") show = struct.split("const show = ")[1].split("const pickPrice")[0] - assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show + assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show assert "opts.keepColumn?.(from)" in show main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main - assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) + assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기 + assert ( + 'askUnsaved(L("M02_TreeUnsaved"))' in main + ) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄 assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main diff --git a/resources/tester/test_m02_drawing_card.py b/resources/tester/test_m02_drawing_card.py index 7db5d6688..b0a015979 100644 --- a/resources/tester/test_m02_drawing_card.py +++ b/resources/tester/test_m02_drawing_card.py @@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만(): def test_저장_안_한_카드_고침은_물음(): cards = _text("M02_MasterTemplete_ItemCards.ts") # 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄 - assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards + assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards assert "dirty: anyDirty," in cards assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts") diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 923e5fe8c..e800b2d63 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -70,6 +70,9 @@ const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; const size = { 이름: "규격", 고르기: [25, 40] }; const area = { 이름: "지역" }; const inputs = [height, kind, size, area]; +const machine = { + 이름: "기계", 고르기: ["0201-0060", "9999"], 고르기이름: { "0201-0060": "굴착기(무한궤도) 0.6㎥" }, +}; console.log(JSON.stringify({ stored: [ m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), @@ -82,6 +85,11 @@ console.log(JSON.stringify({ known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), lineEmpty: m.valueLine(inputs, {}), + named: [ + m.choicesOf(machine).map((c) => [c.값, c.이름]), m.valueHint(machine), + m.valueLine([machine], { 기계: "0201-0060" }).text, m.labelOf(machine, "9999"), + m.storedOf(machine, "0201-0060"), + ], })); """ @@ -100,6 +108,27 @@ def test_값_셈() -> None: assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님 + # 85-1 — 보이는 글 = 이름 규격 · 모르는 번호는 그대로 · 저장 값은 번호 + assert got["named"] == [ + [["0201-0060", "굴착기(무한궤도) 0.6㎥"], ["9999", "9999"]], + "굴착기(무한궤도) 0.6㎥ / 9999", + "기계 굴착기(무한궤도) 0.6㎥", + "9999", + "0201-0060", + ] + + +def test_고르기_원문번호에_이름표(client: TestClient) -> None: + """85-1 — 기계 · 집게 = 「이름 규격」 · 직종(입력 이름에 「직종」) = 직종 이름 · 고르기는 번호 그대로.""" + got = client.get("/api/m02/logic-lib/inputs", params={"keys": "GC000260"}).json() + specs = {i["이름"]: i for i in got["로직"][0]["입력"]} + assert specs["기계"]["고르기이름"]["0201-0060"] == "굴착기(무한궤도) 0.6㎥" + assert specs["집게"]["고르기이름"]["7206-0020"] == "부착용 집게 0.2㎥" + assert "0201-0060" in specs["기계"]["고르기"] + assert "고르기이름" not in specs["뒷길이"] and "고르기이름" not in specs["지역"] + whole = labor_inputs.m01.loaded()[1] + job = {"이름": "조종원직종", "고르기": ["1048", "없는번호"]} + assert labor_inputs._labels(whole, job) == {"1048": "건설기계운전사"} ROWS_SCRIPT = r""" diff --git a/resources/tester/test_m02_leave_dialog.py b/resources/tester/test_m02_leave_dialog.py new file mode 100644 index 000000000..594b1fc7e --- /dev/null +++ b/resources/tester/test_m02_leave_dialog.py @@ -0,0 +1,125 @@ +"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소. + - 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾). + - 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름. + - M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +let focused = null; +let overlay = null; +let buttons = []; +const node = (tag, o = {}) => ({ + tag, ...o, kids: [], on: {}, + append(...x) { this.kids.push(...x); }, + addEventListener(k, f) { this.on[k] = f; }, + remove() { overlay = null; }, +}); +const stubs = { + "./ui_template_elements": { + el: node, + createButton: (o) => { + const b = node("button", { label: o.label, variant: o.variant }); + b.focus = () => { focused = b.label; }; + b.click = o.onClick; + buttons.push(b); + return b; + }, + }, + "./ui_template_locale": { t: (k) => k }, +}; +globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } }; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { leaveOrStay } = box.exports; + +const run = async (pick, hooks) => { + buttons = []; + focused = null; + const calls = []; + const wrap = { + save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); }, + stillDirty: () => !!hooks.left, + discard: () => calls.push("discard"), + }; + const p = leaveOrStay("msg", wrap); + const labels = buttons.map((b) => b.label); + const first = focused; + if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} }); + else buttons[pick].click(); + return { got: await p, calls, labels, first }; +}; + +(async () => { + const out = { + cancel: await run(0, {}), + esc: await run("esc", {}), + discard: await run(1, {}), + save: await run(2, {}), + saveThrows: await run(2, { throws: true }), + saveLeft: await run(2, { left: true }), + }; + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + done = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_단추_순서_기본_초점_취소(got): + row = got["cancel"] + assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"] + assert row["first"] == "M02_TreeCancel" + + +def test_취소_Esc는_머묾(got): + assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == [] + assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == [] + + +def test_버리고_넘어감(got): + assert got["discard"]["got"] == "discarded" + assert got["discard"]["calls"] == ["discard"] + + +def test_저장_후_넘어감과_실패_머묾(got): + assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]} + assert got["saveThrows"]["got"] == "stay" # 저장 던짐 + assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등) + + +def test_M02_자리가_3단추_물음을_씀(): + main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") + assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main + assert "askSettle" not in main + assert "!(await showConfirmDialog(L(" not in main + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + assert "leaveOrStay(TEXT.discard" in cards + struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8") + assert struct.count("leaveOrStay(TEXT.discardCards") == 2 diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index df9aab5ae..e2ec9e675 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -216,3 +216,11 @@ def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path) assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"} kept = fill.strip_design(doc) assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음 + + +def test_집계_길이는_구간형이면_시작_종료가_먼저(project: Path) -> None: + # 노면공 87-2 — 범위를 고쳐도 옛 length_m 이 안 앞섬 + moved = {"start_m": 200, "end_m": 245, "options": {"length_m": 30}} + assert fill._length(moved) == 45 + assert fill._length({"options": {"length_m": 12}}) == 12 # 점형 · 구간 없음 = 입력 길이 + assert fill._length({"options": {}}) is None diff --git a/ui_template/ui_template_leave_dialog.ts b/ui_template/ui_template_leave_dialog.ts new file mode 100644 index 000000000..cf0a72ccb --- /dev/null +++ b/ui_template/ui_template_leave_dialog.ts @@ -0,0 +1,84 @@ +/* ============================================================================= + * ui_template_leave_dialog.ts + * 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추). + * 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾. + * 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm). + * ========================================================================== */ + +import { createButton, el } from "./ui_template_elements"; +import { t as L } from "./ui_template_locale"; + +export type LeaveChoice = "save" | "discard" | "cancel"; + +/** 3단추 물음 — 닫기 · Esc = "cancel" */ +export function askLeave(message: string): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const actions = el("div", { className: "ui-confirm__actions" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: LeaveChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const cancel = createButton({ + label: L("M02_TreeCancel"), + variant: "ghost", + onClick: () => done("cancel"), + }); + actions.append( + cancel, + createButton({ + label: L("M02_TreeDiscardGo"), + variant: "danger", + onClick: () => done("discard"), + }), + createButton({ + label: L("M02_TreeSaveGo"), + variant: "filled", + onClick: () => done("save"), + }), + ); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done("cancel"); + }); + panel.append(el("p", { className: "ui-confirm__message", text: message }), actions); + overlay.append(panel); + document.body.append(overlay); + cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 + }); +} + +export interface LeaveHooks { + /** 저장 한 번 — 실패는 던지거나 `stillDirty` 로 알림(알림 글은 저장 길이 띄움) */ + save: () => Promise; + /** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */ + stillDirty: () => boolean; + /** [버리고 넘어감] 때 */ + discard?: () => void | Promise; +} + +/** + * 물음 → 고름대로 처리. 넘어가도 되면 "saved" | "discarded", 머물면 "stay". + * 저장 실패(던짐) · 저장 뒤 고침 남음 · 취소 = "stay". + */ +export async function leaveOrStay( + message: string, + hooks: LeaveHooks, +): Promise<"saved" | "discarded" | "stay"> { + const choice = await askLeave(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") { + await hooks.discard?.(); + return "discarded"; + } + try { + await hooks.save(); + } catch { + return "stay"; + } + return hooks.stillDirty() ? "stay" : "saved"; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e7da5b753..39a8a1f48 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -154,8 +154,8 @@ export const ui_locales_m2 = { "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", "Unsaved table edits — settle them before changing the tree", ], - M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], - M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], + M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], + M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"],