Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-07 08:24:36 +09:00
18 changed files with 619 additions and 52 deletions
@@ -37,9 +37,9 @@ export function structureHashParts(section: CrossSection): Array<string | number
);
}
const box = section.box;
// 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음.
if (box) {
return [
...extras,
extras.push(
"bx",
box.inner_width_m,
box.inner_height_m,
@@ -54,7 +54,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
const value = section.design?.box_adjust?.[side];
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
}),
];
);
}
const ford = section.ford;
if (ford) {
@@ -63,8 +63,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
const value = adjust?.[role];
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
};
return [
...extras,
extras.push(
"fd",
ford.diameter_m,
ford.pipe_count,
@@ -73,7 +72,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
ford.wing_out.slab_extend_m,
wall("inlet"),
wall("outlet"),
];
);
}
const culvert = section.culvert;
if (!culvert) return extras;
@@ -13,6 +13,7 @@
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { bodyAbsolute } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import {
@@ -136,7 +137,17 @@ function detachedAdjusts(
owner: CrossSection,
section: CrossSection,
): ReturnType<typeof storedAdjusts> {
const base = storedAdjusts(owner);
// 세월교 · 박스 흙막이 — 본체 기준 x 를 절대값으로(옆 측점엔 본체가 없음 · 횡단도와 같음 · 73-4)
const ownerBody = owner.culvert?.body
? computeCulvertLayout(
owner,
owner.samples,
storedAdjusts(owner),
"revet",
owner.design?.basin_adjust,
)
: null;
const base = ownerBody ? bodyAbsolute(ownerBody) : storedAdjusts(owner);
const mine = section.design?.revet_adjust;
const counts = owner.design?.extra_wall_counts;
const pick = (role: string, fallback?: WallAdjust): WallAdjust =>
+2
View File
@@ -244,6 +244,8 @@ export interface CulvertSet {
/** 기초 축 — "기초유" | "기초버림"(실무 정본 탭 제목). **비어 있으면 터파기를 안 그린다**
* — 고르기 전에는 근거가 없다(2026-09-09 사용자 확정 ④). */
foundation?: string | null;
/** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */
body?: "box" | "ford";
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
+48 -2
View File
@@ -315,6 +315,36 @@ def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
}
# 세월교 · 박스 유입 · 유출 구조 칸(73-4) — 「흙막이」면 배관 흙막이와 같은 세트를 함께 얹음.
_GUARD_KEYS = {"box": ("wing_in", "wing_out"), "ford": ("inlet_type", "outlet_type")}
def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str, Any] | None:
"""세월교 · 박스 흙막이 — 독립 기슭막이와 같은 세트 모양(관 숨김) · 자리 = 관 · 본체 끝(73-4).
같은 측점에 `culvert` 키로 함께 얹혀 배관 흙막이 길(그림 · 연동 · 경사 · 3D · 면적)을 그대로 탐.
흙막이를 안 고른 쪽 = 구조 빈 값(안 그림) · 둘 다 아니면 None. TS 짝 `guardSet`.
"""
sides: dict[str, Any] = {}
for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]):
guarded = str(values.get(key) or "").strip() == "흙막이"
sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""}
if not any(side["structure"] for side in sides.values()):
return None
return {
"type": "pipe",
"hidden_pipe": True,
"body": body,
"side": "양쪽",
"tiers": 1,
"foundation": None,
"pipe_kind": None,
"diameter_m": diameter_m,
"min_cover_m": 0.0,
**sides,
}
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
@@ -404,7 +434,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
("ford_height_m", "월류 높이", 0.0),
],
)
return {
spec = {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
@@ -423,6 +453,10 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
guard = _guard_set(values, "ford", spec["diameter_m"])
if guard:
spec["guard"] = guard
return spec
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
@@ -474,7 +508,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
slab = slab if slab and slab > 0 else FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
spec = {
"type": "box",
"inner_width_m": inner_width or 2.0,
"inner_height_m": inner_height or 2.0,
@@ -496,6 +530,15 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
guard = _guard_set(values, "box", box_body_height(spec))
if guard:
spec["guard"] = guard
return spec
def box_body_height(box: dict[str, Any]) -> float:
"""박스 본체 전체 높이(상판 + 내공 + 저판) — 흙막이 세트의 관경 자리(73-4)."""
return round(box["top_thickness_m"] + box["inner_height_m"] + box["slab_thickness_m"], 3)
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
@@ -578,6 +621,9 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
**spec,
"chainage_m": pipe_chainage,
}
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
if spec.get("guard"):
section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage}
attached += 1
break
return attached
@@ -188,8 +188,10 @@ function areaRowOf(
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
const pipeOwner =
!!section.culvert &&
!section.culvert.body &&
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
@@ -0,0 +1,150 @@
/* =============================================================================
* B06_Section_UI_Cross_Body_Guard.ts
* 세월교 · 박스 유입 · 유출 흙막이(73-4) — 관보호공 흙막이와 **같은 벽 · 같은 자리 규칙**.
*
* 세트는 배관 흙막이와 같은 `section.culvert`(관 숨김 · `body`)로 얹혀 그림 · 연동 · 경사 · 3D ·
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 전면이 관(세월교) · 본체(박스) 끝에 옴
* (배관 벽 전면이 관 끝인 것과 같음). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
* 본체가 이 측점에 없으면(연동 옆 측점) 배관 규칙 그대로 — 소유 측점 값을 절대값으로 넘겨 받음.
* ========================================================================== */
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { outletPipeEnd } from "./B06_Section_UI_Cross_Culvert_Tiers";
import type {
CulvertDesignTrim,
CulvertLayout,
OffsetPoint,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
type Role = "inlet" | "outlet";
/** 본체 끝(관 invert · 박스 저판 밑) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
export interface BodyGuardBase {
ends: Record<Role, OffsetPoint | null>;
trim: CulvertDesignTrim;
}
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
): BodyGuardBase | null {
const body = section.culvert?.body;
const adjust = section.design;
if (body === "box" && section.box) {
const layout = computeBoxLayout(section, samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.right ?? {}) },
});
if (!layout) return null;
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const endOf = (role: Role): OffsetPoint | null => {
const side = layout.sides.find(
(s) => (s.role === "left") === ((role === "inlet") === inletOnLeft),
);
return side ? { offset: side.offset, elevation: side.bottomElevation } : null;
};
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
if (body === "ford" && section.ford) {
const layout = computeFordLayout(section, samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
});
if (!layout) return null;
const endOf = (role: Role): OffsetPoint | null =>
layout.sides.find((s) => s.role === role)?.pipeEnd ?? null;
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
return null;
}
/**
* 벽 전면을 본체 끝에 맞춘 배관 벽 4축(x · d) — `placePipeWall` 역산.
* 바닥 기준(invert) = 본체 끝 표고 · 상단 = 노견 − d/1.2 · 자리 = 0점 + (x + d).
*/
export function bodyGuardAxes(
end: OffsetPoint,
wall: {
autoOffset: number;
outward: number;
height: number;
edgeElevation: number;
lean: number;
thickness: number;
},
): { x: number; d: number } {
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - wall.height - end.elevation);
// 전면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
const probe = buildRevetWallGeometry({
anchor: { offset: 0, elevation: end.elevation },
outward: wall.outward,
height: wall.height,
material: "dry",
role: "outlet",
thickness: wall.thickness,
lean: wall.lean,
roadElevation: wall.edgeElevation,
});
const anchorOffset = end.offset - outletPipeEnd(probe).offset;
return { x: (anchorOffset - wall.autoOffset) * wall.outward - d, d };
}
/** 벽이 선 쪽은 흙막이 트림 · 안 선 쪽은 본체 트림(73-4). 벽이 하나도 없으면 null. */
export function mergeGuardTrim(
guard: CulvertDesignTrim,
body: CulvertDesignTrim,
walls: readonly WallLayout[],
): CulvertDesignTrim | null {
if (!walls.length) return null;
const max = walls.some((w) => w.outward > 0) ? guard : body;
const min = walls.some((w) => w.outward < 0) ? guard : body;
return {
minOffset: min.minOffset,
maxOffset: max.maxOffset,
minElevation: min.minElevation,
maxElevation: max.maxElevation,
minSlope: min.minSlope,
maxSlope: max.maxSlope,
};
}
/** 소유 측점 적용값(x = 본체 기준) → 옆 측점이 받을 절대값 — 옆 측점엔 본체가 없음. */
export function bodyAbsolute(layout: CulvertLayout): CulvertLayout["revetShift"] {
const shift = layout.revetShift;
const bodyX = layout.bodyX;
if (!bodyX) return shift;
const plus = (adjust: WallAdjust, dx: number): WallAdjust => ({ ...adjust, x: adjust.x + dx });
return {
...shift,
inlet: plus(shift.inlet, bodyX.inlet),
outlet: plus(shift.outlet, bodyX.outlet),
};
}
/** 흙막이가 선 쪽의 본체 성토선을 걷음 — 그 쪽 성토선은 흙막이 벽이 냄(두 줄 방지). */
export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
layout: T | null,
culvert: CulvertLayout | null,
): T | null {
if (!layout || !culvert?.culvert.body) return layout;
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
if ("box" in layout && "topSlab" in layout) {
const sides = layout.sides.map((side) =>
guarded(side.role === "left" ? 1 : -1) ? { ...side, fillLine: [] } : side,
);
return { ...layout, sides };
}
const ford = layout as FordLayout;
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, fillSegments: [] } : side,
);
return { ...ford, sides } as T;
}
@@ -68,7 +68,8 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
// 세월교 · 박스 흙막이(73-4)는 흙막이를 고른 쪽(구조 있음)만 봄
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
@@ -22,10 +22,12 @@ import {
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
import { bodyGuardAxes, bodyGuardBase, mergeGuardTrim } from "./B06_Section_UI_Cross_Body_Guard";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinAdjust,
CulvertDesignTrim,
CulvertLayout,
EndFace,
InletStructureChoice,
@@ -152,6 +154,26 @@ export function computeCulvertLayout(
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 세월교 · 박스 흙막이(73-4) — 자동 자리 = 본체 끝(x 0점 · d 기본값). 본체 없으면 배관 규칙.
const body = culvert.body ? bodyGuardBase(section, groundSamples) : null;
const bodyAxesOf = (role: "inlet" | "outlet", info: typeof inletInfo) => {
const end = body?.ends[role];
const spec = role === "inlet" ? inletWallSpec : outletWallSpec;
if (!end) return null;
return bodyGuardAxes(end, {
autoOffset: slopeZeroAnchor(info.edge, info.outward, spec.height, spec.lean),
outward: info.outward,
height: spec.height,
edgeElevation: info.edge.elevation_m,
lean: spec.lean,
thickness: REVET_THICKNESS_M,
});
};
const inletBody = bodyAxesOf("inlet", inletInfo);
const outletBody = bodyAxesOf("outlet", outletInfo);
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
const inletOptions = inletChoiceAvailability({
designAt,
@@ -214,10 +236,11 @@ export function computeCulvertLayout(
edgeElevation: inletInfo.edge.elevation_m,
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
adjust: adjInlet,
adjust: onBody(adjInlet, inletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
minX,
});
appliedAdjust.inlet.x = placed.x;
appliedAdjust.inlet.x = placed.x - (inletBody?.x ?? 0);
appliedAdjust.inlet.d = placed.d;
wallVertical.inlet = {
height: inletWallSpec.height,
@@ -321,7 +344,8 @@ export function computeCulvertLayout(
}
// 구조 · 형태를 안 고른 배관 벽 = 그리지 않음(54-39 · 조정창 형태가 있으면 그 값)
const pickedForm = (spec.role === "inlet" ? adjInlet : adjOutlet).m || spec.revet_form;
if (!culvert.hidden_pipe && (spec.structure !== "기슭막이" || !pickedForm)) return null;
if ((!culvert.hidden_pipe || culvert.body) && (spec.structure !== "기슭막이" || !pickedForm))
return null;
// 형상 = 공용 `buildRevetWallGeometry`(추가·사면안정 흙막이·옹벽과 한 벌). 높이는
// vertical(계산용)이 들고 온다 — 형태별 높이 한계 없음, 윗면은 노면에서 멈춤.
const height = vertical?.height ?? revetTargetHeight(diameter);
@@ -401,10 +425,11 @@ export function computeCulvertLayout(
edgeElevation: outletInfo.edge.elevation_m,
// 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
adjust: adjOutlet,
adjust: onBody(adjOutlet, outletBody),
defaultD: PIPE_WALL_DEFAULT_RUN_M,
minX,
});
appliedAdjust.outlet.x = placed.x;
appliedAdjust.outlet.x = placed.x - (outletBody?.x ?? 0);
appliedAdjust.outlet.d = placed.d;
wallVertical.outlet = {
height: outletWallSpec.height,
@@ -459,7 +484,7 @@ export function computeCulvertLayout(
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
@@ -608,6 +633,17 @@ export function computeCulvertLayout(
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const ownTrim: CulvertDesignTrim | null =
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null;
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -645,16 +681,8 @@ export function computeCulvertLayout(
extras: extras.appliedAdjusts,
basinExtras: basinExtras.appliedAdjusts,
},
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
bodyX:
inletBody || outletBody ? { inlet: inletBody?.x ?? 0, outlet: outletBody?.x ?? 0 } : null,
designTrim: body && ownTrim ? mergeGuardTrim(ownTrim, body.trim, walls) : ownTrim,
};
}
@@ -383,6 +383,8 @@ export function placePipeWall(input: {
adjust: WallAdjust;
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
defaultD: number;
/** x 하한 — 기본 0(안쪽 금지) · 세월교 · 박스 흙막이는 본체 끝이 0점이라 안 막음(73-4). */
minX?: number;
}): PipeWallPlacement {
const top0 = input.baseElevation0 + input.height;
const dLow = Math.max(
@@ -390,7 +392,7 @@ export function placePipeWall(input: {
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
);
const d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
const x = Math.max(input.adjust.x, 0);
const x = Math.max(input.adjust.x, input.minX ?? 0);
const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN;
return {
x,
@@ -222,6 +222,8 @@ export interface CulvertLayout {
* basinLUAllowed = 집수정 ㄴ·ㄷ형(구조물이 원지반으로 밀고 들어갈 때만).
*/
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
/** 세월교 · 박스 흙막이(73-4) — 본체 끝 자리의 배관 4축 x(적용 x 의 0점). 본체 없으면 없음. */
bodyX?: { inlet: number; outlet: number } | null;
}
/** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
@@ -4,6 +4,7 @@
* 분리(700줄 제한). 카드가 다시 그려질 때마다 한 번 불린다.
* ========================================================================== */
import { bodyAbsolute } from "./B06_Section_UI_Cross_Body_Guard";
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
@@ -426,14 +427,15 @@ export function computeCardCulvert(
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
const ownerApplied =
computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? ownerInput;
// 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4).
const ownerLayout = computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
);
const ownerApplied = ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput;
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
// 벽만 자기 값으로 선다(2026-08-30 사용자).
const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
+10 -2
View File
@@ -7,6 +7,7 @@
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
* ========================================================================== */
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
appendCrossAreaBands,
@@ -404,8 +405,15 @@ export function createCrossSectionCard(
revetLink,
);
// 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust());
const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust());
// 흙막이(73-4)가 선 쪽 본체 성토선은 걷음 — 그 쪽 성토선은 흙막이 벽이 냄.
const boxLayout = withoutGuardedFill(
computeBoxLayout(section, section.samples, bodies.boxAdjust()),
culvertLayout,
);
const fordLayout = withoutGuardedFill(
computeFordLayout(section, section.samples, bodies.fordAdjust()),
culvertLayout,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
// 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반
// 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다).
+45 -12
View File
@@ -6,7 +6,7 @@
* 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
* ========================================================================== */
import { pipeDiameterM } from "@util/common_util_culvert_sets";
import { guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
@@ -17,7 +17,7 @@ import {
revetWallSpec,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { CrossSection, CulvertSet } from "./B06_Section_Api_Fetch";
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
type StationControls = ReturnType<typeof createStationControls>;
@@ -69,16 +69,14 @@ export function applyPipeOptionsToCache(
if (!owner) return;
// 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 —
// 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정).
if (owner.ford) {
applyFordFormOptions(ctx.ford, owner.chainage_m, patch);
return;
}
if (owner.ford) applyFordFormOptions(ctx.ford, owner.chainage_m, patch);
// BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다
// (2026-08-30 사용자: 세월교와 같은 문제).
if (owner.box) {
applyBoxFormOptions(ctx.box, owner.chainage_m, patch);
return;
}
else if (owner.box) applyBoxFormOptions(ctx.box, owner.chainage_m, patch);
// 세월교 · 박스 흙막이(73-4) — 폼 칸 전부로 세트를 다시 짓고 아래 배관 흙막이 길(높이 · 형태 ·
// 추가 흙막이)을 그대로 탐. 일부만 온 되얹기는 세트를 못 지어 건너뜀.
const body = owner.ford ? "ford" : owner.box ? "box" : null;
if (body && (!whole || !syncBodyGuard(owner, body, patch))) return;
const culvert = owner.culvert;
if (!culvert) return;
const num = (key: string): number | undefined => {
@@ -111,7 +109,7 @@ export function applyPipeOptionsToCache(
own(side, "revet_length_m", num(`${prefix}_revet_length_m`));
own(side, "revet_before_m", num(`${prefix}_revet_before_m`));
own(side, "revet_after_m", num(`${prefix}_revet_after_m`));
put(side, "structure", patch[`${prefix}_type`]);
if (!body) put(side, "structure", patch[`${prefix}_type`]); // 세월교 구조 칸 = 흙막이 고르기
}
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
@@ -144,7 +142,14 @@ export function applyPipeOptionsToCache(
});
}
}
if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole);
// 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4)
const soil = body ? soilCount(patch) : null;
if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0))
// 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴)
void import("../A00_Common/design/design_structure_pipe_calc").then((calc) =>
calc.setPipeExtraCount(owner.chainage_m, soil),
);
if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole);
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
@@ -165,6 +170,34 @@ export function applyPipeOptionsToCache(
}
}
/** 세월교 · 박스 흙막이 세트를 폼 칸 전부로 다시 지음(73-4) — 흙막이가 없으면 걷음. */
function syncBodyGuard(
owner: CrossSection,
body: "box" | "ford",
patch: Record<string, number | string>,
): boolean {
const box = owner.box;
const diameter = box
? box.top_thickness_m + box.inner_height_m + box.slab_thickness_m
: (owner.ford?.diameter_m ?? 1);
const guard = guardSet(patch, body, Number(diameter.toFixed(3)));
if (!guard) delete owner.culvert;
else
owner.culvert = {
...(guard as unknown as CulvertSet),
chainage_m: ((box ?? owner.ford) as { chainage_m?: number } | undefined)?.chainage_m,
};
return !!guard;
}
/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */
function soilCount(patch: Record<string, number | string>): number {
return Math.max(
0,
...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)),
);
}
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
const soilSent = new Map<string, { h: number | null; m: string | null }>();
+57 -2
View File
@@ -199,6 +199,56 @@ function revetSet(values: Record<string, unknown>): CulvertSetSpec {
};
}
/** 세월교 · 박스 유입 · 유출 구조 칸(73-4) — 「흙막이」면 배관 흙막이와 같은 세트를 함께 얹음. */
const GUARD_KEYS: Record<"box" | "ford", [string, string]> = {
box: ["wing_in", "wing_out"],
ford: ["inlet_type", "outlet_type"],
};
/** 세월교 · 박스 흙막이 — 독립 기슭막이와 같은 세트 모양(관 숨김) · 자리 = 관 · 본체 끝(73-4).
* 흙막이를 안 고른 쪽 = 구조 빈 값(안 그림) · 둘 다 아니면 null. 파이썬 짝 `_guard_set`. */
export function guardSet(
values: Record<string, unknown>,
body: "box" | "ford",
diameterM: number,
): CulvertSetSpec | null {
const [inletKey, outletKey] = GUARD_KEYS[body];
const side = (role: string, key: string): CulvertSetSpec =>
String(values[key] ?? "").trim() === "흙막이"
? revetSide(values, role)
: { role, structure: "" };
const inlet = side("inlet", inletKey);
const outlet = side("outlet", outletKey);
if (!inlet.structure && !outlet.structure) return null;
return {
type: "pipe",
hidden_pipe: true,
body,
side: "양쪽",
tiers: 1,
foundation: null,
pipe_kind: null,
diameter_m: diameterM,
min_cover_m: 0.0,
inlet,
outlet,
};
}
/** 박스 본체 전체 높이(상판 + 내공 + 저판) — 흙막이 세트의 관경 자리(파이썬 `wall_body_height`). */
function boxBodyHeight(box: CulvertSetSpec): number {
return round(
Number(box.top_thickness_m) + Number(box.inner_height_m) + Number(box.slab_thickness_m),
3,
);
}
/** 흙막이가 있으면 세트에 `guard` 로 실음(얹을 때 `culvert` 자리로 감). */
function withGuard(spec: CulvertSetSpec, guard: CulvertSetSpec | null): CulvertSetSpec {
if (guard) spec.guard = guard;
return spec;
}
/** 관경(㎜) → 관 지름(m). 파이썬 `_culvert_set`·`_ford_set` 과 같은 반올림. */
export function pipeDiameterM(diameterMm: number | null | undefined): number {
return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3);
@@ -296,7 +346,7 @@ function fordSet(
const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
const spec: CulvertSetSpec = {
type: "ford",
pipe_kind: kind,
diameter_m: pipeDiameterM(diameterMm),
@@ -321,6 +371,7 @@ function fordSet(
...wingDefaulted(values, wingOut, "out"),
],
};
return withGuard(spec, guardSet(values, "ford", Number(spec.diameter_m)));
}
/** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */
@@ -368,7 +419,7 @@ function boxSet(
const slab = slabValue && slabValue > 0 ? slabValue : FORD_SLAB_THICKNESS_M;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
const spec: CulvertSetSpec = {
type: "box",
inner_width_m: innerWidth || 2.0,
inner_height_m: innerHeight || 2.0,
@@ -388,6 +439,7 @@ function boxSet(
...wingDefaulted(values, wingOut, "out"),
],
};
return withGuard(spec, guardSet(values, "box", boxBodyHeight(spec)));
}
/** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */
@@ -453,6 +505,9 @@ export function attachCulvertSets(
...spec,
chainage_m: pipeChainage,
};
// 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
if (spec.guard)
section.culvert = { ...(spec.guard as CulvertSetSpec), chainage_m: pipeChainage };
attached += 1;
break;
}
File diff suppressed because one or more lines are too long
+119
View File
@@ -0,0 +1,119 @@
/* 73-4 세월교 · 박스 흙막이 횡단 기하 헬퍼 — 화면 TS(`B06_Section_Structure_Layouts` · `_Cross_Body_Guard`)를
* 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 픽스처(검증_sub7 상세 일부) · 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_73_4_body_guard.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", f);
const { computeStoredLayouts, structureAreaRows, trimOfLayouts } = loadTs(
B06("B06_Section_Structure_Layouts.ts"),
);
const { outletPipeEnd } = loadTs(B06("B06_Section_UI_Cross_Culvert_Tiers.ts"));
const { bodyGuardBase } = loadTs(B06("B06_Section_UI_Cross_Body_Guard.ts"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6);
const round = (v) => Math.round(v * 1000) / 1000;
/** 벽마다 전면(관 끝 자리) ↔ 본체 끝 차이(m) · 바닥 기준 ↔ 본체 끝 표고 차이. */
function fronts(section) {
const layouts = computeStoredLayouts(section, sections);
const base = bodyGuardBase(section, section.samples);
const walls = layouts.culvert?.walls ?? [];
return {
walls: walls.map((w) => {
const end = base.ends[w.role];
const front = outletPipeEnd(w);
return {
role: w.role,
form: w.form,
dOffset: round(front.offset - end.offset),
dElevation: round(front.elevation - end.elevation),
front: round(front.offset),
};
}),
bodyX: layouts.culvert?.bodyX ?? null,
applied: layouts.culvert?.revetShift
? { inlet: layouts.culvert.revetShift.inlet.x, outlet: layouts.culvert.revetShift.outlet.x }
: null,
trimSame:
JSON.stringify(trimOfLayouts(layouts)) === JSON.stringify(layouts.culvert?.designTrim),
pipeLength: null,
};
}
const out = {};
const box = at(352.14);
const ford = at(173.09);
out.box = fronts(box);
out.ford = fronts(ford);
// 박스를 좌우로 1 m 늘리면 벽 전면도 1 m 따라 나감(연동 = 본체 기준 자리)
const moved = JSON.parse(JSON.stringify(box));
moved.design.box_adjust = { left: { lengthM: 1, riseM: 0 }, right: { lengthM: 1, riseM: 0 } };
const swapped = sections.map((s) => (s === box ? moved : s));
const movedLayouts = computeStoredLayouts(moved, swapped);
const base = bodyGuardBase(moved, moved.samples);
out.boxMoved = movedLayouts.culvert.walls.map((w) => ({
role: w.role,
dOffset: round(outletPipeEnd(w).offset - base.ends[w.role].offset),
front: round(outletPipeEnd(w).offset),
}));
// 연동 옆 측점(340 · 360) — 소유 측점 벽이 섬
out.linked = [340, 360].map((c) => {
const s = at(c);
return { chainage: c, walls: computeStoredLayouts(s, sections)?.culvert?.walls.length ?? 0 };
});
// 면적 행 — 서버 Node 진입점과 같은 함수(`structureAreaRows`)
out.areas = structureAreaRows(sections).filter((r) =>
[173.087, 352.14].some((c) => Math.abs(r.chainage_m - c) < 0.6),
);
process.stdout.write(JSON.stringify(out));
+82
View File
@@ -0,0 +1,82 @@
"""73-4 세월교 · 박스 흙막이 — 관보호공 흙막이와 같은 벽 · 같은 자리 규칙(관 · 본체 끝).
화면 TS 를 그 자리에서 돌린 값(`helper_73_4_body_guard.cjs`)과 서버 Node 번들(면적)을 대조함.
픽스처 = 검증_sub7 상세 일부(세월교 173.09 유입 · 유출 흙막이 · 박스 352.14 유입 · 유출 흙막이 전후 15 · 10 m).
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HELPER = Path(__file__).parent / "helper_73_4_body_guard.cjs"
FIXTURE = Path(__file__).parent / "fixtures" / "b06_73_4_body_guard_sections.json"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def screen() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_박스_벽_전면이_본체_끝(screen: dict) -> None:
walls = screen["box"]["walls"]
assert {w["role"] for w in walls} == {"inlet", "outlet"}
for wall in walls:
assert wall["dOffset"] == pytest.approx(0, abs=1e-3)
assert wall["dElevation"] == pytest.approx(0, abs=1e-3)
# 형태 = 폼 값 그대로(유입 찰 · 유출 콘크리트)
assert {w["role"]: w["form"] for w in walls} == {"inlet": "돌쌓기(찰)", "outlet": "콘크리트"}
# 자동 자리 = 본체 기준 0 — 되받아 저장해도 본체를 따라감
assert screen["box"]["applied"] == {"inlet": 0, "outlet": 0}
def test_박스를_늘리면_벽도_m_단위로_따라감(screen: dict) -> None:
before = {w["role"]: w["front"] for w in screen["box"]["walls"]}
for wall in screen["boxMoved"]:
assert wall["dOffset"] == pytest.approx(0, abs=1e-3)
assert abs(wall["front"] - before[wall["role"]]) == pytest.approx(1.0, abs=1e-3)
def test_세월교_벽_전면이_관_끝(screen: dict) -> None:
walls = {w["role"]: w for w in screen["ford"]["walls"]}
assert walls["outlet"]["dOffset"] == pytest.approx(0, abs=1e-3)
assert walls["outlet"]["dElevation"] == pytest.approx(0, abs=1e-3)
# 유입 2.5 m 벽은 관 끝 자리에선 노면 위로 솟음 — 배관 벽과 같이 상단이 노견에서 멈춰 내려앉음
assert walls["inlet"]["dElevation"] <= 0
def test_연동_옆_측점에도_벽이_섬(screen: dict) -> None:
assert [row["walls"] for row in screen["linked"]] == [2, 2]
def test_설계선_트림은_흙막이_쪽(screen: dict) -> None:
assert screen["box"]["trimSame"] and screen["ford"]["trimSame"]
def test_서버_면적이_화면과_같음(screen: dict) -> None:
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
from common_util.common_util_node_bundle import run_bundle_json
sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"]
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
assert output, "서버 Node 번들 실행 실패"
server = {round(r["chainage_m"], 2): r for r in output["areas"]}
for row in screen["areas"]:
mine = server[round(row["chainage_m"], 2)]
for key in ("cut_area_m2", "fill_area_m2"):
assert mine[key] == pytest.approx(row[key], abs=1e-4), (row["chainage_m"], key)
# 세월교 · 박스 흙막이 세트는 관이 아님 — 관 연장 안 실음
assert all("pipe_length_m" not in r for r in screen["areas"])
@@ -53,6 +53,10 @@ POINTS = [
"wing_in_length_m": 3.0,
"wing_in_angle_deg": 30.0,
"wing_out": "없음",
# 세월교 유입 흙막이(73-4)
"inlet_type": "흙막이",
"inlet_revet_form": "콘크리트",
"inlet_revet_height_m": 1.5,
},
},
{
@@ -64,6 +68,10 @@ POINTS = [
"wing_in": "날개벽",
"wing_out": "흙막이",
"wing_out_length_m": 2.0,
# 박스 유출 흙막이(73-4) — 배관 흙막이와 같은 칸 · `culvert` 자리에 함께 얹힘
"outlet_revet_form": "돌쌓기(찰)",
"outlet_revet_height_m": 3.5,
"outlet_revet_length_m": 10,
},
},
{
@@ -175,3 +183,19 @@ def test_culvert_sets_match_between_python_and_ts(tmp_path: Path) -> None:
assert left == right, f"{CHAINAGES[index]}m 측점 세트가 갈렸다"
# 시설이 실제로 붙었는지 — 빈 결과끼리 같아서 통과하는 것을 막는다.
assert sum(1 for item in expected if len(item) > 1) >= 6
def test_ford_box_guard_attached_as_culvert() -> None:
"""세월교 · 박스 흙막이(73-4) — 흙막이 고른 쪽만 기슭막이 · 관 숨김 · 자리 기준 = 본체."""
sections = {item["chainage_m"]: item for item in _python_sections()}
ford, box = sections[60.0]["culvert"], sections[80.0]["culvert"]
assert (ford["body"], ford["hidden_pipe"], ford["diameter_m"]) == ("ford", True, 1.0)
assert ford["inlet"]["structure"] == "기슭막이" and ford["outlet"]["structure"] == ""
assert ford["inlet"]["revet_form"] == "콘크리트"
assert box["body"] == "box" and box["inlet"]["structure"] == ""
assert box["outlet"]["revet_height_m"] == 3.5
# 본체 전체 높이 = 상판 + 내공 2.5 + 저판
b = sections[80.0]["box"]
assert box["diameter_m"] == round(b["top_thickness_m"] + 2.5 + b["slab_thickness_m"], 3)
# 흙막이 없는 시설엔 안 얹음
assert "culvert" not in sections[100.0]