Files
Aislo/resources/tester/test_b07_landuse_parcel_info.py
T
eomsangdonandClaude Opus 5.5 0ca0583995 feat(B07): 배수유역도 종류화 · 평면도 드롭다운 공용 꼴 · IP 표 차례 · 용지도 라벨 2배와 상자 밑 선 자름(109-1~4)
- 배수유역도 = 기본 종류(양식 watershed · 1/6,000 한 장 · 조각 없음) · 도면 id plan_watershed · × 지우기 · 「새 종류」 양식 고르기로 다시 · 옛 폴더에 한 번만 더함
- 평면도 칸 종류 · 양식 고르기 = 공용 createSelectField(compact)
- IP 표 첫 줄 맨 위 · 왼 → 오 · 위 → 아래(묶음 왼쪽 아래 기준 그대로)
- 용지도 필지 정보 글 3.6(상수 하나) · 상자 밑 등고선 · 계류 · 필지 · 읍면동 · 시군구 선 잘라 냄(clip_lines 일반화)
- 시험: 기본 종류 · 유역 한 장 · 지우기 · 다시 만들기 · IP 표 두 줄 · 상자 안 선 0

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

209 lines
8.3 KiB
Python

"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3).
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4)
"""
import json
from pathlib import Path
from shapely.geometry import LineString, Point
from shapely.geometry import box as box_polygon
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines
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 (
build_plan_kind_drawing,
layer_id,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
LABEL_CLIPPED,
PARCEL_FONT,
ROUTE_CLEAR_M,
clip_parcels,
label_rects,
place_labels,
)
BOX = (0.0, 0.0, 400.0, 300.0)
def _square(x0: float, y0: float, size: float) -> list[list[float]]:
return [[x0, y0], [x0 + size, y0], [x0 + size, y0 + size], [x0, y0 + size], [x0, y0]]
def _geojson(tmp_path: Path, features: list[tuple[dict, dict]]) -> Path:
path = tmp_path / "parcels.geojson"
payload = {
"type": "FeatureCollection",
"features": [
{"type": "Feature", "properties": props, "geometry": geometry}
for props, geometry in features
],
}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
def _polygon(ring: list[list[float]]) -> dict:
return {"type": "Polygon", "coordinates": [ring]}
def _rect(item: dict) -> tuple[float, float, float, float]:
width, height = balloon_size(item["lines"], PARCEL_FONT)
cx, cy = item["center"]
return (cx - width / MM / 2, cy - height / MM / 2, cx + width / MM / 2, cy + height / MM / 2)
def test_one_box_per_parcel(tmp_path: Path) -> None:
full = {"pnu": "A", "jibun": "95전", "jimok": "전", "parea": 4916, "owner_nm": "개인"}
split = {"pnu": "B", "jibun": "산1임", "jimok": "임야", "parea": 12.4, "owner_nm": ""}
two_parts = {
"type": "MultiPolygon",
"coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]],
}
path = _geojson(tmp_path, [(full, _polygon(_square(20.0, 20.0, 120.0))), (split, two_parts)])
parcels = clip_parcels(path, "EPSG:4326", BOX)
items = place_labels(parcels, [], BOX)
assert sorted(item["lines"] for item in items) == [
["95전", "전", "4,916㎡", "개인"],
["산1임", "임야", "12㎡", ""],
]
# 쪼개진 필지는 큰 조각(300~380 · 150~230)에 지시선 끝
(b,) = [item for item in items if item["lines"][0] == "산1임"]
assert 300.0 <= b["anchor"][0] <= 380.0 and 150.0 <= b["anchor"][1] <= 230.0
# 빈 줄은 글 없이 자리만 — 도면 글은 일곱
drawing = build_plan_kind_drawing(
"plan_landuse", "평면도(용지도)", BOX, {"parcel_info": {"items": items}}
)
texts = [
e["shapeData"]["label"]
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
]
assert sorted(texts) == sorted(["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡"])
def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None:
props = {
"pnu": "P1",
"sgg_nm": "봉화군",
"emd_nm": "재산면",
"ri_nm": "갈산리",
"jibun": "산77-1임",
"jimok": "임야",
"parea": 1234567,
"owner_nm": "국",
}
path = _geojson(tmp_path, [(props, _polygon(_square(-500.0, -500.0, 2000.0)))])
parcels = clip_parcels(path, "EPSG:4326", BOX)
assert len(parcels) == 1 and parcels[0]["lines"] == [] and parcels[0]["whole"]
(item,) = place_labels(parcels, [(150.0, 100.0), (250.0, 200.0)], BOX)
assert item["lines"] == [
"구역 전체가 이 필지 안 (봉화군 재산면 갈산리)",
"산77-1임",
"임야",
"1,234,567㎡",
"국",
]
def test_boxes_clear_route_and_each_other(tmp_path: Path) -> None:
# 노선이 가운데를 가로지르는 필지 열둘(4 × 3)
features = [
(
{
"pnu": f"P{i}{j}",
"jibun": f"{i}{j}-1",
"jimok": "임야",
"parea": 9999,
"owner_nm": "홍길동",
},
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
)
for i in range(4)
for j in range(3)
]
path = _geojson(tmp_path, features)
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
items = place_labels(clip_parcels(path, "EPSG:4326", BOX), route, BOX)
assert len(items) == 12
clear = LineString(route).buffer(ROUTE_CLEAR_M)
rects = [_rect(item) for item in items]
for item, rect in zip(items, rects):
assert not box_polygon(*rect).intersects(clear) # 상자 · 노선 겹침 0
assert not clear.contains(Point(item["anchor"])) # 지시선 끝도 노선 밖
assert BOX[0] <= rect[0] and rect[2] <= BOX[2] and BOX[1] <= rect[1] and rect[3] <= BOX[3]
for index, rect in enumerate(rects):
for other in rects[index + 1 :]:
assert not box_polygon(*rect).intersects(box_polygon(*other))
# 도면(종이 mm)에서도 상자 테두리가 중심선과 안 닿음
pieces = {
"centerline": {"lines": [[list(p) for p in route]]},
"parcel_info": {"items": items},
}
drawing = build_plan_kind_drawing("plan_landuse", "평면도(용지도)", BOX, pieces)
(center,) = [e for e in drawing["entities"] if e.get("layerId") == layer_id("centerline")]
center_line = _polyline(center)
# 상자 = 변 넷 · 지시선 = 계단 둘
outlines = [
e
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and len(e.get("children") or []) == 4
]
assert len(outlines) == 12
for outline in outlines:
assert _polyline(outline).distance(center_line) > 0
def _polyline(entity: dict) -> LineString:
children = entity["children"]
points = [children[0]["shapeData"]["startPoint"]] + [
c["shapeData"]["endPoint"] for c in children
]
return LineString([(p["x"], p["y"]) for p in points])
def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None:
features = [
(
{
"pnu": f"P{i}{j}",
"jibun": f"{i}{j}-1",
"jimok": "임야",
"parea": 9999,
"owner_nm": "홍길동",
},
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
)
for i in range(4)
for j in range(3)
]
parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX)
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
items = place_labels(parcels, route, BOX)
rects = label_rects(items)
assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음
# 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감
across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)]
pieces = {
"contour": {"lines": [list(line) for line in across]},
"stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]},
"parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]},
"emd": {"lines": [list(line) for line in across]},
"sgg": {"lines": [list(line) for line in across]},
"centerline": {"lines": [[list(p) for p in route]]},
}
clip_lines(pieces, rects, LABEL_CLIPPED)
inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects]
for piece in LABEL_CLIPPED:
for points in pieces[piece]["lines"]:
line = LineString(points)
assert all(line.intersection(area).length < 1e-6 for area in inner), piece
# 상자 밖은 남음 · 노선은 그대로
assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400
assert pieces["centerline"]["lines"] == [[list(p) for p in route]]