"""B07 용지도 조각 — 연속지적도 · 행정구역을 평면도 장 범위로 자르고 필지 라벨 자리를 잡는다(93장). 조각 넷 = parcel(필지 경계) · parcel_info(필지 정보 상자) · emd(읍면동 경계) · sgg(시군구 경계). 필지마다 지번 · 지목 · 면적 · 소유자 네 줄을 상자 + 지시선으로 적는다(빈 값은 그 줄만 비움). 지시선 끝 = (필지 ∩ 도곽) − 노선 버퍼 의 대표점 · 상자 = 후보 자리 중 노선 버퍼 · 이미 놓은 상자 · 도곽 밖과 안 겹치는 첫 자리. 좌표는 사업지 m — 그리기는 `Engine_Cad_PlanKinds`. """ from pathlib import Path from typing import Any import numpy as np import shapely from pyproj import Transformer from shapely.errors import GEOSException from shapely.geometry import LineString, Polygon, shape from shapely.geometry import box as box_polygon from shapely.validation import make_valid from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PARCEL_FONT from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg") # 필지 정보 상자 밑에서 잘라 낼 선 조각 — 배경이 글을 가리지 않게(109-4). LABEL_CLIPPED = ("contour", "stream", "parcel", "emd", "sgg") # B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). PARCEL_FILE = "연속지적도_bounds.geojson" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) _GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) _RINGS = 5 # 후보 자리 고리 수 _DIRECTIONS = ((1, 1), (-1, 1), (1, -1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1)) def parcel_info_lines(props: dict[str, Any]) -> list[str]: """지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열.""" def text(value: Any) -> str: return value.strip() if isinstance(value, str) else "" area = props.get("parea") try: area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else "" except (TypeError, ValueError): area_text = text(area) return [ text(props.get("jibun")), text(props.get("jimok")), area_text, text(props.get("owner_nm")), ] def _enclosing_note(props: dict[str, Any]) -> str: """구역 전체가 한 필지 안일 때 정보 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다.""" place = " ".join( str(props[key]).strip() for key in ("sgg_nm", "emd_nm", "ri_nm") if isinstance(props.get(key), str) and props[key].strip() ) return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안" def _lines_of(geometry: Any) -> list[list[list[float]]]: return [ [[round(x, 4), round(y, 4)] for x, y in part.coords] for part in shapely.get_parts(geometry) if isinstance(part, LineString) and len(part.coords) >= 2 ] def clip_parcels( path: Path, crs: str, box: tuple[float, float, float, float] ) -> list[dict[str, Any]]: """도곽에 걸친 필지 [{key, props, area(필지 ∩ 도곽), whole(도곽을 통째로 감쌈), lines}]. `lines` = 도곽 안 경계선(밖은 버림 — 산지 대필지 하나가 도면을 10 km 로 벌리지 않게). 같은 지번(pnu)이 여러 피처면 하나로 합친다. """ if not path.is_file(): return [] to_site = Transformer.from_crs("EPSG:4326", crs, always_xy=True) def project(coords: np.ndarray) -> np.ndarray: xs, ys = to_site.transform(coords[:, 0], coords[:, 1]) return np.column_stack([xs, ys]) frame = box_polygon(*box) groups: dict[str, dict[str, Any]] = {} for index, feature in enumerate(_geojson_features(path)): try: geometry = make_valid(shapely.transform(shape(feature.get("geometry")), project)) except (AttributeError, TypeError, ValueError, GEOSException): continue if geometry.is_empty or not geometry.intersects(frame): continue area = geometry.intersection(frame) if area.area <= 0: continue props = feature.get("properties") or {} key = str(props.get("pnu") or f"#{index}") group = groups.setdefault( key, {"key": key, "props": props, "parts": [], "whole": False, "lines": []} ) group["parts"].append(area) group["whole"] = group["whole"] or geometry.contains(frame) group["lines"].extend(_lines_of(geometry.boundary.intersection(frame))) for group in groups.values(): group["area"] = shapely.union_all(group.pop("parts")) return list(groups.values()) def _anchor(area: Any, clear: Any) -> tuple[float, float]: """지시선 끝 — 노선 버퍼를 뺀 필지 조각 중 가장 큰 것의 대표점(다 덮이면 필지 대표점).""" free = area.difference(clear) polygons = [part for part in shapely.get_parts(free) if isinstance(part, Polygon)] target = max(polygons, key=lambda part: part.area) if polygons else area point = target.representative_point() return (point.x, point.y) def place_labels( parcels: list[dict[str, Any]], route_xy: list[tuple[float, float]], box: tuple[float, float, float, float], ) -> list[dict[str, Any]]: """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() shapely.prepare(clear) no_outline = not any(parcel["lines"] for parcel in parcels) placed: list[tuple[float, float, float, float]] = [] items: list[dict[str, Any]] = [] for parcel in sorted(parcels, key=lambda item: -item["area"].area): lines = parcel_info_lines(parcel["props"]) if no_outline and parcel["whole"]: lines = [_enclosing_note(parcel["props"]), *lines] if not any(lines): continue width_mm, height_mm = balloon_size(lines, PARCEL_FONT) half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 ax, ay = _anchor(parcel["area"], clear) free_of_route: tuple[float, float, float, float] | None = None chosen = None for ring in range(1, _RINGS + 1): for sx, sy in _DIRECTIONS: cx = ax + sx * (half_w + _GAP_M) * ring cy = ay + sy * (half_h + _GAP_M) * ring rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h) inside = ( rect[0] >= box[0] and rect[1] >= box[1] and rect[2] <= box[2] and rect[3] <= box[3] ) if not inside or clear.intersects(box_polygon(*rect)): continue free_of_route = free_of_route or rect if not any( rect[0] < other[2] and other[0] < rect[2] and rect[1] < other[3] and other[1] < rect[3] for other in placed ): chosen = rect break if chosen: break # ponytail: 빈자리가 없으면 상자끼리 겹침은 받아들임 · 노선과는 끝내 안 겹침(못 놓으면 뺌). rect = chosen or free_of_route if rect is None: continue placed.append(rect) items.append( { "lines": lines, "anchor": [round(ax, 4), round(ay, 4)], "center": [ round((rect[0] + rect[2]) / 2.0, 4), round((rect[1] + rect[3]) / 2.0, 4), ], } ) return items def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]: """필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기.""" rects = [] for item in items: width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT) half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 cx, cy = item["center"] rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h)) return rects def landuse_pieces( project_root: Path, crs: str, box: tuple[float, float, float, float], route_xy: list[tuple[float, float]], ) -> dict[str, dict[str, Any]]: """그 장의 용지 조각 넷 — 노선은 라벨이 비켜 갈 전체 중심선(사업지 m).""" folder = project_root / "B04_PreProcess" / "processed" parcels = clip_parcels(folder / PARCEL_FILE, crs, box) return { "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, "parcel_info": {"items": place_labels(parcels, route_xy, box)}, "emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)}, "sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)}, }