- 종류 파일 scale(없거나 목록 밖이면 1200) · 추가 · 업데이트 인자 · 목록 밖 거절 - 장 나눔 · 도곽 범위 · 배경 절취 · 축척 글 · 도곽 칸 · IP 표 · 필지 라벨 · 측점 눈금 실거리 환산이 축척 따라감(함수 인자) - 「종류」 폼에 공용 「축척」 고르기 · 유역도 양식은 잠금 표시 - 시험 test_119_b07_plan_scale 새로 · test_b07_plan_kinds 종류 dict 에 scale Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
222 lines
9.2 KiB
Python
222 lines
9.2 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_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],
|
|
mm: float = MM,
|
|
) -> 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]], mm: float = MM
|
|
) -> 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]],
|
|
mm: float = MM,
|
|
) -> 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, mm)},
|
|
"emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)},
|
|
"sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)},
|
|
}
|