"""구조물 부속 컴포넌트(공도교/개폐장치/사다리/덮개/에이프런 등) 존재 여부를 DXF 레이어·엔티티·텍스트 신호로 검출하는 공통 헬퍼. 배경: 각 구조물 파서(gate, valve_chamber, intake_tower, retaining_wall, …)에서 "도면에 실제로 있는 것만 빌드"하기 위해 `has_X` 플래그를 채택. 검출 로직이 파서마다 복붙되면 유지보수 비용과 drift 위험이 크므로, 본 모듈로 통합. 사용 예: from optional_detector import ComponentSpec, detect_components specs = [ ComponentSpec( name="service_bridge", layer_tokens=("bridge", "공도교", "공도", "관리도로", "service road"), text_keywords=("공도교", "service bridge", "관리교", "관리도로"), default=False, ), ComponentSpec( name="hoist_housings", layer_tokens=("hoist", "권양", "winch", "gantry"), text_keywords=("권양기", "hoist", "winch"), default=True, # 래디얼 게이트엔 통상 동반 preserve_default_on_no_signal=True, # 신호 부재 시 default 유지 ), ] report = detect_components(msp, specs) params.has_service_bridge = report["service_bridge"].present params.has_hoist_housings = report["hoist_housings"].present """ from __future__ import annotations from dataclasses import dataclass, field from collections.abc import Iterable # geometry로 간주할 엔티티 타입 (TEXT/MTEXT/DIMENSION 등 주석은 제외) GEOM_TYPES: frozenset[str] = frozenset({ "LWPOLYLINE", "POLYLINE", "LINE", "CIRCLE", "ARC", "ELLIPSE", "SPLINE", "3DFACE", "SOLID", "HATCH", }) @dataclass class ComponentSpec: """한 부속 컴포넌트의 검출 규칙.""" name: str # 결과 dict의 key layer_tokens: tuple[str, ...] # 레이어명 부분일치 (대소문자 무시) text_keywords: tuple[str, ...] = () # TEXT/MTEXT 부분일치 (대소문자 무시) default: bool = False # 판정 불가 시 기본값 # True면 "신호 부재"를 False로 낮추지 않고 default 유지 (예: 개폐장치처럼 # 일반적으로 있으나 별도 레이어로 분리되지 않는 부속) preserve_default_on_no_signal: bool = False # geometry 대신 text 신호만으로도 True 허용 여부 (false positive 방지 기본 False) allow_text_only: bool = False @dataclass class ComponentReport: """검출 결과.""" present: bool geom_count: int text_count: int matched_layers: list[str] = field(default_factory=list) def describe(self) -> str: return (f"present={self.present} (geom={self.geom_count}, " f"txt={self.text_count}, layers={sorted(self.matched_layers)})") def count_layer_geom(msp, tokens: Iterable[str]) -> tuple[int, set[str]]: """주어진 레이어 토큰에 부분일치하는 geometry 엔티티 수 + 매칭 레이어 집합. Args: msp: ezdxf modelspace tokens: 레이어명에 포함되어야 할 문자열들 (대소문자 무시, 부분일치) Returns: (count, matched_layer_names) """ lowered = tuple(t.lower() for t in tokens) count = 0 matched: set[str] = set() for e in msp: try: layer = e.dxf.layer except Exception: continue lname = layer.lower() if not any(t in lname for t in lowered): continue try: etype = e.dxftype() except Exception: continue if etype in GEOM_TYPES: count += 1 matched.add(layer) return count, matched def count_text_hits(msp, keywords: Iterable[str]) -> int: """TEXT/MTEXT에서 키워드 부분일치 엔티티 수 (대소문자 무시).""" lowered = tuple(k.lower() for k in keywords) if not lowered: return 0 n = 0 for e in msp: try: etype = e.dxftype() except Exception: continue if etype not in ("TEXT", "MTEXT"): continue try: txt = e.dxf.text if etype == "TEXT" else (e.text or "") except Exception: continue if not txt: continue tl = txt.lower() if any(k in tl for k in lowered): n += 1 return n def detect_component(msp, spec: ComponentSpec) -> ComponentReport: """단일 컴포넌트 검출. 판정 규칙: 1) layer geometry count > 0 → present = True (확정) 2) allow_text_only=True 이고 text count > 0 → present = True 3) preserve_default_on_no_signal=True 이고 신호 둘 다 0 → default 유지 4) 그 외 → present = False (default가 True였어도 낮춤) """ geom_count, matched = count_layer_geom(msp, spec.layer_tokens) text_count = count_text_hits(msp, spec.text_keywords) if geom_count > 0 or (spec.allow_text_only and text_count > 0): present = True elif spec.preserve_default_on_no_signal and geom_count == 0 and text_count == 0: present = spec.default else: # 신호 부재 → default를 False로 낮춤 # 단, default=True 이지만 preserve_default_on_no_signal=False 인 경우, # text 약신호라도 있으면 True 유지 여지 present = bool(spec.default and text_count > 0) return ComponentReport( present=present, geom_count=geom_count, text_count=text_count, matched_layers=sorted(matched), ) def detect_components(msp, specs: Iterable[ComponentSpec] ) -> dict[str, ComponentReport]: """다중 컴포넌트 일괄 검출. 결과는 name → ComponentReport.""" return {spec.name: detect_component(msp, spec) for spec in specs} def summary_line(reports: dict[str, ComponentReport]) -> str: """검출 결과를 raw_text_annotations에 넣기 좋은 한 줄 요약.""" parts = [] for name, rep in reports.items(): parts.append(f"{name}={rep.present}(g={rep.geom_count},t={rep.text_count})") return "[detect] " + ", ".join(parts)