# 🛠️ Graph Pipeline Phase 1: 기하학적 데이터 추출 (Geometric Extraction)

이 문서는 P&ID Graph Pipeline의 첫 번째 단계인 **기하학적 데이터 추출**의 상세 구현 계획을 다룹니다. 목표는 단순한 텍스트 추출을 넘어, 도면 내 모든 객체의 **물리적 위치(좌표)**와 **기하학적 속성**을 보존하여 이후 위상 모델링(Topology Modeling)이 가능하도록 하는 것입니다.

---

## 📦 1. 필수 패키지 및 환경 설정

### 1.1 Python 패키지
| 패키지 | 용도 | 비고 |
|---|---|---|
| `ezdxf` | DXF 파일 파싱 및 엔티티 추출 | 핵심 라이브러리 |
| `shapely` | 기하학적 연산 (Intersection, Distance, Bounding Box) | 좌표 기반 분석 필수 |
| `numpy` | 대량의 좌표 데이터 계산 및 행렬 연산 | 성능 최적화 |
| `pandas` | 추출된 객체 데이터의 구조화 및 CSV/JSON 저장 | 데이터 관리 |
| `pydantic` | 추출 데이터의 스키마 정의 및 유효성 검증 | 데이터 무결성 보장 |
| `pytesseract` / `pdf2image` | PDF 도면의 영역 기반 OCR 추출 | PDF 처리 시 필요 |

### 1.2 설치 명령어
```bash
pip install ezdxf shapely numpy pandas pydantic pytesseract pdf2image
```

---

## 📐 2. 상세 설계 구조

### 2.1 데이터 모델 (Schema)
모든 추출 객체는 다음과 같은 공통 속성을 갖는 `GeometricEntity` 모델을 따릅니다.

```python
from pydantic import BaseModel
from typing import List, Optional, Union, Tuple

class BoundingBox(BaseModel):
    min_x: float
    min_y: float
    max_x: float
    max_y: float
    center: Tuple[float, float]

class GeometricEntity(BaseModel):
    entity_id: str
    entity_type: str  # TEXT, LINE, CIRCLE, POLYLINE, ARC
    layer: str
    bbox: BoundingBox
    properties: dict  # 텍스트 값, 색상, 선 굵기 등
    coordinates: List[Tuple[float, float]]  # 시작점, 끝점 또는 정점 리스트
```

### 2.2 처리 파이프라인 흐름
1.  **DXF Load:** `ezdxf.readfile()`을 통해 도면 로드.
2.  **Entity Iteration:** 모든 레이어의 엔티티를 순회하며 타입별 분류.
3.  **Coordinate Extraction:** 
    *   `TEXT`: 삽입점(Insertion Point) 및 텍스트 길이를 이용한 BBox 계산.
    *   `LINE`: 시작점(Start)과 끝점(End) 추출.
    *   `POLYLINE`: 모든 정점(Vertices) 리스트 추출.
    *   `CIRCLE/ARC`: 중심점(Center)과 반지름(Radius) 추출.
4.  **Spatial Normalization:** 도면 좌표계를 분석 시스템 좌표계로 정규화.
5.  **Structured Export:** JSON 또는 DB(PostgreSQL/PostGIS)에 저장.

---

## 💻 3. 실제 구현 코딩 가이드 (Example)

### 3.1 DXF 기하학적 추출 핵심 코드
```python
import ezdxf
import re
import json
from shapely.geometry import box, LineString, Point
from typing import List, Optional, Tuple

class PidGeometricExtractor:
    def __init__(self, file_path: str):
        self.doc = ezdxf.readfile(file_path)
        self.msp = self.doc.modelspace()

    def clean_text(self, text: str) -> str:
        """DXF 특수 제어 문자 및 MTEXT 포맷팅을 최대한 제거하여 LLM 토큰 부하 감소"""
        if not text:
            return ""
        
        # 1. MTEXT 포맷팅 및 제어 문자 제거
        # \P(줄바꿈), \W(너비), \L(밑줄), \A(정렬), \C(색상), \H(높이), \S(스택), \T(탭) 및 관련 인자 제거
        text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
        
        # 2. 중괄호 { } 제거 (MTEXT에서 서식 지정 시 사용됨)
        text = re.sub(r'[\{\}]', ' ', text)
        
        # 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
        text = re.sub(r'%%[U|O|S|R]', ' ', text)
        
        # 4. 불필요한 특수 기호 및 반복되는 공백 정제
        # - 연속된 공백을 하나로 통합
        # - 텍스트 양 끝의 공백 제거
        text = re.sub(r'\s+', ' ', text).strip()
        
        return text

    def get_bbox(self, entity) -> Optional[box]:
        """엔티티의 Bounding Box를 계산하여 shapely box 객체로 반환"""
        try:
            if entity.dxftype() == 'TEXT':
                p = entity.dxf.insert
                h = entity.dxf.height
                # 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
                width = len(entity.dxf.text) * h * 0.6
                return box(p.x, p.y, p.x + width, p.y + h)
            
            elif entity.dxftype() == 'MTEXT':
                p = entity.dxf.insert
                h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
                # MTEXT는 보통 width 속성이 정의되어 있음
                w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
                return box(p.x, p.y, p.x + w, p.y + h)

            elif entity.dxftype() == 'LINE':
                start = entity.dxf.start
                end = entity.dxf.end
                return box(min(start.x, end.x), min(start.y, end.y),
                          max(start.x, end.x), max(start.y, end.y))
            
            elif entity.dxftype() == 'LWPOLYLINE':
                points = entity.get_points()
                xs = [p[0] for p in points]
                ys = [p[1] for p in points]
                return box(min(xs), min(ys), max(xs), max(ys))
                
        except Exception as e:
            print(f"Error calculating bbox for {entity.dxftype()}: {e}")
        return None

    def extract_and_save(self, output_path: str):
        """
        추출된 기하학적 데이터를 파일로 저장하여 Phase 3 Worker들이
        공유 메모리/파일 시스템을 통해 참조할 수 있도록 함 (Phase 5 병렬 아키텍처 반영)
        """
        results = []
        for entity in self.msp:
            bbox_obj = self.get_bbox(entity)
            if bbox_obj:
                # 텍스트 값 추출 및 정제
                raw_text = ""
                if entity.dxftype() == 'TEXT':
                    raw_text = entity.dxf.text
                elif entity.dxftype() == 'MTEXT':
                    raw_text = entity.text
                
                results.append({
                    "id": entity.dxf.handle,
                    "type": entity.dxftype(),
                    "layer": entity.dxf.layer,
                    "bbox": {
                        "min_x": bbox_obj.bounds[0],
                        "min_y": bbox_obj.bounds[1],
                        "max_x": bbox_obj.bounds[2],
                        "max_y": bbox_obj.bounds[3]
                    },
                    "raw_value": raw_text,
                    "clean_value": self.clean_text(raw_text) if raw_text else None,
                    "coordinates": entity.get_points() if hasattr(entity, 'get_points') else []
                })
        
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(results, f, ensure_ascii=False, indent=4)
        
        return output_path

# 사용 예시 (Phase 5 Orchestrator 관점)
extractor = PidGeometricExtractor("plant_drawing.dxf")
# 데이터를 직접 반환받지 않고 공유 저장소(파일)에 적재
geo_data_path = extractor.extract_and_save("shared_geo_data.json")
```

