"""구조물 정본(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": "통나무·목재틀", } # 소단 타입 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 {} 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