Files
Aislo/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py
T
eomsangdonandClaude Opus 5.5 3048dd96dd feat(B07): 용지도 = 평면도 종류 · 필지 상자 라벨 노선 비킴 · 배수유역도 평면도 묶음 · 제목 평면도(93-1~93-4)
- 조각 넷(필지 경계 · 필지 정보 · 읍면동 경계 · 시군구 경계) · 기본 종류 「용지도」 · 옛 종류 폴더에 한 번 더함
- 필지 라벨 = (필지 ∩ 도곽) − 노선 버퍼 대표점 + 상자 · 지시선 · 노선 · 상자끼리 안 겹침(새 Router_Support_Landuse)
- 옛 따로 용지도 길(landuse_source · build_landuse_drawing) 지움
- 배수유역도 목록 항목에 plan_kind 실음 · 종류 제목 「평면도(…)」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-08 13:54:15 +09:00

205 lines
8.4 KiB
Python

"""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_Router_Support_Basin import _clip_rings, _geojson_features
LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg")
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
PARCEL_FILE = "연속지적도_bounds.geojson"
EMD_FILE = "행정구역_읍면동_bounds.geojson"
SGG_FILE = "행정구역_시군구_bounds.geojson"
PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm)
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 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)},
}