feat(377-1 B07 전개도): 전개도 자료 · 묶음 · 그림 생성기 — (구조물 종류 · 형태)마다 한 장 · 박스 = 거리 × H(포장 B) · 형태 무늬 기본 도형(DXF 에도 남음) · 양 끝 측점 No.04+08 · 우하단 표(측점 · 거리 · 높이/폭 · 면적 · 계) · 도각 · 축척 자동 · 길이 미입력 주기

- 관보호공(배관 유입 · 유출 + 추가 흙막이) · BOX · 세월교 흙막이 · 흙막이(숨김 벽 종류 포함) · 옹벽(wall_form) · 콘크리트포장 · 쇄석포설
- 흙막이 길이 · 빈 높이 = B08 구조물집계표 규칙 재사용 · 추가 단 = 별도 박스 · 넘치면 _2 장
- 시험 test_241_development.py(그림 예 No.22+00~No.26+05 = 85 m × 3.5 = 297.5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-17 14:48:31 +09:00
co-authored by Claude Opus 5.5
parent 7329065c3b
commit 9b01cafb52
4 changed files with 1008 additions and 0 deletions
@@ -0,0 +1,328 @@
"""B07 전개도 자료 · 묶음 — 종단 길이를 가진 구조물을 (종류 · 형태)마다 한 장으로(요구 241-2 · 3).
서버 단독(CLAUDE.md 5장 ③) · 저장 안 함 · 값을 짓지 않고 정본을 모으기만:
관보호공 배관 유입 · 유출(`{쪽}_type == 기슭막이`) `{쪽}_revet_*`
+ 추가 흙막이 `soil_add_{n}_*`
BOX · 세월교 유입 · 유출 구조 == 흙막이(B08 `_guard`) + 추가 흙막이
흙막이 · 옹벽 C군 벽(표 형상 wall) start_m ~ end_m · 높이 height_m · 추가 단 = 별도 박스
옹벽 형태 = `wall_form`(`form` 은 형식) · 숨김 벽 종류 = 흙막이 묶음
포장 · 쇄석 G군 `pavement_specs_from` 구간 · 폭 B = `width_m`(빈칸 = 횡단 노폭 평균)
흙막이 길이 · 빈 높이 = B08 구조물집계표와 같은 규칙(`_revet_length` · 관경 최소 높이 · 박스 높이) ·
시점 = 기준 − 전 · 종점 = 기준 + 후(둘 다 비면 길이 반씩) · 거리 = 종점 − 시점 · 면적 = 거리 × H(B).
추가 흙막이 단 수 = 지형이 세운 유출 다단까지(`container_rows` · 설계가 있을 때만).
그림 = `B07_DesignDetail_Engine_Cad_Development_Draw.py`.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_height_m, pipe_wall_min_height_m
from B06_Section.B06_Section_Engine_Structures_Wall import tier_extras
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem
from B08_Quantity.B08_Quantity_Router_Summary import (
FORMS,
GUARD_SOIL,
SOIL,
_guard,
_num,
_revet_length,
)
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_structure_catalog import base_type, catalog_row
from common_util.common_util_structure_pavement import pavement_specs_from
from common_util.common_util_summary_item import GROUP_JOIN
from M02_MasterTemplete import M02_Table_Fill as fill
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows, soil_tiers_from_designs
DEVELOPMENT_ID = re.compile(r"^development_([a-z]+\d?)(?:_(\d+))?$")
#: 묶음 차례 · 이름 — 포장은 형태 없이 한 묶음
GROUPS = {
"pipe": "관보호공",
"box": "BOX암거 흙막이",
"ford": "세월교 흙막이",
"soil": "흙막이",
"wall": "옹벽",
"concrete": "콘크리트포장",
"gravel": "쇄석포설",
}
PAVEMENTS = {"pavement_concrete": "concrete", "gravel_surfacing": "gravel"}
_BODY_GROUP = {"pipe": "pipe", "box_culvert": "box", "ford_bridge": "ford"}
#: 한 장에 담는 박스 수 — 넘치면 `_2` 장(우하단 표가 작도 영역 안에 들게)
MAX_ROWS = 30
_EMPTY_FORM = 9 # 형태 빈칸 id 자리
def station_text(chainage_m: float, interval_m: float) -> str:
"""전개도 측점 — `No.04+08` · 소수면 `No.22+05.5`(그림 241 표기)."""
safe = interval_m if interval_m > 0 else 20.0
number = int((chainage_m + 1e-6) // safe)
rest = round(chainage_m - number * safe, 1)
if rest >= safe - 0.05:
number, rest = number + 1, 0.0
if abs(rest - round(rest)) < 0.05:
return f"No.{number:02d}+{int(round(rest)):02d}"
return f"No.{number:02d}+{rest:04.1f}"
def _row(group: str, form: Any, start: float | None, end: float | None, height: Any, key: str):
"""박스 한 줄 — 시점 · 종점 중 하나라도 없으면 길이 미입력."""
if start is not None and end is not None and end < start:
start, end = end, start
return {
"group": group,
"form": str(form or ""),
"start_m": start,
"end_m": end,
"height_m": _num(height),
"key": key,
}
def _span(at: float, options: dict[str, Any], head: str, unit: str):
"""기준 측점 + 전 · 후(둘 다 비면 길이 반씩) → (시점, 종점) · 길이도 없으면 (None, None)."""
before = _num(options.get(f"{head}before{unit}"))
after = _num(options.get(f"{head}after{unit}"))
if before is None and after is None:
length = _revet_length(options, head, unit)
if not length:
return None, None
before = after = length / 2.0
return at - (before or 0.0), at + (after or 0.0)
def _empty_heights(type_id: str, item: dict[str, Any]) -> dict[str, float | None]:
"""빈 높이 — 관 = 관경 최소(유입 + 0.7) · 박스 = 박스 높이 · 세월교 = 미입력(B08 같은 규칙)."""
options = item["options"]
if type_id == "pipe":
diameter = _num(options.get("pipe_diameter_mm"))
if not diameter:
return {"inlet": None, "outlet": None, "soil_add": None}
meters = diameter / 1000.0
wall = pipe_wall_min_height_m(meters)
return {"inlet": pipe_wall_min_height_m(meters, "inlet"), "outlet": wall, "soil_add": wall}
height = _num(options.get("body_height_m")) if type_id == "box_culvert" else None
return {"inlet": height, "outlet": height, "soil_add": height}
def _body_rows(item: dict[str, Any], raw: dict[str, Any]) -> list[dict[str, Any]]:
"""배관 · 박스 · 세월교 한 곳 — 유입 · 유출 흙막이 + 추가 흙막이 n."""
type_id = item["type_id"]
group = _BODY_GROUP[type_id]
at = float(item["chainage_m"])
empty = _empty_heights(type_id, item)
diameter = _num(item["options"].get("pipe_diameter_mm"))
if type_id == "pipe":
sides = [side for side in ("inlet", "outlet") if raw.get(f"{side}_type") == SOIL]
else:
sides = [
s for s in ("inlet", "outlet") if _guard(type_id, item["options"], s) == GUARD_SOIL
]
rows = []
for side in sides:
height = _num(raw.get(f"{side}_revet_height_m"))
if type_id == "pipe" and diameter:
height = pipe_wall_height_m(height, diameter / 1000.0)
start, end = _span(at, raw, f"{side}_revet_", "_m")
form = raw.get(f"{side}_revet_form")
key = f"{item['복사본']}:{side}"
rows.append(_row(group, form, start, end, empty[side] if height is None else height, key))
for extra in container_rows(item, {}):
link = extra["복사본"].partition(GROUP_JOIN)[2]
if not link.startswith("gsoiladd"):
continue
head = f"soil_add_{link[len('gsoiladd') :]}_"
height = _num(raw.get(f"{head}height"))
start, end = _span(at, raw, head, "")
height = empty["soil_add"] if height is None else height
rows.append(_row(group, raw.get(f"{head}form"), start, end, height, extra["복사본"]))
return rows
def _wall_rows(item: dict[str, Any]) -> list[dict[str, Any]]:
"""C군 벽 — 본 단 + 추가 단(별도 박스 · 같은 구간)."""
type_id = item["type_id"]
options = item["options"]
wall = base_type(type_id) == "retaining_wall"
group = "wall" if wall else "soil"
form = (
options.get("wall_form")
if wall
else options.get("form")
or ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form")
)
start, end = _num(item.get("start_m")), _num(item.get("end_m"))
key = item["복사본"]
rows = [_row(group, form, start, end, options.get("height_m"), key)]
for n, extra in enumerate(tier_extras(type_id, options), start=1):
tier_form = extra["form"] or form
rows.append(_row(group, tier_form, start, end, extra["height_m"], f"{key}:tier{n}"))
return rows
def _road_width(designs: dict[float, Any], start: float, end: float) -> float | None:
"""구간 안 측점 횡단 노폭(`carriageway_width_m`) 평균 — 없으면 None."""
widths = [
width
for chainage, design in (designs or {}).items()
if start - 0.02 <= float(chainage) <= end + 0.02
and isinstance(design, dict)
and (width := _num(design.get("carriageway_width_m")))
]
return round(sum(widths) / len(widths), 2) if widths else None
def _pavement_rows(item: dict[str, Any], designs: dict[float, Any]) -> list[dict[str, Any]]:
"""콘크리트 포장 · 쇄석 포설 — 구간 × 폭 B."""
specs = pavement_specs_from([item], {item["type_id"]})
group = PAVEMENTS[base_type(item["type_id"])]
if not specs:
return [_row(group, "", None, None, None, item["복사본"])]
start, end = specs[0]["start_m"], specs[0]["end_m"]
width = _num(item["options"].get("width_m")) or _road_width(designs, start, end)
if end <= start:
start = end = None
return [_row(group, "", start, end, width, item["복사본"])]
def development_rows(
project_root: str | Path, designs: dict[float, Any] | None = None
) -> list[dict[str, Any]]:
"""설계 정본 → 전개도 박스 줄 전부(묶음 · 형태 · 시점 · 종점 · 높이(폭)).
`designs` = `{측점: 횡단 설계}` — 추가 흙막이 단 수 · 포장 빈 폭에 씀(없으면 저장된 것 그대로).
"""
rows_in = [{"chainage_m": k, "design": v} for k, v in (designs or {}).items()]
items = fill.collect_items(
project_root, soil_tiers=soil_tiers_from_designs(rows_in) if designs else None
)
raw = {
(point.facility or "pipe", f"pipe@{round(float(point.chainage_m), 3)}"): point.options
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root)))
}
rows: list[dict[str, Any]] = []
for item in items:
owner, _, link = item["복사본"].partition(GROUP_JOIN)
if link:
continue # 그룹 · 컨테이너 줄은 본체 줄에서 다시 셈(단 수 거름)
type_id = item["type_id"]
if type_id in _BODY_GROUP:
rows += _body_rows(item, dict(raw.get((type_id, owner)) or {}))
elif (catalog_row(type_id) or {}).get("shape") == "wall":
rows += _wall_rows(item)
elif base_type(type_id) in PAVEMENTS:
rows += _pavement_rows(item, designs or {})
return rows
def _form_index(form: str) -> int:
return FORMS.index(form) if form in FORMS else _EMPTY_FORM
def development_sheets(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""(묶음 · 형태)마다 한 장 · 넘치면 `_2` — `{id, kind_id, title, group, rows, missing}`."""
found: dict[tuple[int, int, str], list[dict[str, Any]]] = {}
order = list(GROUPS)
for row in rows:
paved = row["group"] in ("concrete", "gravel")
form = "" if paved else row["form"]
key = (order.index(row["group"]), _form_index(form) if not paved else 0, form)
found.setdefault(key, []).append(row)
sheets = []
for (group_at, form_at, form), members in sorted(found.items()):
group = order[group_at]
paved = group in ("concrete", "gravel")
kind_id = f"development_{group}" if paved else f"development_{group}{form_at}"
title = (
f"{GROUPS[group]} 전개도"
if paved
else f"{GROUPS[group]} {form or '형태 미입력'} 전개도"
)
drawn = sorted(
(r for r in members if r["start_m"] is not None), key=lambda r: (r["start_m"], r["key"])
)
missing = len(members) - len(drawn)
chunks = [drawn[i : i + MAX_ROWS] for i in range(0, len(drawn), MAX_ROWS)] or [[]]
for index, chunk in enumerate(chunks, start=1):
sheets.append(
{
"id": kind_id if index == 1 else f"{kind_id}_{index}",
"kind_id": kind_id,
"title": title,
"group": group,
"form": form,
"rows": chunk,
"missing": missing if index == 1 else 0,
}
)
return sheets
def table_rows(rows: list[dict[str, Any]], interval_m: float) -> list[dict[str, Any]]:
"""우하단 표 줄 — 측점 범위 · 거리 · 높이(폭) · 면적(높이 빈칸이면 빈칸)."""
out = []
for row in rows:
distance = round(row["end_m"] - row["start_m"], 3)
height = row["height_m"]
out.append(
{
"station": f"{station_text(row['start_m'], interval_m)}~"
f"{station_text(row['end_m'], interval_m)}",
"distance": distance,
"height": height,
"area": None if height is None else round(distance * height, 3),
}
)
return out
def _read_json(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def development_list(
project_root: Path, manifest_drawings: dict[str, Any]
) -> list[DesignDrawingItem]:
"""좌측 「전개도」 카드 단추 — 장마다 한 줄(plan_kind = 묶음 · 형태 · 하위 묶음)."""
return [
DesignDrawingItem(
id=sheet["id"],
kind="development",
label=sheet["title"],
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
plan_kind=sheet["kind_id"],
plan_kind_name=sheet["title"],
)
for sheet in development_sheets(development_rows(project_root))
]
def read_development(
project_root: Path, longitudinal_path: Path, drawing_id: str, designs: Any
) -> tuple[str, str, dict[str, Any], bool, None]:
"""전개도 한 장 — 목록 뒤 설계로 단 수가 줄어 장이 사라졌으면 빈 장 + 안내."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import (
build_development_drawing,
)
designs = designs if isinstance(designs, dict) else {}
sheets = development_sheets(development_rows(project_root, designs))
sheet = next((s for s in sheets if s["id"] == drawing_id), None)
if sheet is None:
match = DEVELOPMENT_ID.fullmatch(drawing_id)
if not match:
raise ValueError("올바르지 않은 도면 ID입니다.")
base = next((s for s in sheets if s["kind_id"] == f"development_{match.group(1)}"), None)
sheet = {**(base or {"title": "전개도", "group": "soil", "form": ""}), "rows": []}
sheet["missing"] = 0
longitudinal = _read_json(longitudinal_path)
interval = infer_station_interval(longitudinal.get("stations") or []) or 20.0
drawing = build_development_drawing(drawing_id, sheet, interval)
return "development", sheet["title"], drawing, False, None