feat(b05,b06): 물넘이포장 도수로/돌붙임 = 다른 구조물과 같은 칸 + 맨 아래 「길이 (m)」 — 그림 전용 (286-3)

- 명세 — 도수로/돌붙임 칸 = CHUTE_ITEMS(구조 · 형태 · 두께 · 면적) + 길이(ford_chute_{n}_length_m)
- 서버 _chute · TS chute 짝 — 길이 있으면 제원에 length_m
- 횡단 · 3D 띠 — 유출 성토사면을 노견에서 사면 따라 그 길이까지(비거나 사면보다 길면 원지반까지)
- 길이 = 그림 전용 · 집계 열 · B08 돌붙임 수량(면적)에 안 실림
- 시험 test_286_1 · 물넘이 명세 시험 기대값 새 칸으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-12 20:15:17 +09:00
co-authored by Claude Opus 5.5
parent 7e1b5bd27e
commit 6d25379469
9 changed files with 155 additions and 30 deletions
@@ -2,13 +2,16 @@
* design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
*
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
* 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
* 비면 원지반까지.
* ========================================================================== */
import { CHUTE_ITEMS } from "./design_structure_guard";
import type { StructureSpec } from "./design_structure_spec";
export const FORD_PAVEMENT_SPEC: StructureSpec = {
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
default: "도수로",
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
],
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 90-2) + 맨 아래 길이(그림 전용 · 286-3)
items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }],
},
],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
@@ -11,7 +11,7 @@
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import { fordChuteLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
interface FillSegment {
@@ -51,6 +51,6 @@ export function fordPavementChute(
);
if (owner !== section) return null;
}
const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute);
const bands = chuteBands([{ points: fordChuteLine(section.design, pave.chute) }], pave.chute);
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
}
+2
View File
@@ -228,6 +228,8 @@ export interface CulvertSideSpec {
export interface ChuteSpec {
structure: string;
thickness_m: number;
/** 사면 따라 그릴 길이(m · 물넘이 포장 286-3) — 그림 전용 · 없으면 원지반까지. */
length_m?: number;
}
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
+7 -2
View File
@@ -251,7 +251,8 @@ def _side_spec(
def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None:
"""도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 ·
두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림.
TS 짝 `chute`."""
길이(`{head}_{n}_length_m` · 286-3) = 사면 따라 그릴 길이 — 그림 전용(수량 · 내역에 안 씀) ·
비면 원지반까지. TS 짝 `chute`."""
pattern = re.compile(rf"^{head}_(\d+)_structure$")
picked = sorted(
(int(hit.group(1)), str(value).strip())
@@ -263,7 +264,11 @@ def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] |
n, structure = picked[0]
cm = _number(values.get(f"{head}_{n}_thickness"), None)
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
found = {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
length = _number(values.get(f"{head}_{n}_length_m"), None)
if length and length > 0:
found["length_m"] = round(length, 3)
return found
def _with_chute(
@@ -34,8 +34,8 @@ export interface FordPavementSpec {
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */
chute?: { structure: string; thickness_m: number };
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠 · 길이 = 사면 따라(286-3 · 비면 원지반까지). */
chute?: { structure: string; thickness_m: number; length_m?: number };
}
interface Edge {
@@ -190,7 +190,8 @@ export function appendFordPavementOverlay(
"b06-chart__ford-deck",
),
);
if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y);
if (spec.chute)
appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute) }], spec.chute, x, y);
return true;
}
@@ -218,6 +219,29 @@ export function outletFillLine(design: CrossDesign): OffsetPoint[] {
return line;
}
/**
* 물넘이 도수로 · 돌붙임 선(286-3) — 유출 성토사면 선을 노견에서 사면 따라 `length_m` 까지 자름 ·
* 비거나 사면보다 길면 원지반까지. 횡단 띠 · 3D 띠가 같이 씀(그림 전용).
*/
export function fordChuteLine(design: CrossDesign, chute?: { length_m?: number }): OffsetPoint[] {
const line = outletFillLine(design);
let left = chute?.length_m ?? 0;
if (!(left > 0)) return line;
const out = line.slice(0, 1);
for (let i = 1; i < line.length && left > 1e-9; i += 1) {
const [a, b] = [line[i - 1], line[i]];
const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation);
if (step < 1e-9) continue;
const t = Math.min(left / step, 1);
out.push({
offset: a.offset + (b.offset - a.offset) * t,
elevation: a.elevation + (b.elevation - a.elevation) * t,
});
left -= step;
}
return out;
}
/**
* 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가
* 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다).
+9 -3
View File
@@ -199,11 +199,12 @@ function sideSpec(
}
/** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께
* (cm → m · 빈 값 45 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */
* (cm → m · 빈 값 45 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림 · 길이
* (`{head}_{n}_length_m` · 286-3) = 사면 따라 그릴 길이(그림 전용 · 비면 원지반까지). 파이썬 짝 `_chute`. */
export function chute(
values: Record<string, unknown>,
head = "chute_add",
): { structure: string; thickness_m: number } | null {
): { structure: string; thickness_m: number; length_m?: number } | null {
const pattern = new RegExp(`^${head}_(\\d+)_structure$`);
const picked = Object.entries(values)
.flatMap(([key, value]) => {
@@ -215,7 +216,12 @@ export function chute(
if (!picked.length) return null;
const [n, structure] = picked[0];
const cm = num(values[`${head}_${n}_thickness`], null);
return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) };
const found = {
structure,
thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3),
};
const length = num(values[`${head}_${n}_length_m`], null);
return length && length > 0 ? { ...found, length_m: round(length, 3) } : found;
}
/** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */
@@ -42,7 +42,35 @@ const deck = (design) => ({
right: ford.fordDeckElevationAt(design, SPEC, -3),
});
// 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01)
const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts"));
const slopeDesign = {
design_elevation_m: 100,
cross_slope_pct: 2,
section_mode: "left_cut",
road_edges: {
left: { offset_m: 2.5, elevation_m: 100 },
right: { offset_m: -2.5, elevation_m: 100 },
},
design_line: [
{ offset_m: 10, elevation_m: 95 },
{ offset_m: 2.5, elevation_m: 100 },
{ offset_m: -2.5, elevation_m: 100 },
{ offset_m: -6, elevation_m: 104 },
],
};
const chuteOf = (extra) =>
sets.chute(
{ ford_chute_1_structure: "돌붙임", ford_chute_1_thickness: 30, ...extra },
"ford_chute",
);
const out = {
chuteNoLength: chuteOf({}),
chuteLength: chuteOf({ ford_chute_1_length_m: 3 }),
lineNoLength: ford.fordChuteLine(slopeDesign, chuteOf({})),
lineLength: ford.fordChuteLine(slopeDesign, chuteOf({ ford_chute_1_length_m: 3 })),
lineTooLong: ford.fordChuteLine(slopeDesign, chuteOf({ ford_chute_1_length_m: 50 })),
fordMarks: station.pipeStationMarks({ chainage_m: 230, facility: "ford_pavement" }),
planted: station.plantedChainages(input.pipes, input.structures, input.types),
deckLeftHigh: deck(designOf(105, 95, "right_cut")),
@@ -5,6 +5,8 @@
`resolve_extra_stations` 같은 값 · 라벨 「물넘이포장」 · B05 · B06 거름 걷음.
- 286-2 물넘이 자리 = 측구 없음(서버 정본 · 화면 짝 · 거울 = `test_b06_cross_design_mirror`) ·
노면 = 경사각도로 표고 낮은 쪽(설계선 양 끝 표고 · 같으면 단면유형).
- 286-3 도수로/돌붙임 = 다른 구조물과 같은 칸 + 맨 아래 「길이 (m)」(`ford_chute_{n}_length_m`) ·
비면 원지반까지 · 있으면 사면 따라 그 길이 · 서버 · TS 같은 값 · 집계 · B08 수량에 안 실림.
진짜 TS 는 `helper_286_1_ford_pavement.cjs` 가 Node 로 돌림. 화면은 사용자 확인.
"""
@@ -27,7 +29,10 @@ from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations # no
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Router_Design import ford_pavement_ranges, paved_at # noqa: E402
from B06_Section.B06_Section_Engine_Culvert import _chute # noqa: E402
from B08_Quantity.B08_Quantity_Router_Summary import _chutes # noqa: E402
from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows # noqa: E402
PIPES = [
{"chainage_m": 95.0, "source": "user"},
@@ -135,3 +140,64 @@ def test_저장_뒤_서버가_물넘이_측구를_맞춤():
assert 'bool(design.get("ford_paved")) == ford_paved' in router
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
assert "fordPaved: Boolean(section.ford_pavement)," in refresh
CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30}
def test_도수로_길이_서버_TS_같은_값(ts):
assert (
_chute(CHUTE, "ford_chute")
== ts["chuteNoLength"]
== {
"structure": "돌붙임",
"thickness_m": 0.3,
}
)
long = {**CHUTE, "ford_chute_1_length_m": 3}
assert _chute(long, "ford_chute") == ts["chuteLength"]
assert ts["chuteLength"]["length_m"] == 3
def test_도수로_길이_없음_원지반까지_있음_그_길이(ts):
# 유출 = 좌(우가 표고 높음) · 노견(2.5, 100) → 원지반(10, 95)
assert ts["lineNoLength"] == [
{"offset": 2.5, "elevation": 100},
{"offset": 10, "elevation": 95},
]
start, end = ts["lineLength"]
assert start == {"offset": 2.5, "elevation": 100}
assert (end["offset"] - 2.5) ** 2 + (100 - end["elevation"]) ** 2 == pytest.approx(9.0)
assert end["offset"] == pytest.approx(2.5 + 3 * 7.5 / 9.0139, abs=1e-3)
assert ts["lineTooLong"] == ts["lineNoLength"] # 사면보다 길면 원지반에서 멈춤
def test_컨테이너_같은_칸_맨_아래_길이():
spec = _read("A00_Common", "design", "design_structure_ford_pavement.ts")
assert (
'items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }],'
in spec
)
def test_길이는_집계_수량에_안_실림():
options = {**CHUTE, "ford_chute_1_bond": "찰붙임", "ford_chute_1_area": 12}
item = {"type_id": "ford_pavement", "options": options, "복사본": "pipe@50.0"}
plain = container_rows(item, {"gfordpavechute1": "SS1"})
long = container_rows(
{**item, "options": {**options, "ford_chute_1_length_m": 7}}, {"gfordpavechute1": "SS1"}
)
# 집계 열이 읽는 칸(length_m · 쪽별 revet_length_m)은 그대로 — 길이는 안 읽히는 이름으로만
for key in ("length_m", "inlet_revet_length_m", "outlet_revet_length_m", "area_m"):
assert plain[0]["options"].get(key) == long[0]["options"].get(key)
class Sheet:
def __init__(self) -> None:
self.rows: list[tuple] = []
def add(self, at, key, sort, group, label, value, combos) -> None:
self.rows.append((key, label, value))
sheet = Sheet()
_chutes(sheet, 50.0, {**options, "ford_chute_1_length_m": 7}, {"gfordpavechute1": "SS1"}, {})
assert sheet.rows == [("chute|찰붙임", "㎡", 12.0)] # 돌붙임 수량 = 면적 · 길이 안 탐
@@ -25,7 +25,9 @@ const load = (file) => {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
// 같은 폴더 명세 import(`CHUTE_ITEMS` · 286-3)만 따라 옮김
const near = (m) => (m.startsWith("./") ? load(path.join(path.dirname(file), `${m}.ts`)) : {});
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
const s = load(path.join(ROOT, "A00_Common/design/design_structure_spec.ts"));
@@ -75,8 +77,9 @@ def test_저장_값은_도_와_인스턴스_키(out):
"width": "6",
"height": "0.3",
} # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5)
# 두께(cm · 90-2) — 빈 칸은 빈 값(새 상자만 기본 30)
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임", "thickness": ""}]}
# 다른 구조물과 같은 칸(구조 · 형태 · 두께 · 면적) + 길이(286-3) — 빈 칸은 빈 값(새 상자만 기본값)
chute = {"structure": "돌붙임", "bond": "", "thickness": "", "area": "", "length_m": ""}
assert out["state"] == {"slope": [slope], "chute": [chute]}
assert out["written"] == {
"ford_slope_deg": 15,
"ford_width_m": 6,