- 등록부 box_culvert: 연장(기본값 없음) · 측벽 0.3 · 상·저판 0.25 · 헌치 0.2 · 버림 0.1 — 제안값 = 산림과임업기술(임도) 5장 수량산출 예제 · KDS 44 90 00 300㎜ 병기(판 0.25 는 못 미침을 나란히) - B06 BOX 세트(파이썬·TS 짝)가 두께 칸을 읽음 — 종전 「세월교 값 승계」(벽 0.2·판 0.3) 걷음 · 그림 따라감 - B08 전개식 `_UnitQuantity_Box`: 콘크리트 · 버림 · 유로폼(외벽·내벽·상판 밑·귀면) · 버림 옆 합판 · 동바리 · 비계(시종점 두 면은 개소당 한 번) · 철근은 울진 2×2 관측(D13 179·D16 157kg/m)만, 나머지 크기는 「크기별 식 없음」 - 울진 기번3 「암거수량집계표(2×2)」 m당과 맞음: 레미콘 2.84 · 버림 0.28 · 유로폼 11.131 · 합판 0.2 · 동바리 3.92 · 비계 벽체 5.2 - 치수 칸을 안 적은 BOX 는 제안값으로 서되 미확정(금액 밖) · 연장이 비면 안 섬 - 묶음 조각 12-01-01·12-38·12-04(6회)·12-03·12-20·12-19·12-25 · 동바리 대상 표시 · 세월교 「수량 근거 없음」 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
566 lines
28 KiB
Python
566 lines
28 KiB
Python
"""B06 배수관 세트(배관·기슭막이·보호공) 횡단도 정보.
|
||
|
||
B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 읽어, 같은 누가거리의
|
||
횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고
|
||
**정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다.
|
||
|
||
⚠ TS 짝: `common_util/common_util_culvert_sets.ts` (2026-09-06). 한쪽만 고치면 화면과
|
||
저장본이 갈린다 — 거울 테스트 `tmp/tests/test_b06_culvert_sets_mirror.py` 로 지킨다.
|
||
매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 골랐다(CLAUDE.md 5장 「계산 자리」 ①).
|
||
|
||
근거(지식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_FORD_PAVEMENT,
|
||
PIPE_FACILITY_PIPE,
|
||
PIPE_FACILITY_REVET,
|
||
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
|
||
|
||
# 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다.
|
||
_SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"}
|
||
|
||
# 구조물 종류별 **인접 측점 연동**(2026-08-25 사용자 확정). 이름을 넣으면 도로 방향
|
||
# 폭(`span_m`)의 절반만큼 앞뒤 측점에도 같은 스펙이 붙어 횡단도가 한 벌 더 그려진다.
|
||
# 세월교·BOX암거는 끔 — 소유 측점 한 곳에만 선다. 3D는 그 측점에서 `span_m` 전체를
|
||
# 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다).
|
||
# 물넘이포장만 켠다 — 노면을 월류 폭만큼 파는 시설이라 **범위 안 모든 횡단도**에
|
||
# 파임이 그려져야 한다(2026-08-28 사용자 확정).
|
||
# ※ 기슭막이 연동은 이 경로가 아니다 — 프론트 `culvertOwnerFor`가 따로 판정한다.
|
||
_SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
|
||
|
||
# ⚠ 교차 참조 ① 관 위 최소 토피(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
|
||
|
||
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
|
||
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
|
||
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
|
||
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
|
||
# `test_b06_pipe_wall_height_mirror`.
|
||
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
|
||
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
|
||
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
|
||
|
||
|
||
def pipe_wall_min_height_m(diameter_m: float) -> float:
|
||
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
|
||
|
||
⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖).
|
||
"""
|
||
raw = float(diameter_m) + REVET_FREEBOARD_M
|
||
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
|
||
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 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
|
||
# 물넘이포장 월류 폭 기본값(m) — 같은 폼이 물넘이에 채우는 값(2026-08-18 사용자 확정,
|
||
# 지식DB 폭 수치 근거 없음).
|
||
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
|
||
|
||
# BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이다
|
||
# (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기).
|
||
# 종전 「세월교 값 승계」(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, diameter_m: float = 1.0
|
||
) -> 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
|
||
|
||
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
|
||
height = _number(
|
||
options.get(f"{side}_revet_height_m"),
|
||
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
|
||
)
|
||
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")
|
||
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
|
||
return {
|
||
"type": "pipe",
|
||
"pipe_kind": str(kind) if kind else None,
|
||
"diameter_m": diameter_m,
|
||
"min_cover_m": MIN_PIPE_COVER_M,
|
||
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
|
||
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
|
||
"foundation": (
|
||
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
|
||
or None
|
||
),
|
||
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
|
||
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
|
||
}
|
||
|
||
|
||
def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
|
||
"""독립 기슭막이 한쪽 벽 제원 — 배관 유입/유출 벽과 같은 스펙 모양으로 만든다.
|
||
|
||
옵션 키도 배관 벽과 **같은 한 벌**(`{role}_revet_*`)을 쓴다 — B06 조정창의 구간값
|
||
(길이·전/후)이 그 키로 저장되므로, 여기서 다른 키를 읽으면 조정값이 매번 되돌아온다
|
||
(2026-08-28 화면 실측: 조정창 길이 10.5가 저장돼도 10으로 리셋). 역할 키가 없으면
|
||
B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다.
|
||
"""
|
||
height = _number(values.get(f"{role}_revet_height_m"), _number(values.get("height_m"), None))
|
||
form = values.get(f"{role}_revet_form") or values.get("form")
|
||
spec: dict[str, Any] = {
|
||
"role": role,
|
||
"structure": "기슭막이",
|
||
"revet_form": str(form) if form else None,
|
||
"revet_height_m": height,
|
||
"revet_length_m": _number(
|
||
values.get(f"{role}_revet_length_m"), _number(values.get("length_m"), None)
|
||
),
|
||
"revet_before_m": _number(
|
||
values.get(f"{role}_revet_before_m"), _number(values.get("before_m"), None)
|
||
),
|
||
"revet_after_m": _number(
|
||
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
|
||
),
|
||
"face_slope": REVET_FACE_SLOPE,
|
||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
|
||
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
|
||
"face_side": values.get("side"),
|
||
"face_role": values.get("face_role"),
|
||
"face_slope_ratio": values.get("face_slope_ratio"),
|
||
}
|
||
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 _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""독립 기슭막이 1개소 세트 — 배관 세트와 같은 모양이되 **관을 숨긴다**(hidden_pipe).
|
||
|
||
관경은 표시·수량에서 빠지므로 토큰값(0.3)만 둔다 — 벽 높이는 형태(재질) 한계와
|
||
사용자 지정 높이가 정한다. `side`(양쪽/좌/우)로 어느 벽을 세울지 B06이 가리고,
|
||
`tiers`로 다단을 요청한다.
|
||
"""
|
||
values = dict(options or {})
|
||
return {
|
||
"type": "pipe",
|
||
"hidden_pipe": True,
|
||
"side": str(values.get("side") or "양쪽"),
|
||
"tiers": int(_number(values.get("tiers"), 1.0) or 1),
|
||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. 비어 있으면 안 그린다.
|
||
"foundation": (str(values.get("foundation") or "").strip() or None),
|
||
"pipe_kind": None,
|
||
"diameter_m": 0.3,
|
||
"min_cover_m": 0.0,
|
||
"inlet": _revet_side(values, "inlet"),
|
||
"outlet": _revet_side(values, "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 _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
|
||
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
|
||
|
||
2026-09-14 브레인 판정 ② — 저장만 안 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 병.
|
||
TS 짝 `defaulted`(`common_util_culvert_sets.ts`).
|
||
"""
|
||
found = []
|
||
for key, label, used in fields:
|
||
if used is None or values.get(key) not in (None, ""):
|
||
continue
|
||
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
|
||
return found
|
||
|
||
|
||
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
|
||
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
|
||
name = "유입" if side == "in" else "유출"
|
||
prefix = f"wing_{side}"
|
||
fields: list[tuple[str, str, Any]] = [
|
||
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
|
||
]
|
||
if wing["installed"]:
|
||
fields += [
|
||
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
|
||
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
|
||
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
|
||
]
|
||
return _defaulted(values, fields)
|
||
|
||
|
||
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))
|
||
depth = _number(values.get("ford_height_m"), None)
|
||
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
|
||
pipe_count = max(int(count), 1) if count else 1
|
||
wing_in = _wing_spec(values, defaults, "in")
|
||
wing_out = _wing_spec(values, defaults, "out")
|
||
defaulted = _defaulted(
|
||
values,
|
||
[
|
||
("pipe_kind", "관종", str(kind) if kind else None),
|
||
("pipe_diameter_mm", "관경", diameter_mm),
|
||
("ford_width_m", "월류 폭", span),
|
||
("pipe_count", "배관 수량", pipe_count),
|
||
("ford_height_m", "월류 높이", 0.0),
|
||
],
|
||
)
|
||
return {
|
||
"type": "ford",
|
||
"pipe_kind": str(kind) if kind else None,
|
||
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
|
||
"pipe_count": pipe_count,
|
||
"span_m": span,
|
||
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
|
||
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
|
||
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
|
||
"overflow_depth_m": depth if depth and depth > 0 else 0.0,
|
||
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
|
||
"wall_thickness_m": FORD_WALL_THICKNESS_M,
|
||
"min_cover_m": MIN_PIPE_COVER_M,
|
||
"wing_in": wing_in,
|
||
"wing_out": wing_out,
|
||
"defaulted": defaulted
|
||
+ _wing_defaulted(values, wing_in, "in")
|
||
+ _wing_defaulted(values, wing_out, "out"),
|
||
}
|
||
|
||
|
||
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리.
|
||
|
||
구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다.
|
||
바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면
|
||
화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭.
|
||
"""
|
||
defaults = _registry_defaults("ford_pavement")
|
||
values = dict(options or {})
|
||
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
|
||
depth = _number(values.get("ford_height_m"), None)
|
||
slope = _number(values.get("ford_slope_pct"), None)
|
||
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
|
||
return {
|
||
"type": "ford_pavement",
|
||
"span_m": span,
|
||
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
|
||
"depth_m": depth if depth and depth > 0 else None,
|
||
"slope_pct": slope,
|
||
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
|
||
}
|
||
|
||
|
||
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 = _number(values.get("wall_thickness_m"), _number(defaults.get("wall_thickness_m"), None))
|
||
slab = _number(values.get("slab_thickness_m"), _number(defaults.get("slab_thickness_m"), None))
|
||
wall = wall if wall and wall > 0 else FORD_WALL_THICKNESS_M # 등록부가 비었을 때만(옛 판)
|
||
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 {
|
||
"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_in,
|
||
"wing_out": wing_out,
|
||
"defaulted": _defaulted(
|
||
values,
|
||
[
|
||
("body_width_m", "본체 폭", inner_width or 2.0),
|
||
("body_height_m", "본체 높이", inner_height or 2.0),
|
||
],
|
||
)
|
||
+ _wing_defaulted(values, wing_in, "in")
|
||
+ _wing_defaulted(values, wing_out, "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")):
|
||
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)
|
||
elif point.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||
# 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28).
|
||
spec = _ford_pavement_set(point.options)
|
||
elif point.facility == PIPE_FACILITY_REVET:
|
||
# 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관
|
||
# 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다.
|
||
spec = _revet_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
|
||
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
|
||
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
|
||
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
|
||
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
|
||
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
|
||
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
|
||
owner: dict[float, float] = {}
|
||
for pipe_chainage in sets:
|
||
nearest = None
|
||
for section in cross_sections:
|
||
value = _number(section.get("chainage_m"), None)
|
||
if value is None:
|
||
continue
|
||
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
|
||
nearest = value
|
||
if nearest is not None:
|
||
owner[pipe_chainage] = nearest
|
||
for section in cross_sections:
|
||
chainage = _number(section.get("chainage_m"), None)
|
||
if chainage is None:
|
||
continue
|
||
for pipe_chainage, spec in sets.items():
|
||
# 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`).
|
||
reach = _CHAINAGE_TOLERANCE_M
|
||
if spec.get("type") in _SPAN_LINKED_TYPES:
|
||
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
||
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
|
||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||
kind = spec.get("type")
|
||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||
section[_SECTION_KEYS.get(str(kind), "culvert")] = {
|
||
**spec,
|
||
"chainage_m": pipe_chainage,
|
||
}
|
||
attached += 1
|
||
break
|
||
return attached
|