Files
Aislo/B06_Section/B06_Section_Engine_Structures_Wall.py
T
eomsangdonandClaude Opus 5.5 c45814f910 feat(구조물표): 323-4 형상 얹기 한 벌(TS · 파이썬 짝) — 저장분 · 초안 · 미리 뜬 횡단 같은 길(저장 전 그림 = 저장 뒤 그림) · 빈칸 경고 한 꼴(카드 칩 「종류 칸 빈칸」) · 3D 지문 = 그림 자료 통째 · C군 벽 3D 한 번 · 소단 원천 = 표 형상 berm
- 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」)
- 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침
- 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈
- 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`)
- 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row)
- 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 17:26:53 +09:00

221 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
from common_util.common_util_structure_catalog import catalog_row
logger = logging.getLogger(__name__)
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다.
_EDGE_TOLERANCE_M = 0.02
def _wall_form(type_id: str) -> str | None:
"""종류 → 횡단 기하가 아는 **형태** 이름 = 구조물 표 형상칸.form(322-1 · TS `formOf` 짝).
형태는 벽 안 무늬만 정하므로 가장 가까운 것으로 잇는다(폼에서 형태를 비웠을 때).
"""
form = ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form")
return form if isinstance(form, str) else None
# 좌측 폼 「추가 단」 상자 저장 키 머리(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
]
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"""구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록.
소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9).
"""
try:
return wall_specs_from(load_structures(str(project_root))[1])
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
return []
def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]:
"""구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
types = structure_type_map()
found: list[dict[str, Any]] = []
for structure in structures:
definition = types.get(structure.type_id)
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
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 _wall_form(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
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 (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4)
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`)가 있는 측점은 **건드리지 않는다** —
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
"""
attached = attach_wall_specs(cross_sections, load_wall_structures(project_root))
if attached:
logger.info("B06 구조물 벽 %d개 측점에 얹음", attached)
return attached
def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int:
"""구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다."""
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
return attached