Files
Aislo/B06_Section/B06_Section_Engine_Culvert.py
T
eomsangdonandClaude Opus 5 d4c8de738a feat(B05·B06): BOX암거 횡단도 · 3D 중공 셸·날개벽·관통 보어
BOX암거를 세월교와 같은 계류 방향 절단면으로 그리고, 3D는 속이 빈 서피스 셸로
만든다. 관통 컷·중공 튜브는 배수관 세트와 세월교에도 함께 적용한다.

B06 횡단도
- `_box_set()` — 내공 폭·높이(B05 옵션), 부재 두께 세월교 승계(측벽 0.2·상하판 0.3),
  복토 0.5m(별표2 교차 참조), 날개벽 투영 저판 연장
- 상판 윗면 = 노면 − 복토, 구체는 수평 설치(교본 3장). 계류 하상과의 고저차는 툴팁 표기
- 구체가 노면 아래 묻히므로 계획선은 자르지 않는다(세월교와 다른 점)

3D
- 로프트가 **링별 폴리곤**을 받는다 — 구조물을 위·아래 조각으로 갈라 링마다 틈을
  벌려 빈 공간을 만든다. 틈 0이면 속 찬 단면 그대로라 토폴로지가 유지된다
- BOX = 내공 구간만 틈을 벌려 측벽이 저절로 생기는 셸 + 날개벽 4매(날개 축 프레임)
- 관통 보어 = 링 y에서 틈 높이 2·√(r²−(y−y₀)²) → 배관 외측 기준 원형 구멍.
  기슭막이·집수정·세월교 측벽에 적용
- 배관은 중공 튜브(바깥·안쪽 원통 + 양 끝 고리)로 내부 유로를 보인다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:45:32 +09:00

