feat(design): 횡단배수 박스암거(표 02 고친 판) 컨테이너 폼 — 본체 · 유입 구조 · 유출 구조 (PLAN 52-2)

- 박스암거 명세(design_structure_box) — 본체 규격 2.0×2.0 · 3.0×3.0 · 사용자 입력(가로 × 세로 숫자 칸) · 유입 · 유출 구조 없음 · 날개벽 · 흙막이 · 컨테이너마다 구조물 조합 잇기(gboxbody · gboxin · gboxout)
- 저장 키 = 옛 키 그대로(body_width_m · body_height_m · wing_in · wing_out) · 규격 칸은 저장 안 함(폭 · 높이로 되찾음) · 옛 「있음」 = 날개벽 · 옛 날개벽 치수는 화면에서 빼고 지난 정의 값
- 명세 등록(STRUCTURE_SPECS) · 배관 시험 헬퍼가 같은 폴더 명세를 옮겨 읽음
- B06 날개벽 설치 판정 한 곳(wingInstalled) — 흙막이 = 날개벽 안 세움 · 저장 글을 있음/없음으로 안 덮음 · 날개벽 조정 행은 명세 유입 · 유출 칸으로
- 정의 판 7 — box_culvert 이름 표 · 그룹 셋 · wing_in/out 선택지 · 판 6 이력 보관
- 시험 test_52_structure_spec_box · 레지스트리 · 새 구조물 기본값 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-05 23:38:48 +09:00
co-authored by Claude Opus 5.5
parent 1e3e9a5f99
commit 1a53e045c5
15 changed files with 3770 additions and 129 deletions
+117
View File
@@ -0,0 +1,117 @@
/* =============================================================================
* design_structure_box.ts
* 횡단배수 › 박스암거 명세 — 사용자 구조물 표 02 고친 판(`docs/raw/구조물정의_표/02_횡단배수_박스암거.md`) 그대로.
*
* 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리):
* · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`)
* · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽)
* — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝)
* 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값).
* import 없음(꼴만) — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec";
export const BOX_CUSTOM = "사용자 입력";
/** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */
const BOX_PRESETS: Record<string, readonly [number, number]> = {
"2.0×2.0": [2, 2],
"3.0×3.0": [3, 3],
};
/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름. */
const structureItem = (key: string): SpecItem => ({
id: "structure",
label: "구조",
kind: "select",
key,
choices: [
{ value: "없음", label: "없음" },
{ value: "날개벽", label: "날개벽" },
{ value: "흙막이", label: "흙막이" },
],
});
/** 사용자 입력일 때만 서는 숫자 칸. */
const customSide = (id: string, label: string, key: string): SpecItem => ({
id,
label,
kind: "number",
step: 0.1,
by: { item: "size", map: { [BOX_CUSTOM]: { key } } },
});
export const BOX_SPEC: StructureSpec = {
typeId: "box_culvert",
containers: [
{
id: "body",
title: "본체",
required: true,
link: "gboxbody",
items: [
{
id: "size",
label: "본체 규격",
kind: "select",
choices: [
...Object.keys(BOX_PRESETS).map((value) => ({
value,
label: value.replace("×", " × "),
})),
{ value: BOX_CUSTOM, label: BOX_CUSTOM },
],
},
customSide("width", "가로 (m)", "body_width_m"),
customSide("height", "세로 (m)", "body_height_m"),
],
},
{
id: "inlet",
title: "유입 구조",
required: true,
link: "gboxin",
items: [structureItem("wing_in")],
},
{
id: "outlet",
title: "유출 구조",
required: true,
link: "gboxout",
items: [structureItem("wing_out")],
},
],
// 표 02 에 없는 옛 날개벽 치수 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: ["in", "out"].flatMap((side) =>
["height_m", "length_m", "angle_deg"].map((field) => `wing_${side}_${field}`),
),
};
/** 옛 저장 → 폼에 줄 옵션 — 설치 「있음」 = 날개벽(같은 뜻 · 저장하면 새 값으로 굳음). */
export function boxOptionsIn(
options: Record<string, string | number>,
): Record<string, string | number> {
const out = { ...options };
for (const key of ["wing_in", "wing_out"]) if (out[key] === "있음") out[key] = "날개벽";
return out;
}
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */
export function bodyOf(options: Record<string, string | number>): Instance {
const width = Number(options.body_width_m);
const height = Number(options.body_height_m);
if (!(width > 0 && height > 0)) return { size: "" };
const preset = Object.entries(BOX_PRESETS).find(
([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05,
);
return preset
? { size: preset[0] }
: { size: BOX_CUSTOM, width: String(width), height: String(height) };
}
/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음). */
export function bodyOptions(body: Instance | undefined): Record<string, number> {
const preset = BOX_PRESETS[body?.size ?? ""];
return preset ? { body_width_m: preset[0], body_height_m: preset[1] } : {};
}
+5 -1
View File
@@ -12,6 +12,7 @@
* ========================================================================== */
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
import { BOX_SPEC } from "./design_structure_box";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정";
@@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = {
};
/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = { pipe: PIPE_SPEC };
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
pipe: PIPE_SPEC,
box_culvert: BOX_SPEC,
};
+55 -95
View File
@@ -18,11 +18,9 @@
* 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로
* 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
*
* BOX암거(2026-08-17 사용자 확정): 본체는 실무 관측 규격 2.0×2.0·3.0×3.0을 프리셋
* 셀렉트로 받고(기본 2.0×2.0), 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접
* 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다.
* 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · `design_structure_box`) — 본체 규격 ·
* 유입 · 유출 구조. 저장 키는 body_width_m·body_height_m 숫자 · wing_in·wing_out 그대로.
* 날개벽 조각(createWingFields · 설치 · 짧은쪽 높이 · 길이 · 각도)은 세월교 몫.
*
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
@@ -35,8 +33,6 @@ import type {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
BOX_SIZE_CUSTOM,
BOX_SIZE_PRESETS,
createRevetSideGroup,
createWingFields,
grid,
@@ -44,15 +40,19 @@ import {
INLET_REVET_KEYS,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
bodyOf,
bodyOptions,
boxOptionsIn,
} from "../A00_Common/design/design_structure_box";
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -265,30 +265,28 @@ export function createFacilityOptionsForm(
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
let linkSource: SpecLinks | null = null;
let chainage: number | null = null;
const pipeForm = createSpecForm(
{
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
const specLinks: SpecLinks = {
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
};
const pipeForm = createSpecForm(specLinks, {
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
{
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
);
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
const inletSlot = pipeForm.slot("inlet");
const outletSlot = pipeForm.slot("outlet");
@@ -351,40 +349,14 @@ export function createFacilityOptionsForm(
return merged;
};
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("— 안 정함 —", [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1", "0");
const boxHeight = numberInput("0.1", "0");
const boxCustomRow = grid(
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
);
const boxWrap = document.createElement("div");
boxWrap.append(
grid(labeled("본체 규격 (m)", boxSize, ["body_width_m", "body_height_m"])),
boxCustomRow,
);
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
// 날개벽 칸(세월교 몫) — 세월교 · BOX 가 같은 조각을 쓰던 자리 · BOX 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
const hiddenOptions = createHiddenOptions(root);
/** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의
* 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */
function syncBoxSize(): void {
const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value);
if (preset) {
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
const ford = createFordFields(() => emit());
@@ -409,7 +381,7 @@ export function createFacilityOptionsForm(
revetTopRow,
revetInlet.root,
revetOutlet.root,
boxWrap,
boxForm.root,
wingInFields.root,
wingOutFields.root,
);
@@ -423,9 +395,13 @@ export function createFacilityOptionsForm(
const isBox = current === "box_culvert";
pipeRow.hidden = current !== "ford_bridge";
pipeForm.root.hidden = !isPipe;
boxWrap.hidden = !isBox;
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
const hasWing = isBox || current === "ford_bridge";
boxForm.root.hidden = !isBox;
const hasWing = current === "ford_bridge";
// B06 날개벽 조정 행 자리 — BOX 면 명세 유입 · 유출 구조 칸 끝, 아니면 날개벽 칸 끝
(isBox ? boxForm.slot("inlet") : wingInFields.root.lastElementChild!).append(wingInFields.slot);
(isBox ? boxForm.slot("outlet") : wingOutFields.root.lastElementChild!).append(
wingOutFields.slot,
);
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
@@ -446,7 +422,6 @@ export function createFacilityOptionsForm(
// 선택이 풀리면 스스로 사라진다.
extraGroup.root.hidden = extraSlot.childElementCount === 0;
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
wingOutFields.syncVisibility();
@@ -464,9 +439,6 @@ export function createFacilityOptionsForm(
[
pipeMaterial,
pipeDiameter,
boxSize,
boxWidth,
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
...ford.inputs,
@@ -500,8 +472,12 @@ export function createFacilityOptionsForm(
bindLinks(links) {
linkSource = links;
pipeForm.paintLinks();
boxForm.paintLinks();
},
paintLinks: () => {
pipeForm.paintLinks();
boxForm.paintLinks();
},
paintLinks: () => pipeForm.paintLinks(),
setChainage(chainageM) {
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
@@ -525,28 +501,15 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
if (facility === "pipe") {
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const names = definition?.spec_names ?? {};
if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups);
if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
for (const [id, value] of Object.entries(bodyOf(options)))
boxForm.setValue("body", id, value);
}
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
Number.isFinite(savedWidth) &&
savedWidth > 0 &&
Number.isFinite(savedHeight) &&
savedHeight > 0;
const preset = hasSaved
? BOX_SIZE_PRESETS.find(
([, width, height]) =>
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
)
: undefined;
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
@@ -570,11 +533,8 @@ export function createFacilityOptionsForm(
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
putNumber(options, "body_width_m", boxWidth.value);
putNumber(options, "body_height_m", boxHeight.value);
wingInFields.read(options);
wingOutFields.read(options);
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
} else if (current === "ford_pavement") {
ford.read(current, options);
} else if (current === "ford_bridge") {
@@ -461,18 +461,6 @@ export function createBasinSplitFields(length: HTMLInputElement): {
};
}
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
export const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
["2.0×2.0", 2, 2],
["3.0×3.0", 3, 3],
];
export const BOX_SIZE_CUSTOM = "사용자 지정";
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
interface WingKeys {
install: string;
@@ -40,7 +40,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
// BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값)
box_culvert: ["body_width_m", "body_height_m", "wing_in", "wing_out"],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
@@ -8,7 +8,12 @@
* 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다.
* ========================================================================== */
import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets";
import {
boxSpanM,
pipeDiameterM,
wingInstalled,
wingSlabExtendM,
} from "@util/common_util_culvert_sets";
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford";
@@ -76,8 +81,8 @@ function wingPatchFrom(patch: Record<string, number | string>, prefix: string):
return Number.isFinite(value) ? value : undefined;
};
const wing: WingPatch = {};
// 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약).
if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음";
// 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정.
if (patch[prefix] !== undefined) wing.installed = wingInstalled(patch[prefix]);
const heightM = num(`${prefix}_height_m`);
if (heightM !== undefined) wing.height_m = heightM;
const lengthM = num(`${prefix}_length_m`);
@@ -8,6 +8,7 @@
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { wingInstalled } from "@util/common_util_culvert_sets";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
type Merged = Record<string, string | number>;
@@ -50,7 +51,9 @@ function keepWings(
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
// 저장 글이 그린 설치와 같은 뜻이면 그대로 둠(BOX 유입·유출 구조 날개벽 · 흙막이 글을 안 덮음)
if (stored?.[prefix] === undefined || wingInstalled(stored[prefix]) !== wing.installed)
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
+7 -2
View File
@@ -231,6 +231,12 @@ export function wingSlabExtendM(
* 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
* 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다.
*/
/** 날개벽을 세우는 설치 값인가 — BOX암거 유입·유출 구조(표 02) 없음 · 날개벽 · 흙막이 · 옛 있음 = 날개벽.
* 날개벽만 세움 · 빈 값은 세움(기본). 파이썬 `_wing_spec` 과 짝. */
export function wingInstalled(value: unknown): boolean {
return !["없음", "흙막이"].includes(String(value ?? "").trim());
}
function wingSpec(
values: Record<string, unknown>,
defaults: Record<string, unknown>,
@@ -241,8 +247,7 @@ function wingSpec(
const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0));
const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0));
const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0));
// BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움.
const installed = !["없음", "흙막이"].includes(String(install ?? "").trim());
const installed = wingInstalled(install);
return {
installed,
height_m: height,
+55 -10
View File
@@ -1,5 +1,5 @@
{
"판": 6,
"판": 7,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -382,13 +382,14 @@
},
{
"key": "wing_in",
"label": "날개벽(유입)",
"label": "유입 구조",
"input": "select",
"choices": [
"있음",
"없음"
"없음",
"날개벽",
"흙막이"
],
"default": "있음",
"default": "날개벽",
"required": false
},
{
@@ -417,13 +418,14 @@
},
{
"key": "wing_out",
"label": "날개벽(유출)",
"label": "유출 구조",
"input": "select",
"choices": [
"있음",
"없음"
"없음",
"날개벽",
"흙막이"
],
"default": "있음",
"default": "날개벽",
"required": false
},
{
@@ -3295,7 +3297,23 @@
"계곡통과시설",
"BOX_본체규격",
"날개벽"
]
],
"이름": {
"body.size": "본체 규격",
"body.size=2.0×2.0": "2.0 × 2.0",
"body.size=3.0×3.0": "3.0 × 3.0",
"body.size=사용자 입력": "사용자 입력",
"body.width": "가로 (m)",
"body.height": "세로 (m)",
"inlet.structure": "구조",
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
}
},
"ford_pavement": {
"동작": [
@@ -3352,6 +3370,33 @@
"옵션": [],
"형상": {}
}
],
"box_culvert": [
{
"id": "gboxbody",
"이름": "본체",
"옵션": [
"body_width_m",
"body_height_m"
],
"형상": {}
},
{
"id": "gboxin",
"이름": "유입 구조",
"옵션": [
"wing_in"
],
"형상": {}
},
{
"id": "gboxout",
"이름": "유출 구조",
"옵션": [
"wing_out"
],
"형상": {}
}
]
},
"배치": {},
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,9 @@ const load = (file, deps = {}) => {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m));
// 같은 폴더 명세(./design_structure_*)는 같은 식으로 옮겨 읽음
const need = (m) => deps[m] ?? (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m));
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
};
const s = load("design_structure_spec.ts");
@@ -0,0 +1,61 @@
/* 박스암거 명세 단위검증 헬퍼(PLAN 52-2) — `design_structure_spec.ts` · `design_structure_box.ts` 를
* 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌.
* 인자 `names` 면 박스암거 이름 표(정의 `종류.box_culvert.이름`)만 냄.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (file) => {
const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", file), "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
};
const s = load("design_structure_spec.ts");
const b = load("design_structure_box.ts");
const SPEC = b.BOX_SPEC;
if (process.argv[2] === "names") {
process.stdout.write(JSON.stringify(s.nameTable(SPEC)));
process.exit(0);
}
/** 화면이 하는 차례 그대로 — 옛 값 정리 → 명세 읽기 → 본체 칸 되찾기 → 저장 옵션. */
const roundTrip = (options, edit) => {
const state = s.readState(SPEC, b.boxOptionsIn(options));
Object.assign(state.body[0], b.bodyOf(options));
edit?.(state);
return { state, out: { ...s.writeOptions(SPEC, state), ...b.bodyOptions(state.body[0]) } };
};
const body = SPEC.containers.find((c) => c.id === "body");
const width = body.items.find((i) => i.id === "width");
const old = { body_width_m: 3, body_height_m: 2.5, wing_in: "있음", wing_in_length_m: 2 };
const out = {
containers: SPEC.containers.map((c) => [c.id, c.title, c.required]),
links: SPEC.containers.map((c) => s.linkOf(c, 1)),
sizes: body.items[0].choices.map((c) => c.value),
structures: SPEC.containers[1].items[0].choices.map((c) => c.value),
customHidden: s.branchOf(width, { size: "2.0×2.0" }),
customShown: s.branchOf(width, { size: "사용자 입력" }),
old: roundTrip(old),
preset: roundTrip({ body_width_m: 2, body_height_m: 2, wing_out: "없음" }),
toPreset: roundTrip(old, (st) => {
st.body[0].size = "3.0×3.0";
st.outlet[0].structure = "흙막이";
}),
toCustom: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => {
st.body[0].size = "사용자 입력";
st.body[0].width = "2.4";
st.body[0].height = "1.8";
}),
blank: roundTrip({}),
keys: s.specKeys(SPEC),
retired: SPEC.retired,
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""횡단배수 › 박스암거(표 02 고친 판) 명세 — PLAN 52-2.
순수 규칙은 Node 헬퍼(`helper_52_structure_spec_box.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-2_박스암거_컨테이너.js`.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
def _node(*args):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_52_structure_spec_box.cjs"), *args],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def out():
return _node()
def test_컨테이너_셋_모두_필수(out):
assert out["containers"] == [
["body", "본체", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
]
assert out["links"] == ["gboxbody", "gboxin", "gboxout"]
def test_선택지_표_02_그대로(out):
assert out["sizes"] == ["2.0×2.0", "3.0×3.0", "사용자 입력"]
assert out["structures"] == ["없음", "날개벽", "흙막이"]
def test_사용자_입력일_때만_가로_세로_칸(out):
assert out["customHidden"] is None
assert out["customShown"] == {"key": "body_width_m"}
def test_옛_값_읽기_규격_밖은_사용자_입력_있음은_날개벽(out):
st = out["old"]["state"]
assert st["body"][0] == {"size": "사용자 입력", "width": "3", "height": "2.5"}
assert st["inlet"][0]["structure"] == "날개벽"
back = out["old"]["out"]
assert back == {"body_width_m": 3, "body_height_m": 2.5, "wing_in": "날개벽"}
# 옛 날개벽 치수는 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "wing_in_length_m" not in back
assert "wing_in_length_m" in out["retired"]
def test_규격은_폭_높이_수로_저장_규격_칸은_저장_안_함(out):
assert out["preset"]["state"]["body"][0]["size"] == "2.0×2.0"
assert out["preset"]["out"] == {"body_width_m": 2, "body_height_m": 2, "wing_out": "없음"}
to = out["toPreset"]["out"]
assert to["body_width_m"] == 3 and to["body_height_m"] == 3
assert to["wing_out"] == "흙막이"
assert out["toCustom"]["out"] == {"body_width_m": 2.4, "body_height_m": 1.8}
assert "size" not in json.dumps(out["keys"])
def test_빈_값은_빈_채로(out):
assert out["blank"]["out"] == {}
assert out["blank"]["state"]["body"][0]["size"] == ""
def test_정의_파일에_이름_표_그룹_선택지가_올라_있음():
names = _node("names")
with open(
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert set(names) == set(doc["종류"]["box_culvert"]["이름"])
assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"]
box = next(t for t in doc["types"] if t["type_id"] == "box_culvert")
wing = {o["key"]: o for o in box["options"]}
for key in ("wing_in", "wing_out"):
assert wing[key]["choices"] == ["없음", "날개벽", "흙막이"]
@@ -38,7 +38,7 @@ def test_새_관만_기본값_빈_키에만():
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
basins._apply_new_defaults([box])
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음"
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "날개벽"
def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
@@ -544,8 +544,9 @@ def test_box_culvert_wing_wall_options_defined():
for side in ("in", "out"):
toggle = options[f"wing_{side}"]
assert toggle.phase == "b05" and not toggle.required
assert toggle.choices == ["있음", "없음"]
assert toggle.default == "있음", f"wing_{side} 기본 설치"
# 표 02 고친 판(PLAN 52-2) — 유입 · 유출 구조 = 없음 · 날개벽 · 흙막이
assert toggle.choices == ["없음", "날개벽", "흙막이"]
assert toggle.default == "날개벽", f"wing_{side} 기본 구조"
for dim, value in expected.items():
option = options[f"wing_{side}_{dim}"]
assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}"