feat(B06): 구조물 목록의 C군 벽을 횡단 면적에 반영

좌측 「구조물 배치」로 넣은 옹벽·돌쌓기 같은 벽이 지금까지 측점만 심고 기하가 없어
절·성토 면적이 그대로였음(2026-09-06 사용자 확정: 반영해야 함).

- 독립 기슭막이가 쓰던 `section.revetment` 제원 자리에 그대로 얹음 — 기하·설계선
  트림·폐회로 면적·3D 가 손대지 않고 따라옴.
- 서버 `B06_Section_Engine_Structures_Wall.py`(저장분) ↔ 브라우저
  `common_util_structure_walls.ts`(미저장 초안) 짝 + 거울 테스트.
- 설치 측 칸이 없는 C군은 성토가 나는 쪽으로 자동 배치, 양측 절토면 세우지 않음.
- 구조물 목록이 바뀌면 B06 이 초안을 얹어 다시 그림.

검증: 40m 돌쌓기(찰) 추가 시 그 측점 성토 5.81 → 5.02㎡. 테스트 390 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 18:59:55 +09:00
co-authored by Claude Opus 5
parent cdb9799e64
commit 1d4568b92f
6 changed files with 302 additions and 16 deletions
@@ -0,0 +1,107 @@
"""구조물 정본(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
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
# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태가 벽 높이 한계·두께를 정하므로
# (`B06_Section_UI_Cross_Revetment.revetHeightLimit`) 가장 가까운 것으로 잇는다.
_FORM_BY_TYPE = {
"masonry_wet": "돌쌓기(찰)",
"masonry_dry": "돌쌓기(메)",
"boulder_masonry": "돌쌓기(메)",
"retaining_wall": "콘크리트",
"soil_guard": "통나무·목재틀",
}
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
start, end = structure.start_m, structure.end_m
if start is None or end is None:
continue
options = structure.options or {}
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"),
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
}
)
return found
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
return []
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