334 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B06 배수관 세트(배관·기슭막이·보호공) 횡단도 정보.
B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 읽어, 같은 누가거리의
횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고
**정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다.
근거(지식DB):
· 관은 **지반선상 매설** — 뜨게 매설은 부적합 (교본 8장, 그림 8-3·8-9)
· 관 지름 1,000㎜ 이상(현지여건 800㎜ 이상) — 별표2 .2.사.(2)
· 유입구 = 계곡부 기슭막이 / 계통적 옆도랑은 집수정, 유출구 = 기슭막이 (교본 8장 3·4절)
· 기슭막이 계획비탈 1:0.3~1:0.5 (사방기술교본), 돌쌓기 전면 1:0.3 (교본 7-3)
· 유출구부터 원지반까지 도수로·물받이 설치 의무 — 별표2 Ⅰ.2.사.(2)
⚠ 아래 세 상수는 **임도 확정값이 아니다**. 지식DB에 임도 기준이 없어 교차 참조로
채택했고(2026-08-19 사용자 확정), 임도 기준이 확보되면 그 값으로 교체한다.
"""
from __future__ import annotations
import json
import logging
import math
from functools import lru_cache
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_PIPE,
parse_pipe_points,
)
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
)
logger = logging.getLogger(__name__)
# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다.
_CHAINAGE_TOLERANCE_M = 0.02
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
MIN_PIPE_COVER_M = 0.5
# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수.
# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택.
APRON_LENGTH_FACTOR = 2.0
# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합).
# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다.
APRON_THICKNESS_M = 0.45
# 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
# 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다.
INLET_STRUCTURE_BASIN = "집수정"
# ⚠ 교차 참조 ③ 세월교 바닥판 두께(m). 교본 물넘이 물받이 "최소 30㎝ 이상"을 끌어왔다
# (2026-08-25 사용자 확정). 측벽 두께는 근거 없이 집수정 부재 두께를 승계한다.
FORD_SLAB_THICKNESS_M = 0.3
FORD_WALL_THICKNESS_M = 0.2
# 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다.
FORD_DEFAULT_WIDTH_M = 10.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5
def pipe_points_file(project_root: Path) -> Path:
"""프로젝트 저장소 안의 관 지점 정본 경로."""
return (
project_root
/ "B04_PreProcess"
/ DRAINAGE_CACHE_DIRNAME
/ DRAINAGE_EDITS_DIRNAME
/ DRAINAGE_PIPE_POINTS_FILENAME
)
@lru_cache(maxsize=4)
def _registry_defaults(type_id: str) -> dict[str, Any]:
"""레지스트리 타입 옵션의 기본값 묶음.
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
기본값과 항상 같은 값이 그림에 들어간다.
"""
structure_type = structure_type_map().get(type_id)
if structure_type is None:
return {}
return {option.key: option.default for option in structure_type.options}
def _registry_pipe_defaults() -> dict[str, Any]:
"""레지스트리 `pipe` 타입 옵션 기본값."""
return _registry_defaults("pipe")
def _number(value: Any, fallback: float | None) -> float | None:
"""숫자 옵션 하나를 float로 정리한다. 문자열 저장분(관경 "1000")도 받는다."""
if isinstance(value, bool):
return fallback
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return fallback
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라
화면은 라벨만 쓴다(2026-08-19 사용자 확정).
"""
structure = options.get(f"{side}_type") or defaults.get(f"{side}_type") or "기슭막이"
spec: dict[str, Any] = {"role": side, "structure": str(structure)}
if spec["structure"] == INLET_STRUCTURE_BASIN:
# 집수정 종방향 길이(3D 예상형상용, 기본 2m — 2026-08-23 사용자).
# 전/후는 기슭막이와 같은 체계로 따로 지정한다(기본 각 1m — 2026-08-24 사용자).
spec["basin_length_m"] = _number(
options.get("inlet_basin_length_m"),
_number(defaults.get("inlet_basin_length_m"), 2.0),
)
spec["basin_before_m"] = _number(
options.get("inlet_basin_before_m"),
_number(defaults.get("inlet_basin_before_m"), None),
)
spec["basin_after_m"] = _number(
options.get("inlet_basin_after_m"),
_number(defaults.get("inlet_basin_after_m"), None),
)
return spec
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
)
length = _number(
options.get(f"{side}_revet_length_m"),
_number(defaults.get(f"{side}_revet_length_m"), None),
)
form = options.get(f"{side}_revet_form") or defaults.get(f"{side}_revet_form")
# 기준측점 전/후 몶(3D 예상형상 종방향 배치 — 2026-08-23 사용자).
# 저장분이 없으면 길이 절반씩.
before = _number(
options.get(f"{side}_revet_before_m"),
_number(defaults.get(f"{side}_revet_before_m"), None),
)
after = _number(
options.get(f"{side}_revet_after_m"),
_number(defaults.get(f"{side}_revet_after_m"), None),
)
spec.update(
{
"revet_form": str(form) if form else None,
"revet_height_m": height,
"revet_length_m": length,
"revet_before_m": before,
"revet_after_m": after,
"face_slope": REVET_FACE_SLOPE,
}
)
# 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공)."""
defaults = _registry_pipe_defaults()
values = dict(options or {})
diameter_mm = _number(
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"min_cover_m": MIN_PIPE_COVER_M,
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
}
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
날개벽은 횡단면에 보이지 않지만(도로 진행 방향으로 벌어진다), 벌어진 만큼
바닥판이 계류 상·하류로 길어진다 — 연장량 = 길이 × cos(각도)로,
각도는 관축(계류 방향) 기준 벌어짐각이다 (2026-08-25 사용자 확정).
"""
prefix = f"wing_{side}"
install = values.get(prefix, defaults.get(prefix))
length = _number(
values.get(f"{prefix}_length_m"), _number(defaults.get(f"{prefix}_length_m"), 0.0)
)
angle = _number(
values.get(f"{prefix}_angle_deg"), _number(defaults.get(f"{prefix}_angle_deg"), 45.0)
)
height = _number(
values.get(f"{prefix}_height_m"), _number(defaults.get(f"{prefix}_height_m"), 0.0)
)
installed = str(install or "").strip() != "없음"
extend = (length or 0.0) * math.cos(math.radians(angle or 0.0)) if installed else 0.0
return {
"installed": installed,
"height_m": height,
"length_m": length,
"angle_deg": angle,
"slab_extend_m": round(max(extend, 0.0), 3),
}
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
측벽은 ㄴ형 집수정 형상을 양쪽에 쓰고, 관 위는 성토(노체)로 채운다.
구체는 월류 폭만큼 도로 방향으로 이어지며 기준 측점 전후로 절반씩 걸친다.
"""
defaults = _registry_defaults("ford_bridge")
values = dict(options or {})
diameter_mm = _number(
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
}
def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""BOX암거 1개소의 세트 제원(구체 + 날개벽 연장).
구체 축은 계류 방향이라 횡단도에는 종단 절단면(상판·내공·저판)이 보인다.
상판 윗면은 노면에서 복토 0.5m 아래이고, 저판은 날개벽 투영만큼 계류 상·하류로
길어진다 — 연장 산식은 세월교와 같다.
"""
defaults = _registry_defaults("box_culvert")
values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
"inner_height_m": inner_height or 2.0,
"wall_thickness_m": wall,
"slab_thickness_m": slab,
"top_thickness_m": slab,
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
}
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
"""관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다.
노선 지문은 보지 않는다 — 횡단면 자체가 현 노선에서 만들어진 것이라, 지문이
어긋난 옛 관 지점은 매칭될 측점이 없어 자연히 걸러진다. 파일이 없거나 깨졌으면 빈 값.
"""
path = pipe_points_file(project_root)
if not path.is_file():
return {}
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return {}
sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")):
# 물넘이포장은 그림이 다르다 — 배관·세월교·BOX암거만 그린다.
if point.facility == PIPE_FACILITY_PIPE:
spec = _culvert_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
spec = _ford_set(point.options)
elif point.facility == PIPE_FACILITY_BOX:
spec = _box_set(point.options)
else:
continue
sets[round(float(point.chainage_m), 2)] = spec
return sets
def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다."""
sets = load_culvert_sets(project_root)
if not sets:
return 0
attached = 0
for section in cross_sections:
chainage = _number(section.get("chainage_m"), None)
if chainage is None:
continue
for pipe_chainage, spec in sets.items():
# 세월교 구체·BOX암거는 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다.
reach = _CHAINAGE_TOLERANCE_M + (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
# 세월교·BOX암거는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
section["ford" if kind == "ford" else "box" if kind == "box" else "culvert"] = spec
attached += 1
break
return attached