228 lines
8.1 KiB
Python
228 lines
8.1 KiB
Python
"""개방선(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 typing import Iterable
|
||
|
||
|
||
Point = tuple[float, float]
|
||
Segment = tuple[Point, Point]
|
||
|
||
|
||
def _grid_key(p: Point, grid: float) -> tuple[int, int]:
|
||
return (int(math.floor(p[0] / grid)), int(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)
|
||
for p1, p2 in segments:
|
||
u = vs.get_or_add(p1)
|
||
v = vs.get_or_add(p2)
|
||
if u == v:
|
||
continue
|
||
if 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 쌍."""
|
||
segs = []
|
||
for i in range(len(pts) - 1):
|
||
segs.append((pts[i], pts[i + 1]))
|
||
return segs
|
||
|
||
|
||
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}")
|