"""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]]