Files
s-canvas/geo_referencing.py

1095 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Geo-Referencing: 구조물 평면도 ↔ TIN 평면도 4점 매칭 및 변환 계산.
사용자가 구조물 상세 도면(평면도)과 TIN 생성용 도면 각각에서
4개 꼭짓점을 시계방향으로 선택하면 Similarity 변환
(회전 + 평행이동 + 균등 스케일)을 Umeyama 알고리즘으로 산출하여
PlacementTransform 으로 반환한다.
주요 컴포넌트:
- filter_terrain_meshes / EXCLUDE_COLORS : 미리보기에서 물·지면 메쉬 제외
- PlacementTransform : 계산 결과를 저장하는 데이터클래스
- compute_similarity_transform : Umeyama 최소제곱 구현
- extract_plan_shapes : view_detector로 평면도만 추출
- DxfPickerCanvas : matplotlib 임베드 + 4점 픽
- GeoReferencingDialog : 2분할 픽 다이얼로그
"""
from __future__ import annotations
import math
import re
import unicodedata
from dataclasses import dataclass, field, asdict
from pathlib import Path
from tkinter import messagebox
import numpy as np
import customtkinter as ctk
import matplotlib
matplotlib.use("TkAgg")
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.patches import Polygon as MplPolygon, Circle as MplCircle
from matplotlib.backends.backend_tkagg import (
FigureCanvasTkAgg,
NavigationToolbar2Tk,
)
try:
matplotlib.rcParams["font.family"] = "Malgun Gothic"
matplotlib.rcParams["axes.unicode_minus"] = False
except Exception:
pass
# ---------------------------------------------------------------------------
# 물·지면 필터링
# ---------------------------------------------------------------------------
# 각 3D 빌더가 물/지면/에이프런/뒤채움에 사용하는 HEX 색상.
# 이 메쉬들은 TIN 자체가 지형을 표현하므로 구조물 미리보기·최종 배치에서 제외한다.
EXCLUDE_COLORS = {
"#3A7AA8", # 수면 (spillway, intake_tower)
"#7F6F5F", # 지반 (intake_tower, valve_chamber)
"#8B7D6B", # 지반 (retaining_wall)
"#9A968C", # apron (spillway 하류 에이프런)
"#8B7355", # backfill (retaining_wall 뒤채움)
}
def _normalize_hex(c) -> str:
if not isinstance(c, str):
return ""
s = c.strip().upper()
if not s.startswith("#"):
s = "#" + s
return s
def filter_terrain_meshes(meshes):
"""(mesh, color, opacity) 리스트에서 물·지면류를 제외하여 반환.
원본 리스트는 변경하지 않는다. color 비교는 대소문자 무시.
"""
excluded_upper = {c.upper() for c in EXCLUDE_COLORS}
out = []
for tpl in meshes:
if len(tpl) < 2:
continue
color = _normalize_hex(tpl[1])
if color in excluded_upper:
continue
out.append(tpl)
return out
# ---------------------------------------------------------------------------
# PyVista 창 제목 ASCII 변환 (VTK Windows 인코딩 깨짐 회피)
# ---------------------------------------------------------------------------
# 간단 한글 → ASCII 매핑 (구조물 타입에만 우선 적용)
_KO_TO_ASCII_HINTS = {
"여수로": "spillway",
"수문": "gate",
"취수탑": "intake_tower",
"밸브실": "valve_chamber",
"옹벽": "retaining_wall",
"교량": "bridge",
"터널": "tunnel",
"배수문": "drain_gate",
"취수구": "intake",
"물받이": "apron",
"평면도": "plan",
"미리보기": "Preview",
}
def to_ascii_title(name: str, fallback: str = "Preview") -> str:
"""한글/특수문자가 포함된 문자열을 VTK 창 제목용 ASCII로 변환.
한글 표현이 있으면 로마자 힌트(spillway 등)로 치환하고,
남은 비ASCII 문자는 제거한다. 결과가 비면 fallback 반환.
"""
if not name:
return fallback
out = str(name)
for ko, en in _KO_TO_ASCII_HINTS.items():
if ko in out:
out = out.replace(ko, en)
# 남은 비ASCII 제거 (조합형 한글 자모 등)
out = unicodedata.normalize("NFKD", out)
out = out.encode("ascii", "ignore").decode("ascii")
# 연속 공백/특수기호 정리
out = re.sub(r"\s+", " ", out).strip(" -_")
return out or fallback
# ---------------------------------------------------------------------------
# Winding 검증
# ---------------------------------------------------------------------------
def _signed_area(pts) -> float:
"""Shoelace: 부호가 있는 면적. 양수=반시계, 음수=시계 (수학 좌표계)."""
n = len(pts)
if n < 3:
return 0.0
s = 0.0
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
s += x1 * y2 - x2 * y1
return s / 2.0
def is_clockwise(pts) -> bool:
"""점 리스트가 시계방향이면 True (signed area < 0)."""
return _signed_area(pts) < 0
def enforce_clockwise(pts: list) -> list:
"""시계방향이 아니면 역순으로 반환."""
if len(pts) < 3:
return list(pts)
if is_clockwise(pts):
return list(pts)
return list(reversed(pts))
# ---------------------------------------------------------------------------
# 변환 데이터클래스
# ---------------------------------------------------------------------------
@dataclass
class PlacementTransform:
"""구조물 로컬 좌표 → TIN 월드 좌표 변환.
변환식: dst = scale * R(rotation_deg) * src + (tx, ty)
- rotation_deg: Z축 양의 방향(반시계) 기준 (DXF 컨벤션)
- scale: 균등 스케일 (1.0 기대, mm↔m면 1000/0.001)
- residual: 4점 잔차의 RMS (m)
"""
tx: float = 0.0
ty: float = 0.0
rotation_deg: float = 0.0
scale: float = 1.0
residual: float = 0.0
ref_plan: list = field(default_factory=list) # 구조물 평면 4점 (시계방향)
ref_tin: list = field(default_factory=list) # TIN 평면 4점 (시계방향)
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> "PlacementTransform":
if not isinstance(d, dict):
return cls()
known = {k: d.get(k) for k in (
"tx", "ty", "rotation_deg", "scale", "residual",
"ref_plan", "ref_tin",
) if k in d}
return cls(**known)
def describe(self) -> str:
return (f"tx={self.tx:+.2f}m ty={self.ty:+.2f}m "
f"rot={self.rotation_deg:+.2f}° "
f"scale={self.scale:.4f} "
f"residual={self.residual:.3f}m")
# ---------------------------------------------------------------------------
# Similarity 변환 계산 (Umeyama)
# ---------------------------------------------------------------------------
def compute_similarity_transform(src_pts, dst_pts) -> PlacementTransform:
"""4점 대응으로부터 2D Similarity 변환(회전+이동+균등스케일) 최소제곱 해.
Umeyama(1991) 알고리즘의 2D 버전.
Args:
src_pts: 소스 4점 [(x,y), ...] (구조물 평면도 좌표, 시계방향)
dst_pts: 타겟 4점 [(x,y), ...] (TIN 평면도 좌표, 시계방향)
Returns:
PlacementTransform
"""
src = np.asarray(src_pts, dtype=np.float64)
dst = np.asarray(dst_pts, dtype=np.float64)
if src.shape != dst.shape or src.shape[1] != 2 or len(src) < 2:
return PlacementTransform(ref_plan=list(src_pts), ref_tin=list(dst_pts))
n = len(src)
mu_src = src.mean(axis=0)
mu_dst = dst.mean(axis=0)
src_c = src - mu_src
dst_c = dst - mu_dst
var_src = float(np.sum(src_c ** 2) / n)
if var_src < 1e-12:
# 소스 점들이 한 점에 모여 있음 → scale=1, rotation=0
return PlacementTransform(
tx=float(mu_dst[0] - mu_src[0]),
ty=float(mu_dst[1] - mu_src[1]),
rotation_deg=0.0, scale=1.0, residual=0.0,
ref_plan=src.tolist(), ref_tin=dst.tolist(),
)
# 공분산 행렬 H = dst_c.T @ src_c (2x2)
H = dst_c.T @ src_c / n
U, S, Vt = np.linalg.svd(H)
# 반사 방지: det(R) = +1 보장
d = np.sign(np.linalg.det(U @ Vt))
if d == 0:
d = 1.0
D = np.diag([1.0, d])
R = U @ D @ Vt
# 스케일: trace(D · diag(S)) / var_src
scale = float((S[0] + d * S[1]) / var_src)
# 평행이동
t = mu_dst - scale * (R @ mu_src)
# 각도 (반시계 양수)
rotation_deg = math.degrees(math.atan2(R[1, 0], R[0, 0]))
# 잔차 RMS
predicted = (scale * (R @ src.T)).T + t
errors = dst - predicted
residual = float(np.sqrt(np.mean(np.sum(errors ** 2, axis=1))))
return PlacementTransform(
tx=float(t[0]), ty=float(t[1]),
rotation_deg=rotation_deg, scale=scale,
residual=residual,
ref_plan=src.tolist(), ref_tin=dst.tolist(),
)
# ---------------------------------------------------------------------------
# 평면도 Shape 추출
# ---------------------------------------------------------------------------
def _shape_in_bounds(shape, bounds, margin: float = 1.0) -> bool:
"""Shape의 bbox 중심이 bounds 안에 있는지."""
if not shape.points:
return False
b = shape.bbox
cx = (b[0] + b[2]) / 2
cy = (b[1] + b[3]) / 2
return (bounds[0] - margin <= cx <= bounds[2] + margin and
bounds[1] - margin <= cy <= bounds[3] + margin)
def extract_plan_shapes_from_file(dxf_path: str,
try_view_detection: bool = True,
prefer_view_type: str = "plan"):
"""단일 DXF 파일에서 평면도 shapes를 추출.
블록(INSERT) 재귀 explode 포함. try_view_detection=True면 평면도 뷰
영역만 필터, 실패 시 전체 DXF 반환.
"""
from dxf_geometry import extract_structural_geometry
if not dxf_path:
return [], (0, 0, 0, 0), "", "DXF 경로 없음"
try:
geom = extract_structural_geometry(dxf_path, explode_blocks=True)
except Exception as e:
return [], (0, 0, 0, 0), dxf_path, f"DXF 파싱 실패: {e}"
if try_view_detection:
try:
from view_detector import detect_view_regions, get_view_by_type
views = detect_view_regions(dxf_path)
plan_view = get_view_by_type(views, prefer_view_type) if views else None
if plan_view is not None:
bounds = tuple(plan_view.bounds)
inside = [s for s in geom.shapes if _shape_in_bounds(s, bounds)]
if inside:
return (inside, bounds, dxf_path,
f"평면도 뷰 자동감지 · {len(inside)}개 요소 (블록 explode)")
except Exception:
pass
return (list(geom.shapes), tuple(geom.total_bounds), dxf_path,
f"전체 DXF 사용 · {len(geom.shapes)}개 요소 (블록 explode)")
def detect_best_plan_file(dxf_paths: list) -> str:
"""평면도 뷰가 감지된 첫 번째 파일을 우선 추천. 없으면 첫 파일."""
if not dxf_paths:
return ""
for p in dxf_paths:
try:
from view_detector import detect_view_regions, get_view_by_type
views = detect_view_regions(p)
if views and get_view_by_type(views, "plan") is not None:
return p
except Exception:
continue
return dxf_paths[0]
def extract_plan_shapes(dxf_paths: list, prefer_view_type: str = "plan"):
"""구조물 상세 DXF들 중 "평면도"에 해당하는 Shape 리스트를 반환.
블록(INSERT)도 재귀 explode하여 내부 엔티티까지 추출한다.
절차:
1) view_detector.detect_view_regions() 로 평면도 영역(bounds) 감지
2) extract_structural_geometry(explode_blocks=True) 로 전체 shape 추출
3) 평면도 감지 성공 시 bounds 안쪽 shape만 필터
4) 평면도 감지 실패 시 첫 DXF의 전체 shapes 반환
Returns:
(shapes, bounds, source_file, note)
"""
from dxf_geometry import extract_structural_geometry
from view_detector import detect_view_regions, get_view_by_type
if not dxf_paths:
return [], (0, 0, 0, 0), "", "DXF 경로 없음"
# 1) 각 파일에서 평면도 영역 검색 (블록 explode 포함)
for path in dxf_paths:
try:
views = detect_view_regions(path)
except Exception:
views = []
plan_view = get_view_by_type(views, prefer_view_type) if views else None
# 블록 explode된 전체 shapes
try:
geom = extract_structural_geometry(path, explode_blocks=True)
except Exception as e:
continue
if plan_view is not None:
bounds = tuple(plan_view.bounds)
inside = [s for s in geom.shapes if _shape_in_bounds(s, bounds)]
if inside:
return (inside, bounds, path,
f"평면도 뷰 자동감지 · {len(inside)}개 요소 (블록 explode 포함)")
# 해당 파일의 평면도가 검출 안되면 다음 파일로
if plan_view is None and geom.shapes:
# 폴백: 이 DXF 전체 사용
return (list(geom.shapes), tuple(geom.total_bounds), path,
f"전체 DXF 사용 (평면도 자동감지 실패) · "
f"{len(geom.shapes)}개 요소 (블록 explode 포함)")
# 2) 완전 실패 시 첫 DXF 전체
first = dxf_paths[0]
try:
geom = extract_structural_geometry(first, explode_blocks=True)
return (list(geom.shapes), tuple(geom.total_bounds),
first, f"전체 DXF 사용 · {len(geom.shapes)}개 요소")
except Exception as e:
return [], (0, 0, 0, 0), first, f"DXF 파싱 실패: {e}"
def extract_tin_shapes(tin_dxf_path: str, shift_origin=None):
"""TIN 생성용 DXF에서 shapes 전체 추출 (블록 explode 포함).
Args:
tin_dxf_path: DXF 경로
shift_origin: (ox, oy) 또는 None. 지정 시 모든 shape 점에서 (ox, oy)를
차감하여 TIN 로컬 좌표계로 정렬. self.origin을 넘기면 TIN 캔버스와
self.tin_mesh의 좌표계가 일치하여 사용자 픽이 바로 local로 저장됨.
Note:
unit_override="m"으로 고정 — create_tin_from_dxf가 원좌표를 그대로 쓰므로
shape도 같은 좌표계(m)로 읽어야 shift_origin 차감이 정합함. 자동감지가
KATEC 절대좌표를 mm로 오판해 ×0.001 되는 문제 방지.
"""
from dxf_geometry import extract_structural_geometry
from dxf_geometry import Shape as _Shape
if not tin_dxf_path:
return [], (0, 0, 0, 0), "", "DXF 경로 없음"
try:
geom = extract_structural_geometry(
tin_dxf_path, explode_blocks=True, unit_override="m"
)
shapes = geom.shapes
bounds = geom.total_bounds
if shift_origin is not None:
ox = float(shift_origin[0])
oy = float(shift_origin[1])
shifted = []
for s in shapes:
new_pts = [(float(p[0]) - ox, float(p[1]) - oy) for p in s.points]
new_extra = dict(s.extra) if s.extra else {}
if "center" in new_extra:
c = new_extra["center"]
new_extra["center"] = (float(c[0]) - ox, float(c[1]) - oy)
shifted.append(_Shape(kind=s.kind, layer=s.layer,
points=new_pts, closed=s.closed,
extra=new_extra))
shapes = shifted
bounds = (bounds[0] - ox, bounds[1] - oy,
bounds[2] - ox, bounds[3] - oy)
note_suffix = " (origin 차감, TIN 로컬)"
else:
note_suffix = ""
return (shapes, tuple(bounds), tin_dxf_path,
f"TIN DXF 전체 · {len(shapes)}개 요소 (블록 explode){note_suffix}")
except Exception as e:
return [], (0, 0, 0, 0), tin_dxf_path, f"DXF 파싱 실패: {e}"
# ---------------------------------------------------------------------------
# DXF 2D 픽 캔버스 (matplotlib + tkinter)
# ---------------------------------------------------------------------------
class DxfPickerCanvas(ctk.CTkFrame):
"""DXF Shape 리스트를 matplotlib으로 그리고 4점까지 클릭 선택.
- 마우스 스크롤: 커서 위치 기준 줌 인/아웃
- 좌클릭: 꼭짓점 스냅 (커서 15px 이내 shape vertex) 후 점 추가
- Pan/Zoom 툴바 모드에선 클릭 무시
"""
MAX_PICKS = 4
SNAP_PIXELS = 15.0 # 스냅 허용 픽셀 반경
ZOOM_FACTOR = 1.25 # 스크롤 1회 줌 배율
def __init__(self, parent, shapes, bounds, title: str,
on_picks_changed=None, fg_color: str = "#1e1e1e"):
super().__init__(parent, fg_color=fg_color)
self.shapes = list(shapes) if shapes else []
self.bounds = bounds if bounds else (0, 0, 1, 1)
self.title = title
self.on_picks_changed = on_picks_changed
self.picks: list = [] # [(x, y), ...]
self._pick_artists: list = []
self._pick_label_artists: list = []
# 스냅 포인트 인덱스 (shape 꼭짓점 + 원/호 중심)
self._snap_pts = self._build_snap_points()
# 스냅 힌트 (마우스 이동 시 표시되는 원형 마커)
self._snap_hint = None
self._snap_at = None # 현재 스냅된 월드좌표 or None
self._build_ui()
self._draw_shapes()
def _build_snap_points(self) -> np.ndarray:
"""모든 shape의 꼭짓점을 numpy 배열로 집계 (중복 허용, 빠른 탐색용)."""
pts = []
for sh in self.shapes:
if not sh.points:
continue
# 폴리라인/라인/원호 샘플은 점 수가 많으므로
# 끝점·주요 꼭짓점만 스냅 대상으로.
if sh.kind in ("line",):
pts.extend(sh.points)
elif sh.kind == "polyline":
# 시작/끝 + 각 꼭짓점 (샘플링 아님: 실제 vertex)
pts.extend(sh.points)
elif sh.kind == "arc":
# 원호는 양 끝점 + 중심
if len(sh.points) >= 2:
pts.append(sh.points[0])
pts.append(sh.points[-1])
c = sh.extra.get("center") if sh.extra else None
if c:
pts.append(c)
elif sh.kind == "circle":
c = sh.extra.get("center") if sh.extra else None
if c:
pts.append(c)
if not pts:
return np.empty((0, 2), dtype=np.float64)
arr = np.array(pts, dtype=np.float64)
# 중복 정리 (약간의 허용오차)
if len(arr) > 1:
_, idx = np.unique(np.round(arr, 4), axis=0, return_index=True)
arr = arr[np.sort(idx)]
return arr
def _snap_to_vertex(self, x, y):
"""커서 월드좌표 (x, y) 근처 꼭짓점으로 스냅.
반환: (snapped_x, snapped_y, is_snapped)
"""
if len(self._snap_pts) == 0:
return x, y, False
# 데이터→픽셀 변환으로 픽셀 거리 측정
try:
trans = self.ax.transData
click_disp = trans.transform((x, y))
pts_disp = trans.transform(self._snap_pts)
dists = np.linalg.norm(pts_disp - click_disp, axis=1)
idx = int(np.argmin(dists))
if dists[idx] < self.SNAP_PIXELS:
sp = self._snap_pts[idx]
return float(sp[0]), float(sp[1]), True
except Exception:
pass
return x, y, False
# -- UI --
def _build_ui(self):
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=5, pady=(5, 0))
ctk.CTkLabel(header, text=self.title,
font=ctk.CTkFont(size=12, weight="bold"),
anchor="w").pack(side="left", padx=5)
self.status_var = ctk.StringVar(value="0/4 점 선택됨")
ctk.CTkLabel(header, textvariable=self.status_var,
font=ctk.CTkFont(size=10),
text_color="#F39C12", anchor="e").pack(side="right", padx=5)
fig_frame = ctk.CTkFrame(self, fg_color="#121212")
fig_frame.pack(fill="both", expand=True, padx=5, pady=5)
self.fig = Figure(figsize=(5, 5), dpi=100, facecolor="#1e1e1e")
self.ax = self.fig.add_subplot(111, facecolor="#121212")
self.ax.set_aspect("equal", adjustable="datalim")
self.ax.tick_params(colors="#CCCCCC", labelsize=8)
for spine in self.ax.spines.values():
spine.set_color("#666666")
self.canvas = FigureCanvasTkAgg(self.fig, master=fig_frame)
self.canvas.get_tk_widget().pack(fill="both", expand=True)
self.canvas.mpl_connect("button_press_event", self._on_click)
self.canvas.mpl_connect("scroll_event", self._on_scroll)
self.canvas.mpl_connect("motion_notify_event", self._on_motion)
# 툴바 (zoom/pan). 빈 frame에 pack 후 색상 맞춤
tb_frame = ctk.CTkFrame(self, fg_color="transparent")
tb_frame.pack(fill="x", padx=5, pady=(0, 5))
try:
self.toolbar = NavigationToolbar2Tk(self.canvas, tb_frame, pack_toolbar=False)
self.toolbar.update()
self.toolbar.pack(side="left")
except Exception:
self.toolbar = None
ctk.CTkLabel(tb_frame,
text=" 스크롤: 줌 · 좌클릭: 꼭짓점 스냅 선택",
font=ctk.CTkFont(size=10), text_color="#888888"
).pack(side="left", padx=6)
ctk.CTkButton(tb_frame, text="픽 리셋", width=80, height=26,
fg_color="transparent", border_width=1,
command=self.reset_picks).pack(side="right", padx=3)
# -- Shape 렌더 --
def _draw_shapes(self):
self.ax.clear()
for spine in self.ax.spines.values():
spine.set_color("#666666")
self.ax.set_facecolor("#121212")
if not self.shapes:
self.ax.text(0.5, 0.5, "(표시할 지오메트리가 없습니다)",
transform=self.ax.transAxes,
ha="center", va="center", color="#888")
self.canvas.draw_idle()
return
for sh in self.shapes:
pts = sh.points
if not pts or len(pts) < 2:
continue
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
if sh.kind == "polyline" and sh.closed and len(pts) >= 3:
poly = MplPolygon(pts, closed=True, facecolor="#3A4A5A",
edgecolor="#A0C0E0", linewidth=0.6, alpha=0.55)
self.ax.add_patch(poly)
elif sh.kind == "circle":
cx, cy = sh.extra.get("center", (0, 0))
r = float(sh.extra.get("radius", 1.0))
self.ax.add_patch(MplCircle((cx, cy), r,
fill=False, edgecolor="#A0C0E0",
linewidth=0.6))
else:
self.ax.plot(xs, ys, color="#A0C0E0", linewidth=0.6)
# 축 범위
xmin, ymin, xmax, ymax = self.bounds
if xmax - xmin < 1e-6 or ymax - ymin < 1e-6:
xmin, ymin, xmax, ymax = -1, -1, 1, 1
pad_x = (xmax - xmin) * 0.05
pad_y = (ymax - ymin) * 0.05
self.ax.set_xlim(xmin - pad_x, xmax + pad_x)
self.ax.set_ylim(ymin - pad_y, ymax + pad_y)
self.ax.grid(True, color="#333333", linewidth=0.4, alpha=0.5)
self.ax.set_xlabel("X (m)", color="#CCCCCC")
self.ax.set_ylabel("Y (m)", color="#CCCCCC")
self._redraw_picks()
self.canvas.draw_idle()
def _redraw_picks(self):
for a in self._pick_artists:
try: a.remove()
except Exception: pass
for a in self._pick_label_artists:
try: a.remove()
except Exception: pass
self._pick_artists = []
self._pick_label_artists = []
colors = ["#E74C3C", "#F39C12", "#F1C40F", "#27AE60"]
for i, (x, y) in enumerate(self.picks):
c = colors[i % len(colors)]
sc = self.ax.scatter([x], [y], s=90, color=c, edgecolors="white",
linewidths=1.5, zorder=10)
txt = self.ax.annotate(str(i + 1), (x, y),
xytext=(6, 6), textcoords="offset points",
color="white", fontsize=11, fontweight="bold",
zorder=11)
self._pick_artists.append(sc)
self._pick_label_artists.append(txt)
# 연결 폴리곤 점선 (4점 다 모이면)
if len(self.picks) == self.MAX_PICKS:
xs = [p[0] for p in self.picks] + [self.picks[0][0]]
ys = [p[1] for p in self.picks] + [self.picks[0][1]]
ln, = self.ax.plot(xs, ys, color="#FFFFFF", linewidth=0.8,
linestyle="--", alpha=0.7, zorder=9)
self._pick_artists.append(ln)
# -- 클릭 핸들러 --
def _on_click(self, event):
if event.inaxes is not self.ax:
return
# Pan/Zoom 모드에서는 무시
if self.toolbar is not None:
mode = getattr(self.toolbar, "mode", "")
if mode:
return
if event.button != 1: # 좌클릭만
return
if event.xdata is None or event.ydata is None:
return
if len(self.picks) >= self.MAX_PICKS:
messagebox.showinfo("안내",
f"이미 {self.MAX_PICKS}개 점이 선택되었습니다. '픽 리셋' 후 다시 선택하세요.",
parent=self.winfo_toplevel())
return
# 꼭짓점 스냅
sx, sy, snapped = self._snap_to_vertex(float(event.xdata), float(event.ydata))
self.picks.append((sx, sy))
self._redraw_picks()
self.canvas.draw_idle()
snap_tag = " (스냅)" if snapped else ""
self.status_var.set(f"{len(self.picks)}/{self.MAX_PICKS} 점 선택됨{snap_tag}")
if self.on_picks_changed is not None:
try:
self.on_picks_changed(list(self.picks))
except Exception:
pass
def _on_scroll(self, event):
"""커서 중심으로 줌 인/아웃."""
if event.inaxes is not self.ax:
return
if event.xdata is None or event.ydata is None:
return
# event.button: 'up' = 스크롤 위 (zoom in), 'down' = 스크롤 아래 (zoom out)
# event.step 부호로도 판정 가능
direction = 1
if hasattr(event, "step") and event.step is not None:
direction = 1 if event.step > 0 else -1
else:
direction = 1 if event.button == "up" else -1
scale = 1.0 / self.ZOOM_FACTOR if direction > 0 else self.ZOOM_FACTOR
xlim = self.ax.get_xlim()
ylim = self.ax.get_ylim()
cx, cy = float(event.xdata), float(event.ydata)
new_x0 = cx - (cx - xlim[0]) * scale
new_x1 = cx + (xlim[1] - cx) * scale
new_y0 = cy - (cy - ylim[0]) * scale
new_y1 = cy + (ylim[1] - cy) * scale
self.ax.set_xlim(new_x0, new_x1)
self.ax.set_ylim(new_y0, new_y1)
self.canvas.draw_idle()
def _on_motion(self, event):
"""커서 이동 시 스냅 힌트 표시."""
if event.inaxes is not self.ax:
self._clear_snap_hint()
return
if event.xdata is None or event.ydata is None:
self._clear_snap_hint()
return
sx, sy, snapped = self._snap_to_vertex(float(event.xdata), float(event.ydata))
if snapped:
self._show_snap_hint(sx, sy)
else:
self._clear_snap_hint()
def _show_snap_hint(self, x, y):
need_redraw = False
if self._snap_at is None or (abs(self._snap_at[0] - x) > 1e-6 or
abs(self._snap_at[1] - y) > 1e-6):
self._clear_snap_hint(draw=False)
hint = self.ax.scatter([x], [y], s=140, facecolors="none",
edgecolors="#00E0FF", linewidths=1.8,
zorder=20)
self._snap_hint = hint
self._snap_at = (x, y)
need_redraw = True
if need_redraw:
self.canvas.draw_idle()
def _clear_snap_hint(self, draw: bool = True):
if self._snap_hint is not None:
try:
self._snap_hint.remove()
except Exception:
pass
self._snap_hint = None
self._snap_at = None
if draw:
self.canvas.draw_idle()
def reset_picks(self):
self.picks = []
self._redraw_picks()
self.canvas.draw_idle()
self.status_var.set(f"0/{self.MAX_PICKS} 점 선택됨")
if self.on_picks_changed is not None:
try:
self.on_picks_changed([])
except Exception:
pass
def set_picks(self, pts: list):
self.picks = [(float(p[0]), float(p[1])) for p in pts][: self.MAX_PICKS]
self._redraw_picks()
self.canvas.draw_idle()
self.status_var.set(f"{len(self.picks)}/{self.MAX_PICKS} 점 선택됨")
if self.on_picks_changed is not None:
try:
self.on_picks_changed(list(self.picks))
except Exception:
pass
def get_picks(self) -> list:
return list(self.picks)
def cleanup(self):
"""다이얼로그 close 시 matplotlib/Tk 리소스 해제."""
try:
self.canvas.get_tk_widget().destroy()
except Exception:
pass
try:
plt.close(self.fig)
except Exception:
pass
# ---------------------------------------------------------------------------
# 다이얼로그
# ---------------------------------------------------------------------------
class GeoReferencingDialog(ctk.CTkToplevel):
"""구조물 평면도(좌) ↔ TIN 평면도(우) 2분할 4점 매칭 창.
- 시계방향으로 4개 꼭짓점 선택
- [계산 및 적용] 누르면 Similarity 변환 산출 후 on_confirm(tr) 콜백
"""
def __init__(self, parent, structure_detail_dxf_paths: list,
tin_dxf_path: str, layer_name: str = "",
initial_transform: "PlacementTransform | None" = None,
on_confirm=None,
tin_origin=None, tin_view_bounds=None):
super().__init__(parent)
self.title(f"위치 설정 (Geo-Referencing) — {layer_name}")
self.geometry("1400x860")
self.minsize(1100, 720)
self.grab_set()
self.on_confirm = on_confirm
self._last_transform: PlacementTransform | None = None
self._dxf_paths = list(structure_detail_dxf_paths or [])
self._tin_dxf_path = tin_dxf_path
self._tin_origin = tin_origin # (ox, oy) 또는 None
# 기본 선택: 평면도 뷰 자동감지된 파일, 없으면 첫 파일
default_left_path = detect_best_plan_file(self._dxf_paths) if self._dxf_paths else ""
# TIN 데이터 로드 — origin 차감으로 TIN 로컬 좌표계에 정렬
tin_shapes, tin_bounds, tin_src, tin_note = extract_tin_shapes(
tin_dxf_path, shift_origin=tin_origin
)
# 캔버스 초기 뷰를 TIN 메쉬 바운드로 고정 (아웃라이어 엔티티가 있어도
# 주 콘텐츠가 작게 표시되지 않도록)
if tin_view_bounds is not None and len(tin_view_bounds) >= 4:
tin_bounds = tuple(tin_view_bounds)
# --- 상단 안내 ---
header = ctk.CTkFrame(self, fg_color="#2a2a2a", corner_radius=6)
header.pack(fill="x", padx=12, pady=(12, 6))
ctk.CTkLabel(header,
text="각 도면에서 구조물 외곽 4개 꼭짓점을 동일한 시계방향 순서로 클릭하세요.",
font=ctk.CTkFont(size=13, weight="bold"),
anchor="w").pack(fill="x", padx=12, pady=(8, 2))
ctk.CTkLabel(header,
text="좌: 구조물 상세 도면 (아래에서 파일 선택) · "
"우: TIN 생성용 평면도 · "
"스크롤=줌, 좌클릭=꼭짓점 스냅 선택",
font=ctk.CTkFont(size=11), text_color="#BBBBBB",
anchor="w").pack(fill="x", padx=12, pady=(0, 8))
# --- 좌측 DXF 파일 선택 바 ---
file_bar = ctk.CTkFrame(self, fg_color="#252525", corner_radius=6)
file_bar.pack(fill="x", padx=12, pady=(0, 6))
ctk.CTkLabel(file_bar, text="📄 구조물 평면도로 참조할 DXF:",
font=ctk.CTkFont(size=11, weight="bold")
).pack(side="left", padx=10, pady=6)
# 파일명 → 전체경로 매핑 (표시는 basename, 선택 복원 위해 경로 저장)
self._path_by_basename: dict[str, str] = {}
opts = []
for p in self._dxf_paths:
bn = Path(p).name
# 중복 basename 방지
if bn in self._path_by_basename:
bn = f"{bn} [{Path(p).parent.name}]"
self._path_by_basename[bn] = p
opts.append(bn)
if not opts:
opts = ["(업로드된 구조물 DXF 없음)"]
initial_opt = (Path(default_left_path).name
if default_left_path and Path(default_left_path).name in opts
else opts[0])
self._file_var = ctk.StringVar(value=initial_opt)
self._file_menu = ctk.CTkOptionMenu(
file_bar, values=opts, variable=self._file_var,
width=520, command=self._on_file_select,
)
self._file_menu.pack(side="left", padx=6, pady=6)
ctk.CTkLabel(file_bar, text="(선택 시 좌측 캔버스가 재로드되고 픽 포인트 초기화)",
font=ctk.CTkFont(size=10), text_color="#888888"
).pack(side="left", padx=8)
# --- 2분할 캔버스 ---
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=12, pady=6)
body.grid_columnconfigure(0, weight=1, uniform="canvas")
body.grid_columnconfigure(1, weight=1, uniform="canvas")
body.grid_rowconfigure(0, weight=1)
# 좌측: 선택된 파일의 평면도 shapes
self._body_frame = body
plan_shapes, plan_bounds, plan_src, plan_note = (
extract_plan_shapes_from_file(default_left_path) if default_left_path
else ([], (0, 0, 0, 0), "", "DXF 없음")
)
self.left_canvas = DxfPickerCanvas(
body, plan_shapes, plan_bounds,
title=f"구조물 평면도 · {Path(plan_src).name if plan_src else ''} "
f"[{plan_note}]",
on_picks_changed=self._on_picks_changed,
)
self.left_canvas.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
self.right_canvas = DxfPickerCanvas(
body, tin_shapes, tin_bounds,
title=f"TIN 평면도 · {Path(tin_src).name if tin_src else ''} [{tin_note}]",
on_picks_changed=self._on_picks_changed,
)
self.right_canvas.grid(row=0, column=1, sticky="nsew", padx=(6, 0))
# 초기 변환 복원
if initial_transform is not None:
if initial_transform.ref_plan:
self.left_canvas.set_picks(initial_transform.ref_plan)
if initial_transform.ref_tin:
self.right_canvas.set_picks(initial_transform.ref_tin)
# --- 상태 라벨 ---
self.status_var = ctk.StringVar(value="양쪽 캔버스에서 각각 4점을 시계방향으로 선택하세요.")
self.status_color_frame = ctk.CTkFrame(self, fg_color="#2a2a2a", corner_radius=6)
self.status_color_frame.pack(fill="x", padx=12, pady=(0, 6))
self.status_label = ctk.CTkLabel(
self.status_color_frame, textvariable=self.status_var,
font=ctk.CTkFont(size=12), anchor="w",
text_color="#E0E0E0",
)
self.status_label.pack(fill="x", padx=12, pady=8)
# --- 하단 버튼 ---
bottom = ctk.CTkFrame(self, fg_color="transparent")
bottom.pack(fill="x", padx=12, pady=(0, 12))
ctk.CTkButton(bottom, text="취소", width=90,
fg_color="transparent", border_width=1,
command=self._on_cancel).pack(side="right", padx=3)
self.apply_btn = ctk.CTkButton(
bottom, text="✓ 계산 및 적용", width=150,
fg_color="#27AE60", hover_color="#1E8449",
text_color="white",
font=ctk.CTkFont(size=12, weight="bold"),
command=self._on_apply, state="disabled",
)
self.apply_btn.pack(side="right", padx=3)
ctk.CTkButton(bottom, text="시계방향 강제", width=120,
fg_color="#34495E", hover_color="#2C3E50",
command=self._on_enforce_cw).pack(side="right", padx=3)
ctk.CTkButton(bottom, text="모두 리셋", width=100,
fg_color="transparent", border_width=1,
command=self._on_reset_all).pack(side="left", padx=3)
# 종료 프로토콜
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
# -- 파일 선택 --
def _on_file_select(self, basename: str):
"""사용자가 드롭다운에서 다른 구조물 DXF를 선택하면 좌측 캔버스 재로드."""
path = self._path_by_basename.get(basename)
if not path:
return
# 평면도 감지 시도 + 블록 explode
plan_shapes, plan_bounds, plan_src, plan_note = extract_plan_shapes_from_file(path)
# 기존 좌측 캔버스 제거
try:
self.left_canvas.cleanup()
self.left_canvas.grid_forget()
except Exception:
pass
# 새 좌측 캔버스 생성 (픽 초기화)
self.left_canvas = DxfPickerCanvas(
self._body_frame, plan_shapes, plan_bounds,
title=f"구조물 평면도 · {Path(plan_src).name if plan_src else ''} "
f"[{plan_note}]",
on_picks_changed=self._on_picks_changed,
)
self.left_canvas.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
# 상태 재평가
self._on_picks_changed(None)
# -- 상태 --
def _on_picks_changed(self, _picks):
"""양쪽 4점 모두 있으면 즉시 변환 미리 계산하여 상태 업데이트."""
lp = self.left_canvas.get_picks()
rp = self.right_canvas.get_picks()
counts = f"{len(lp)}/4 · 우 {len(rp)}/4"
if len(lp) == 4 and len(rp) == 4:
tr = compute_similarity_transform(
enforce_clockwise(lp), enforce_clockwise(rp)
)
self._last_transform = tr
msg = (f"{counts}{tr.describe()}")
color = "#27AE60"
if tr.residual > 0.5:
color = "#E74C3C"
msg += " ⚠ 잔차 과다 — 점 위치 재확인 권장"
elif not (0.9 <= tr.scale <= 1.1):
color = "#F39C12"
msg += " ⚠ 스케일 비정상 — 단위 불일치 의심"
self.status_label.configure(text_color=color)
self.status_var.set(msg)
self.apply_btn.configure(state="normal")
else:
self._last_transform = None
self.status_label.configure(text_color="#E0E0E0")
self.status_var.set(f"{counts} — 양쪽 캔버스에서 4점씩 선택하세요.")
self.apply_btn.configure(state="disabled")
# -- 액션 --
def _on_enforce_cw(self):
lp = self.left_canvas.get_picks()
rp = self.right_canvas.get_picks()
if len(lp) == 4:
self.left_canvas.set_picks(enforce_clockwise(lp))
if len(rp) == 4:
self.right_canvas.set_picks(enforce_clockwise(rp))
def _on_reset_all(self):
self.left_canvas.reset_picks()
self.right_canvas.reset_picks()
def _on_apply(self):
if self._last_transform is None:
return
tr = self._last_transform
if self.on_confirm is not None:
try:
self.on_confirm(tr)
except Exception as e:
messagebox.showerror("오류", f"변환 적용 실패:\n{e}", parent=self)
return
self._cleanup_and_destroy()
def _on_cancel(self):
self._cleanup_and_destroy()
def _cleanup_and_destroy(self):
try:
self.left_canvas.cleanup()
except Exception:
pass
try:
self.right_canvas.cleanup()
except Exception:
pass
try:
self.destroy()
except Exception:
pass
# ---------------------------------------------------------------------------
# 수동 테스트 (python geo_referencing.py)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# Umeyama 검증: src를 알려진 변환으로 dst 생성 후 역계산
angle = math.radians(25.0)
s = 1.2
tx, ty = 10.0, 5.0
src = [(0.0, 0.0), (2.0, 0.0), (2.0, 1.0), (0.0, 1.0)] # CCW
src = enforce_clockwise(src)
dst = []
for x, y in src:
xr = s * (x * math.cos(angle) - y * math.sin(angle)) + tx
yr = s * (x * math.sin(angle) + y * math.cos(angle)) + ty
dst.append((xr, yr))
tr = compute_similarity_transform(src, dst)
print("expected: scale=1.2, rot=25.0, tx=10, ty=5")
print("got: ",
f"scale={tr.scale:.6f} rot={tr.rotation_deg:.6f} "
f"tx={tr.tx:.6f} ty={tr.ty:.6f} residual={tr.residual:.2e}")
assert abs(tr.scale - s) < 1e-6, tr.scale
assert abs(tr.rotation_deg - 25.0) < 1e-6, tr.rotation_deg
assert abs(tr.tx - tx) < 1e-6, tr.tx
assert abs(tr.ty - ty) < 1e-6, tr.ty
assert tr.residual < 1e-8, tr.residual
print("OK - Umeyama similarity test passed")
# ASCII title
cases = [
("Yeosuro - sumun", None), # already ascii-like
]
for s_in, _ in cases:
out = to_ascii_title(s_in)
print(f" {s_in!r} -> {out!r}")