1095 lines
42 KiB
Python
1095 lines
42 KiB
Python
# -*- 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}")
|