"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5). ⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` · `stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`. B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸 (빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 (모달이 처음 열 때 옮기는 차례와 같음). 그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1). 옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node 진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 · 실패는 비치명(조합 줄만). 좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. """ import json 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 load_definition, load_structure_types from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points from common_util.common_util_node_bundle import run_bundle_json logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] _OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js" _OPTIONS_NPM = "build:b07-note" # TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 PIPE_MATCH_M = 0.51 _LAYER_ID = "기본" _FONT = 2.0 _LINE = 3.0 _COLOR = "#ff7f7f" # 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리 _PIPE_BODY_LINK = "grv6lny" _LAYERS = ("system", "company", "personal") _GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") #: (관 누가거리, 세트, 옵션) — 원형 마크 재료 Pipe = tuple[float, dict[str, Any], dict[str, Any]] #: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items, type_id, options, option_lines}` #: — TS `NoteThing`(option_lines = `optionLines`) Thing = dict[str, Any] def _js_round(value: float) -> int: """JS `Math.round` — .5 는 위로.""" return math.floor(value + 0.5) def _number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def _whole_metres(value: Any) -> str: """관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값).""" return f"{_js_round(value)}m" if _number(value) else "" def station_no_label(chainage_m: float, interval_m: float) -> str: """측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝.""" safe = interval_m if interval_m > 0 else 1.0 number = math.floor((chainage_m + 1e-6) / safe) rest = chainage_m - number * safe if rest >= safe - 0.05: number += 1 rest = 0.0 return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}" def _summary_item(value: Any) -> dict[str, Any] | None: """TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None.""" if not isinstance(value, dict): return None owner, key = value.get("owner"), value.get("key") ok = ( value.get("layer") in _LAYERS and (owner is None or isinstance(owner, str)) and isinstance(key, str) and key != "" and isinstance(value.get("name"), str) ) return value if ok else None def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]: """TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로).""" if not isinstance(value, dict): return [] return [ (gid, item) for gid, raw in value.items() if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw)) ] def things_at(things: list[Thing], chainage_m: float) -> list[Thing]: """그 측점에 놓인 구조물 — TS `thingsAt`.""" return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M] def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]: """구조물 조합 줄 — TS `structureNoteLines` 짝. 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 · 이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1). """ lines: list[str] = [] for thing in things_at(things, chainage_m): own = _summary_item(thing.get("summary_item")) slots = ([("", own)] if own else []) + _group_items(thing.get("group_items")) names = [ item["name"] or item["key"] for gid, item in slots if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK)) ] if slots: lines += list(dict.fromkeys(names)) elif not thing["pipe"]: lines.append(f"{thing['name']} 조합 미지정") lines += [str(line) for line in thing.get("option_lines") or []] return lines def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]: return { "id": f"culvert-note:{index}", "type": kind, "lineColor": _COLOR, "lineWidth": 1, "layerId": _LAYER_ID, "shapeData": shape_data, } def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]: return _shape( index, "Text", { "label": label, "basePoint": {"x": x, "y": y}, "options": { "textDirection": {"x": 1, "y": 0}, "textAlign": align, "textColor": _COLOR, "fontSize": _FONT, "fontFamily": "sans-serif", }, }, ) def _pipe_mark( chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None ) -> list[dict[str, Any]]: """원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음).""" if not isinstance(culvert, dict) or culvert.get("type") != "pipe": return [] if culvert.get("body") or culvert.get("hidden_pipe"): return [] owner = culvert.get("chainage_m") if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M: return [] cx, cy, radius = 6.5, -9.0, 6.5 length = design.get("pipe_length_m") if isinstance(design, dict) else None inside = [ "배수관", f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", f"L={_whole_metres(length)}", ] # 원 + 원 안 세 줄 — 아래 수직 지시선 없음(요구 169) return [_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius})] + [ _text(1 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside) ] def note_entities( chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None, station: str, lines: list[str], ) -> list[dict[str, Any]]: """미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터). `culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`. """ mark = _pipe_mark(chainage_m, culvert, design) if not mark and not lines: return [] x = 15 if mark else 0 start = len(mark) return mark + [ _text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines]) ] def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]: """글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례).""" things: list[Thing] = [ { "name": names.get(point.facility, point.facility), "pipe": point.facility == PIPE_FACILITY_PIPE, "at": float(point.chainage_m), "summary_item": point.summary_item, "group_items": point.group_items, "type_id": point.facility, "options": dict(point.options or {}), } for point in points ] for item in structures: at = item.chainage_m if item.chainage_m is not None else item.start_m if not _number(at): continue things.append( { "name": names.get(item.type_id, item.type_id), "pipe": False, "at": float(at), "summary_item": item.summary_item, "group_items": item.group_items, "type_id": item.type_id, "options": dict(item.options or {}), } ) return things def with_option_lines(things: list[Thing]) -> list[Thing]: """구조물마다 좌측 패널 옵션 줄(166-1) — 화면 TS 를 Node 로 한 번에 · 실패면 그대로.""" if not things: return things try: # 지연 import — M02 정의 저장소가 B07 엔진 import 차례에 끼지 않게 from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible payload = { "things": [ {"type_id": t["type_id"], "options": t.get("options") or {}} for t in things ], "types": with_visible().model_dump(mode="json")["types"], "definition": load_definition(), } output = run_bundle_json(_OPTIONS_BUNDLE, _OPTIONS_NPM, payload) except Exception: logger.warning("B07 CAD 칸 옵션 줄 계산 실패 — 조합 줄만 넣음", exc_info=True) return things rows = output.get("lines") if isinstance(output, dict) else None if not isinstance(rows, list) or len(rows) != len(things): return things return [ { **thing, "option_lines": [line for line in row if isinstance(line, str)] if isinstance(row, list) else [], } for thing, row in zip(things, rows) ] def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: """정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치).""" try: document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8")) points = parse_pipe_points(document.get("points")) except (OSError, json.JSONDecodeError): points = [] pipes: list[Pipe] = [] for point in points: options = dict(point.options or {}) if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""): continue spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)} pipes.append((float(point.chainage_m), spec, options)) _revision, structures = load_structures(str(project_root)) names = {kind.type_id: kind.name for kind in load_structure_types()} return pipes, with_option_lines(_things(points, structures, names)) def with_structure_note( design: dict[str, Any] | None, chainage_m: float, sources: tuple[list[Pipe], list[Thing]], station: str, ) -> dict[str, Any] | None: """CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로. 설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈. """ if not isinstance(design, dict) or design.get("structure_note_cad") is not None: return design if str(design.get("structure_note") or "").strip(): return design pipes, things = sources near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M] spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None lines = structure_note_lines(things, chainage_m) entities = note_entities(chainage_m, spec, design, station, lines) if not entities: return design return {**design, "structure_note_cad": {"entities": entities}}