### 3.2 유틸리티 함수: 인접성 체크 (Proximity Utility)
추후 2단계(위상 모델링)에서 사용할 핵심 유틸리티입니다.

```python
from shapely.geometry import Point

def is_near(entity_a_bbox, entity_b_bbox, threshold=5.0):
    """두 객체의 Bounding Box 간의 최단 거리가 임계값 이내인지 확인"""
    return entity_a_bbox.distance(entity_b_bbox) <= threshold

def is_inside(point, bbox):
    """특정 점이 Bounding Box 내부에 있는지 확인"""
    return bbox.contains(Point(point))
```

---

## 🚀 4. Phase 1 완료 기준 (Definition of Done)

- [ ] DXF 파일 내 모든 `TEXT`, `LINE`, `POLYLINE`의 좌표 데이터가 누락 없이 추출되는가?
- [ ] 각 객체별로 정확한 `Bounding Box`가 계산되어 저장되는가?
- [ ] 추출된 데이터가 `GeometricEntity` 스키마에 맞게 JSON 파일로 저장되어 Worker들이 공유 참조 가능한가? (Phase 5 반영)
- [ ] (선택 사항) PDF 도면의 경우 OCR을 통해 텍스트의 좌표값이 추출되는가?

---

## 🧐 감독자 진단 결과 (2026-05-02)

### 1. 프로그램 설계 점검
- **강점**: `ezdxf`와 `shapely`를 조합하여 기하학적 데이터(BBox, 좌표)를 보존하려는 접근 방식이 매우 적절함. 특히 Phase 5의 병렬 아키텍처를 고려하여 데이터를 파일/공유 저장소에 적재하는 구조는 확장성 면에서 우수함.
- **보완 필요 사항**:
    - **MTEXT 처리**: 현재 예시 코드(`3.1`)는 `TEXT` 엔티티만 처리하고 있으나, 실제 DXF 파일 분석 결과 `MTEXT` 엔티티가 다수 존재함. `MTEXT`는 내부 포맷팅 코드(예: `\P`, `\W`)가 포함되어 있어 단순 텍스트 추출 시 정제가 필요함.
    - **BBox 계산 정밀도**: `TEXT` 엔티티의 BBox를 `p.x + 10, p.y + 5`와 같이 상수로 처리하고 있음. 실제 도면의 폰트 크기(`height`)와 정렬 방식(`align`)을 반영한 동적 계산 로직이 반드시 추가되어야 함.

### 2. 실제 도면(`No-10_Plant_PID.dxf`) 분석 기반 차이점
- **엔티티 규모**: 총 28,819개의 엔티티가 존재하여 데이터 양이 상당함. 단순 리스트 저장보다는 인덱싱 전략이 필요할 수 있음.
- **텍스트 복잡도**:
    - `MTEXT` 내에 `\P` (줄바꿈), `\L` (밑줄) 등 제어 문자가 포함된 수정 사항(Revision) 텍스트가 많음. 이를 그대로 추출하면 위상 분석 시 노이즈가 될 가능성이 높음.
    - `%%U` (Underline)와 같은 DXF 특수 제어 문자가 텍스트 값에 포함되어 있어, 이를 제거하는 전처리 과정이 필수적임.
- **데이터 특성**: `IA-10922-25A-F1A-n`와 같은 복합 파이프라인 번호(Pipe Line Number) 형식이 확인됨. 이를 일반 태그(Tag Name)와 명확히 구분하여 추출하고 관리하는 로직이 Phase 2/3에서 중요하게 작용할 것으로 보임.

### 3. 최종 권고 사항
1. **MTEXT 지원 추가**: `PidGeometricExtractor`에 `MTEXT` 처리 로직을 추가하고, 제어 문자를 제거하는 `clean_text()` 유틸리티 함수를 구현할 것.
2. **동적 BBox 구현**: `entity.dxf.height`를 활용하여 텍스트 크기에 맞는 정확한 Bounding Box를 계산하도록 수정할 것.
3. **전처리 파이프라인 강화**: 추출 단계에서 `%%U` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것.
