- 정의 「상세 내용」 = 폭만 · depth_m 표시 끔(옛 구간 값 = 기본값으로 읽음 · 새 기본 1 m)
- 짝: BermSpec 쪽마다 깊이 · side_berm ↔ sideBerm · 설계 결과 berm_depth_{side}_m 되싣기(파이썬 ↔ TS)
- 저장: patch berm_depth_{side}_m(≥ 0) · 재계산 살림 목록 짝(RD ↔ CR) · 세션 bermdepth
- 서버 [저장] 다시 셈 = 구조물 정본 소단 구간(load_berm_spans) + 측점 깊이 · 구간 지우면 소단 없음
- 고름: 소단 수평 단 = 높이만(▲ 0 에서 멈춤 · ▼ 사면 끝 앞 멈춤 · ◀▶ 막힘) · 사면 = 가로 · 노면 = 종단
- 고름을 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각
- 새 시험 test_194_1_berm_depth + helper · test_178_2 · test_54_26 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
216 lines
10 KiB
Python
216 lines
10 KiB
Python
"""구조물 정본(C군 사면안정 벽)을 횡단 측점에 얹는다.
|
|
|
|
왜 필요한가(2026-09-06 사용자 확정) — 좌측 「구조물 배치」로 넣은 옹벽·돌쌓기 같은 벽이
|
|
횡단도에도 서고 **절·성토 면적에도 반영**돼야 한다. 지금까지 이 목록은 측점만 심고
|
|
(`B05_Profile_Engine_Sections.resolve_extra_stations`) 기하가 없어 면적이 그대로였다.
|
|
|
|
방법은 **이미 도는 길을 그대로 태우는 것**이다. 독립 기슭막이가 쓰는 `section.revetment`
|
|
제원과 같은 꼴로 얹으면 횡단 기하(`B06_Section_UI_Cross_Revetment`)·설계선 트림·폐회로
|
|
면적(`B06_Section_Structure_Layouts`)·3D 가 손대지 않고 따라온다. 옛 D군 기슭막이가
|
|
관 정본으로 이관되며 사라졌던 `attach_revetments`(2026-08-28)를 C군 벽으로 되살린 것이다.
|
|
|
|
치수는 여기서 정하지 않는다 — 높이·형태만 넘기고 도형은 화면·3D 가 같은 산식으로 그린다.
|
|
|
|
**짝**: `common_util/common_util_structure_walls.ts` — 브라우저는 아직 저장하지 않은
|
|
목록으로 같은 제원을 만들어야 해서 한 벌을 더 둔다. 거울 테스트
|
|
`tmp/tests/test_b06_structure_walls_mirror.py` 가 같은 값이 나오는지 대조한다.
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다.
|
|
_EDGE_TOLERANCE_M = 0.02
|
|
|
|
# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태는 벽 안 무늬만 정하므로
|
|
# 가장 가까운 것으로 잇는다.
|
|
_FORM_BY_TYPE = {
|
|
"masonry_wet": "돌쌓기(찰)",
|
|
"masonry_dry": "돌쌓기(메)",
|
|
"boulder_masonry": "돌쌓기(메)",
|
|
"retaining_wall": "콘크리트",
|
|
"soil_guard": "통나무·목재틀",
|
|
}
|
|
|
|
|
|
# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`.
|
|
_TIER_KEY = re.compile(r"^tier_(\d+)_")
|
|
|
|
|
|
def _num(value: Any) -> float | int | None:
|
|
"""유한한 수만(TS `num` 짝 — 글 · 참거짓은 None)."""
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
return value if math.isfinite(value) else None
|
|
|
|
|
|
def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""「추가 단」 상자 → 추가 단 [{형태 · 높이}](n 차례) — 옹벽 = 콘크리트 · TS `tierExtras` 짝."""
|
|
ns = sorted({int(m.group(1)) for key in options if (m := _TIER_KEY.match(key))})
|
|
return [
|
|
{
|
|
"form": "콘크리트"
|
|
if type_id == "retaining_wall"
|
|
else (options.get(f"tier_{n}_form") or None),
|
|
"height_m": _num(options.get(f"tier_{n}_height")),
|
|
}
|
|
for n in ns
|
|
]
|
|
|
|
|
|
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
|
|
BERM_TYPE_ID = "berm"
|
|
|
|
|
|
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
|
"""구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록."""
|
|
try:
|
|
types = structure_type_map()
|
|
found: list[dict[str, Any]] = []
|
|
for structure in load_structures(str(project_root))[1]:
|
|
definition = types.get(structure.type_id)
|
|
if definition is None or definition.group != "C" or definition.placement != "interval":
|
|
continue
|
|
# 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
|
# 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
|
|
if structure.type_id == BERM_TYPE_ID:
|
|
continue
|
|
start, end = structure.start_m, structure.end_m
|
|
if start is None or end is None:
|
|
continue
|
|
options = structure.options or {}
|
|
extras = tier_extras(structure.type_id, options)
|
|
found.append(
|
|
{
|
|
"structure_id": structure.structure_id,
|
|
"type_id": structure.type_id,
|
|
"name": definition.name,
|
|
"start_m": float(min(start, end)),
|
|
"end_m": float(max(start, end)),
|
|
"anchor_m": float(structure.anchor_m()),
|
|
"form": options.get("form") or _FORM_BY_TYPE.get(structure.type_id),
|
|
"height_m": options.get("height_m"),
|
|
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
|
|
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
|
|
"side": options.get("side"),
|
|
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
|
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
|
"foundation": options.get("foundation"),
|
|
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
|
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
|
"extras": extras,
|
|
"lift_m": options.get("lift_m"),
|
|
"shift_m": options.get("shift_m"),
|
|
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
|
"face_role": options.get("face_role"),
|
|
"face_slope_ratio": options.get("face_slope_ratio"),
|
|
}
|
|
)
|
|
return found
|
|
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
|
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
|
return []
|
|
|
|
|
|
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
|
|
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
|
|
|
|
짝: TS `bermSpansFromStructures`(같은 규칙 — 폭 0 = 없음 · 깊이 빈 값 = 기본 1.0 · 0 미만 = 0).
|
|
못 읽으면 None(부르는 쪽이 저장분 `design.berm` 으로 물러섬).
|
|
"""
|
|
from common_util.common_util_cross_berm import (
|
|
BERM_DEFAULT_DEPTH_M,
|
|
BERM_DEFAULT_INTERVAL_M,
|
|
BERM_DEFAULT_SLOPE_DEG,
|
|
BERM_DEFAULT_WIDTH_M,
|
|
)
|
|
|
|
def number(options: dict[str, Any], key: str, fallback: float) -> float:
|
|
try:
|
|
value = float(options.get(key))
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
return value if math.isfinite(value) and value >= 0 else fallback
|
|
|
|
try:
|
|
spans: list[tuple[float, float, dict[str, float]]] = []
|
|
for item in load_structures(str(project_root))[1]:
|
|
if item.type_id != BERM_TYPE_ID:
|
|
continue
|
|
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
|
start = item.start_m if item.start_m is not None else anchor
|
|
end = item.end_m if item.end_m is not None else anchor
|
|
if start is None or end is None:
|
|
continue
|
|
options = item.options or {}
|
|
width = number(options, "width_m", BERM_DEFAULT_WIDTH_M)
|
|
if width <= 0:
|
|
continue
|
|
raw = options.get("depth_m")
|
|
try:
|
|
depth = BERM_DEFAULT_DEPTH_M if raw in (None, "") else max(float(raw), 0.0)
|
|
except (TypeError, ValueError):
|
|
depth = BERM_DEFAULT_DEPTH_M
|
|
if not math.isfinite(depth):
|
|
depth = BERM_DEFAULT_DEPTH_M
|
|
spec = {
|
|
"width_m": width,
|
|
"interval_m": number(options, "interval_m", BERM_DEFAULT_INTERVAL_M),
|
|
"slope_deg": number(options, "slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
|
"depth_m": depth,
|
|
}
|
|
spans.append((float(min(start, end)), float(max(start, end)), spec))
|
|
return spans
|
|
except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬
|
|
logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)")
|
|
return None
|
|
|
|
|
|
def berm_span_at(
|
|
spans: list[tuple[float, float, dict[str, float]]], chainage_m: float
|
|
) -> dict[str, float] | None:
|
|
"""그 측점을 덮는 소단 제원 — 겹치면 먼저 놓은 것(TS `bermSpecAt` 짝)."""
|
|
for start, end, spec in spans:
|
|
if start - 1e-6 <= chainage_m <= end + 1e-6:
|
|
return spec
|
|
return None
|
|
|
|
|
|
def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
|
"""구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다.
|
|
|
|
이미 관 정본이 얹은 세트(`culvert`·`revetment`)가 있는 측점은 **건드리지 않는다** —
|
|
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
|
|
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
|
|
"""
|
|
walls = load_wall_structures(project_root)
|
|
if not walls:
|
|
return 0
|
|
attached = 0
|
|
for section in cross_sections:
|
|
chainage = section.get("chainage_m")
|
|
if not isinstance(chainage, (int, float)):
|
|
continue
|
|
if section.get("culvert") or section.get("revetment"):
|
|
continue
|
|
for spec in walls:
|
|
if (
|
|
spec["start_m"] - _EDGE_TOLERANCE_M
|
|
<= float(chainage)
|
|
<= spec["end_m"] + _EDGE_TOLERANCE_M
|
|
):
|
|
section["revetment"] = spec
|
|
attached += 1
|
|
break
|
|
if attached:
|
|
logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls))
|
|
return attached
|