diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f6862a92..3abed2df 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -505,10 +505,65 @@ "placement": "interval", "style": { "color": "#2ec5dc", - "abbr": "소단" + "abbr": "소단구" }, "drawing_views": ["cross_section", "quantity"], - "options": [] + "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", @@ -910,6 +965,73 @@ } ] }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": ["profile", "cross_section", "quantity"], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, { "type_id": "erosion_check", "group": "D", diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index e4f9c143..d4b3a70d 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -39,6 +39,10 @@ _FORM_BY_TYPE = { } +# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). +BERM_TYPE_ID = "berm" + + def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" try: @@ -48,6 +52,10 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: definition = types.get(structure.type_id) if definition is None or definition.group != "C" or definition.placement != "interval": continue + # 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + # 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + if structure.type_id == BERM_TYPE_ID: + continue start, end = structure.start_m, structure.end_m if start is None or end is None: continue diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b82cef1a..38e54977 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -56,7 +56,11 @@ async function withDraftWalls( const types = await fetchStructureTypes().catch(() => []); const names = new Map( types - .filter((type) => type.group === "C" && type.placement === "interval") + // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + // 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + .filter( + (type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm", + ) .map((type) => [type.type_id, type.name] as const), ); if (!names.size) return detail; diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts deleted file mode 100644 index 4ee00280..00000000 --- a/B06_Section/B06_Section_UI_Berm_Panel.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Berm_Panel.ts - * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). - * - * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 - * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 - * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 - * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. - * - * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, - * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 - * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). - * ========================================================================== */ - -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import { - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, -} from "@util/common_util_cross_berm"; -import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; -import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; -import { buildGroup } from "./B06_Section_UI_Page_Common"; -import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; - -/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ -const DEFAULT_BEFORE_M = 5; -const DEFAULT_AFTER_M = 5; - -export interface BermPanelDeps { - /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ - getInterval: () => number; - /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ - target: () => { projectId: string; routeId: number } | null; - /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ - onChange: () => void; -} - -export interface BermPanel { - root: HTMLElement; - /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ - reload: () => void; -} - -function numberField( - label: string, - value: number, - step: string, -): ReturnType { - const field = createInputField({ label, type: "number" }); - field.input.step = step; - field.input.min = "0"; - field.input.value = String(value); - return field; -} - -export function createBermPanel(deps: BermPanelDeps): BermPanel { - const root = buildGroup("소단"); - - const anchor = stationFields("기준 측점", () => deps.getInterval()); - const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); - const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); - const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); - const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); - const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); - - const spanRow = document.createElement("div"); - spanRow.className = "b05-structure__grid"; - spanRow.append(beforeField.root, afterField.root); - - const specRow = document.createElement("div"); - specRow.className = "b05-structure__grid"; - specRow.append(widthField.root, intervalField.root); - - const slopeRow = document.createElement("div"); - slopeRow.className = "b05-structure__grid"; - slopeRow.append(slopeField.root); - - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - - let selected = -1; - - const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); - const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); - removeButton.classList.add("is-danger"); - removeButton.disabled = true; - const actions = document.createElement("div"); - actions.className = "b06-profile__field-row"; - actions.append(addButton, removeButton); - - root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); - - function spans(): BermSpan[] { - const target = deps.target(); - return target ? readBermSpans(target.projectId, target.routeId) : []; - } - - function save(next: BermSpan[]): void { - const target = deps.target(); - if (!target) return; - writeBermSpans(target.projectId, target.routeId, next); - render(); - deps.onChange(); - } - - function readNumber(field: { input: HTMLInputElement }, fallback: number): number { - const parsed = Number(field.input.value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - } - - function add(): void { - const interval = deps.getInterval(); - const chainage = anchor.read(interval, true); - if (chainage === null) { - showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); - return; - } - const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); - const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); - if (width <= 0 || gap <= 0) { - showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); - return; - } - const before = readNumber(beforeField, DEFAULT_BEFORE_M); - const after = readNumber(afterField, DEFAULT_AFTER_M); - save([ - ...spans(), - { - start_m: Math.max(chainage - before, 0), - end_m: chainage + after, - width_m: width, - interval_m: gap, - slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), - }, - ]); - selected = -1; - } - - function remove(): void { - if (selected < 0) return; - const next = spans().filter((_span, index) => index !== selected); - selected = -1; - save(next); - } - - function render(): void { - const interval = deps.getInterval(); - const current = spans(); - list.replaceChildren(); - removeButton.disabled = selected < 0 || selected >= current.length; - if (!current.length) { - const empty = document.createElement("li"); - empty.className = "b05-route__irregular-empty"; - empty.textContent = "놓은 소단이 없습니다."; - list.append(empty); - return; - } - current.forEach((span, index) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; - item.classList.toggle("is-selected", index === selected); - const where = document.createElement("strong"); - where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; - const what = document.createElement("span"); - what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; - item.append(where, what); - item.addEventListener("click", () => { - selected = index === selected ? -1 : index; - render(); - }); - list.append(item); - }); - } - - render(); - - return { - root, - reload(): void { - selected = -1; - render(); - }, - }; -} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 890019f0..43675dbd 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -24,11 +24,13 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; -import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { + bermSpansFromStructures, confirmCurrentSections, createRockBoundaryStore, + readBermSpans, saveCurrentSections, + writeBermSpans, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; @@ -150,7 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { reveal: () => layout.setOptionsOpen(true), // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. - onMarks: (structures, types) => structureMarksSink?.(structures, types), + onMarks: (structures, types) => { + structureMarksSink?.(structures, types); + syncBermSpans(structures); + }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -207,14 +212,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). - const bermPanel = createBermPanel({ - getInterval: () => stationInterval ?? 20, - target: () => - projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, - onChange: () => void reconcileStaleDesigns({ force: true }), - }); - leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); + leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -337,6 +335,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** + * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. + * + * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 + * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. + */ + function syncBermSpans(structures: ReadonlyArray): void { + if (!projectId || currentRouteId === null) return; + const next = bermSpansFromStructures(structures); + if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; + writeBermSpans(projectId, currentRouteId, next); + void reconcileStaleDesigns({ force: true }); + } + /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; @@ -726,7 +738,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); - bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..b5da86b4 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -22,6 +22,11 @@ import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structure import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -86,6 +91,52 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } +/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ +export const BERM_TYPE_ID = "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (item.type_id !== BERM_TYPE_ID) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + }); + } + return spans; +} + /** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { const found = spans.find( diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index ae79cb0b..892cb56d 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -15,8 +15,9 @@ 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. -· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 - **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). + 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. + 그래서 지식DB 에도 적지 않는다(사용자 지시). """ import math @@ -25,7 +26,7 @@ from typing import Callable, NamedTuple # 소단 기본값 — 근거는 위 모듈 설명. BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 -BERM_DEFAULT_SLOPE_DEG = 2.0 +BERM_DEFAULT_SLOPE_DEG = 0.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 5d97e807..7e88897b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -13,13 +13,14 @@ * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. - * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 + * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; -export const BERM_DEFAULT_SLOPE_DEG = 2.0; +export const BERM_DEFAULT_SLOPE_DEG = 0.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py index 092ff85e..43c617f8 100644 --- a/common_util/common_util_structure_lengths.py +++ b/common_util/common_util_structure_lengths.py @@ -16,9 +16,11 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 -# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). -PENDING_TYPE_IDS = frozenset({"ditch_berm"}) +# 아직 셈하지 않는 타입 — 지금은 없다. +# +# 소단측구(`ditch_berm`)는 계획서 3-9 로 **소단이 실제로 서면서 빈 칸이 풀렸다** +# (2026-09-07). 이제 다른 구조물과 같은 길로 연장이 나온다. +PENDING_TYPE_IDS: frozenset[str] = frozenset() def _merge(spans: list[tuple[float, float]]) -> float: @@ -48,11 +50,11 @@ def _merge(spans: list[tuple[float, float]]) -> float: def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). - 빼는 것 셋 — + 빼는 것 둘 — · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). - · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + (`PENDING_TYPE_IDS` 는 지금 비어 있다 — 소단측구가 3-9 로 풀렸다.) 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 (`StructureInstance.validate_placement_fields`).