"""개방선(line-soup) 집합에서 폐합 폴리곤(면)을 복원하는 기하 유틸리티. 배경: CAD 도면은 구조물 외곽이나 교각을 단일 폐합 폴리라인이 아닌 여러 개의 선분 (LINE + LWPOLYLINE의 인접 점 쌍 등)으로 그리는 경우가 많다. 이 모듈은 그런 "개방선 수프(line-soup)"에서 실제로 둘러싸인 영역(face)을 planar 그래프 face-enumeration 알고리즘으로 복원한다. 알고리즘: 1. 모든 선분 끝점을 공차(tol) 내에서 단일 vertex로 묶음 (그리드 해싱) 2. 무방향 인접 리스트(vertex → 이웃들) 구성 3. "Leftmost-turn traversal": 각 방향 간선 (u→v)에서 시작해, 다음 정점에서 들어온 방향 기준 **왼쪽으로 가장 많이 꺾는** 이웃을 선택, 시작점 복귀 시까지 반복. → 평면 그래프의 모든 face 순환 열거. 4. Dedup: 회전·반사로 동일한 face는 canonical form (최소 vertex id를 시작으로 하는 정방향/역방향 중 사전순 작은 것)으로 중복 제거. 5. 부호 있는 면적으로 외곽 면(가장 큰 면, 혹은 음의 signed area)은 caller가 선택해 제거 가능. 반환: List[(polygon_pts, area_unsigned)], area 내림차순. """ from __future__ import annotations import math from collections.abc import Iterable Point = tuple[float, float] Segment = tuple[Point, Point] def _grid_key(p: Point, grid: float) -> tuple[int, int]: return (math.floor(p[0] / grid), math.floor(p[1] / grid)) class _VertexStore: """공차 내 점을 동일 vertex ID로 묶는 그리드 해시 기반 저장소.""" def __init__(self, tol: float): self.tol = tol self.tol_sq = tol * tol self.grid = max(tol * 2.0, 1e-6) self._grid: dict[tuple[int, int], list[int]] = {} self.points: list[Point] = [] def get_or_add(self, p: Point) -> int: key = _grid_key(p, self.grid) # 주변 3x3 셀 검사 for dx in (-1, 0, 1): for dy in (-1, 0, 1): bucket = self._grid.get((key[0] + dx, key[1] + dy)) if bucket: for vid in bucket: q = self.points[vid] if (q[0] - p[0]) ** 2 + (q[1] - p[1]) ** 2 <= self.tol_sq: return vid vid = len(self.points) self.points.append(p) self._grid.setdefault(key, []).append(vid) return vid def _signed_area(pts: list[Point]) -> float: a = 0.0 n = len(pts) for i in range(n): x1, y1 = pts[i] x2, y2 = pts[(i + 1) % n] a += x1 * y2 - x2 * y1 return a * 0.5 def _canonical(face_ids: list[int]) -> tuple: """Face 순환을 rotation/reversal 무관하게 고유 식별하는 canonical tuple.""" n = len(face_ids) if n == 0: return () # 각 회전·반사 중 사전순 최소 best = None for direction in (face_ids, list(reversed(face_ids))): # 최소 시작 인덱스 min_id = min(direction) start = direction.index(min_id) rotated = tuple(direction[start:] + direction[:start]) if best is None or rotated < best: best = rotated return best def reconstruct_polygons(segments: Iterable[Segment], tol: float = 5.0, min_area: float = 100.0, max_faces: int = 5000) -> list[tuple[list[Point], float]]: """개방선 집합에서 폐합 폴리곤(면) 복원. Args: segments: [(p1, p2), ...] 각 선분 tol: 끝점 동일시 공차 (선분 단위와 동일; DXF mm면 mm 단위) min_area: 이 값 이하의 면은 버림 (노이즈 억제) max_faces: 안전 상한 (무한 루프 방지) Returns: [(polygon_pts, unsigned_area), ...] 면적 내림차순. polygon_pts는 닫히지 않은 형태 (끝점이 시작점과 중복되지 않음). """ # 1) Vertex 단일화 vs = _VertexStore(tol) edges: set[tuple[int, int]] = set() # 무방향 간선 (u v: u, v = v, u edges.add((u, v)) if not edges: return [] # 2) 인접 리스트 (각 vertex → 이웃 정렬 목록) adj: dict[int, list[int]] = {} for u, v in edges: adj.setdefault(u, []).append(v) adj.setdefault(v, []).append(u) # 3) Leftmost-turn face enumeration def _ang(a_id: int, b_id: int) -> float: ax, ay = vs.points[a_id] bx, by = vs.points[b_id] return math.atan2(by - ay, bx - ax) visited: set[tuple[int, int]] = set() # 방향 간선 canonical_faces: set[tuple] = set() result_faces: list[list[int]] = [] # 각 방향 간선 (u→v)에 대해 한 번씩 for u in adj: for v in adj[u]: if (u, v) in visited: continue # 이 방향에서 face 한 개 추적 face = [u] prev, curr = u, v steps = 0 # 평면 그래프에서 face 길이 <= 전체 간선 수 × 2 안쪽 step_cap = len(edges) * 2 + 10 aborted = False while steps < step_cap: visited.add((prev, curr)) face.append(curr) if curr == u: break # 다음 vertex: curr에서 prev로의 방향 기준 leftmost turn # (2π 안쪽 가장 작은 양수 turn_angle 이웃 선택) back_angle = _ang(curr, prev) candidates = [n for n in adj[curr] if n != prev] if not candidates: aborted = True break best_n = None best_t = None for n in candidates: out = _ang(curr, n) t = (out - back_angle) % (2.0 * math.pi) # 완전 역방향(동일 간선 되돌아가기, t≈0 또는 2π)은 마지막 수단 if t < 1e-9: t = 2.0 * math.pi if best_t is None or t < best_t: best_t = t best_n = n prev, curr = curr, best_n steps += 1 if len(result_faces) + len(canonical_faces) > max_faces: aborted = True break if aborted or len(face) < 4: continue # 마지막 원소는 시작점 중복 → 제거 face_ids = face[:-1] canon = _canonical(face_ids) if canon in canonical_faces: continue canonical_faces.add(canon) result_faces.append(face_ids) # 4) 면적 계산 + 필터·정렬 polys: list[tuple[list[Point], float]] = [] for face_ids in result_faces: pts = [vs.points[v] for v in face_ids] area = abs(_signed_area(pts)) if area < min_area: continue polys.append((pts, area)) polys.sort(key=lambda t: -t[1]) return polys def segments_from_lines(lines: Iterable[tuple[Point, Point]]) -> list[Segment]: """편의: LINE 엔티티들을 segment 리스트로 변환.""" return [(p1, p2) for p1, p2 in lines] def segments_from_polyline(pts: list[Point]) -> list[Segment]: """LWPOLYLINE 점 목록 → 인접 segment 쌍.""" from itertools import pairwise return list(pairwise(pts)) if __name__ == "__main__": # 간단 스모크: 두 개의 겹친 사각형 segs = [ # 외곽 10x10 ((0, 0), (10, 0)), ((10, 0), (10, 10)), ((10, 10), (0, 10)), ((0, 10), (0, 0)), # 내부 사각형 2~5 x 2~5 ((2, 2), (5, 2)), ((5, 2), (5, 5)), ((5, 5), (2, 5)), ((2, 5), (2, 2)), ] polys = reconstruct_polygons(segs, tol=0.01, min_area=0.1) print(f"found {len(polys)} polygons") for i, (pts, area) in enumerate(polys): print(f" #{i}: area={area:.2f}, pts={pts}")