Files
Aislo/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py
T
eomsangdonandClaude Opus 5.5 1827cd8bb5 feat(b07): 평면도 종류마다 축척 고르기 — 1/600 · 1/1,000 · 1/1,200 기본 · 유역도 1/6,000 고정(119-1)
- 종류 파일 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
2026-10-08 18:29:18 +09:00

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)},
}