"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다. 사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고 연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를 넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. 지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· `parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 필지마다 **지번·지목·면적·소유자** 네 줄을 적는다(사용자: 「연속지적도에 해당 정보를 띄우고 판단」) — 빈 값은 그 줄만 비운다. 축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). """ from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, _layer, _text_entity, polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, _FONT_SIZE, _TITLE_FONT_SIZE, MM, plan_area_mm, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, entities_bbox, frame_entities, scale_fields, ) from config.config_system import DRAWING_SCALE_PLAN LANDUSE_KIND = "landuse" LANDUSE_LABEL = "용지도" CONTOUR_LAYER_ID = "b07-landuse-contour" PARCEL_LAYER_ID = "b07-landuse-parcel" JIBUN_LAYER_ID = "b07-landuse-jibun" EMD_LAYER_ID = "b07-landuse-emd" SGG_LAYER_ID = "b07-landuse-sgg" ROUTE_LAYER_ID = "b07-landuse-route" LEGEND_LAYER_ID = "b07-landuse-legend" TITLE_LAYER_ID = "b07-landuse-title" # 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경 # 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」). CONTOUR_COLOR = "#9aa3ad" PARCEL_COLOR = "#8c6b4f" JIBUN_COLOR = "#5c4632" EMD_COLOR = "#2f7d4f" SGG_COLOR = "#a63d3d" ROUTE_COLOR = "#ffe066" LEGEND_COLOR = TABLE_LABEL_COLOR _ROUTE_WIDTH = 3 _SGG_WIDTH = 3 _EMD_WIDTH = 2 _JIBUN_FONT_SIZE = 1.8 _INFO_LINE_GAP = _JIBUN_FONT_SIZE * 1.3 # 필지 정보 줄 간격(mm) _LEGEND_FONT_SIZE = 2.4 _LEGEND_ROW_H = 6.0 _LEGEND_SAMPLE_W = 12.0 _LEGEND_GAP = 3.0 _LEGEND_TOP_GAP = 8.0 # 범례 항목 (표기 이름, 색, 선굵기, 파선). _LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = ( ("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None), ("필지 경계", PARCEL_COLOR, 1, None), ("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]), ("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]), ("등고선", CONTOUR_COLOR, 1, None), ) def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: """고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다.""" xs = [x for x, _y in ring] ys = [y for _x, y in ring] return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) 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 _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" entities: list[dict[str, Any]] = [] x, y = origin entities.append( _text_entity( f"{drawing_id}:legend:title", "범 례", x + _LEGEND_SAMPLE_W / 2.0 + 6.0, y, LEGEND_LAYER_ID, _LEGEND_FONT_SIZE + 0.6, LEGEND_COLOR, ) ) for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS): row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H sample = polyline_entity( drawing_id, [(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)], LEGEND_LAYER_ID, color, suffix=f":legend:{index}", dash=dash, width=width, ) if sample: entities.append(sample) entities.append( _text_entity( f"{drawing_id}:legend:label:{index}", label, x + _LEGEND_SAMPLE_W + _LEGEND_GAP, row_y, LEGEND_LAYER_ID, _LEGEND_FONT_SIZE, LEGEND_COLOR, align="left", ) ) return entities def _boundary_entities( drawing_id: str, rings: list[list[tuple[float, float]]], layer_id: str, color: str, width: int, dash: list[int] | None, paper: Any, tag: str, ) -> list[dict[str, Any]]: entities: list[dict[str, Any]] = [] for index, ring in enumerate(rings): line = polyline_entity( drawing_id, [paper(point) for point in ring], layer_id, color, suffix=f":{tag}:{index}", dash=dash, width=width, ) if line: entities.append(line) return entities def build_landuse_drawing( drawing_id: str, label: str, route_xy: list[tuple[float, float]], contours: list[list[tuple[float, float]]], parcels: list[dict[str, Any]], emd_rings: list[list[tuple[float, float]]], sgg_rings: list[list[tuple[float, float]]], ) -> dict[str, Any]: """용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. `parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는 라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다. """ everything = [ *route_xy, *(point for line in contours for point in line), *(point for parcel in parcels for point in parcel.get("ring") or []), ] if not everything: raise FileNotFoundError( "용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요." ) min_x = min(x for x, _y in everything) min_y = min(y for _x, y in everything) def paper(point: tuple[float, float]) -> tuple[float, float]: return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) entities: list[dict[str, Any]] = [] # 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다. entities.extend( _boundary_entities( drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" ) ) # 필지 경계 + 필지 정보. 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만 — # 가장 큰 조각 가운데에 적는다(구역 안 필지 수 = 정보 묶음 수). spots: dict[str, tuple[float, tuple[float, float], dict[str, Any]]] = {} enclosing: set[str] = set() # 도곽을 통째로 감싼 필지 for index, parcel in enumerate(parcels): ring = parcel.get("ring") or [] props = parcel.get("props") or {} label_at = parcel.get("label_at") if len(ring) >= 2: paper_ring = [paper(point) for point in ring] outline = polyline_entity( drawing_id, paper_ring, PARCEL_LAYER_ID, PARCEL_COLOR, suffix=f":parcel:{index}" ) if outline: entities.append(outline) width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring) height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring) size, center = width * height, _ring_center(paper_ring) elif label_at is not None: # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 정보만 적는다. size, center = 0.0, paper(tuple(label_at)) enclosing.add(str(props.get("pnu") or f"#{index}")) else: continue key = str(props.get("pnu") or f"#{index}") if key not in spots or size > spots[key][0]: spots[key] = (size, center, props) # 필지 경계가 하나도 안 보이면(구역이 한 필지 안) 어느 필지인지 글로 밝힌다(68-2). no_outline = not any(len(parcel.get("ring") or []) >= 2 for parcel in parcels) jibun: list[dict[str, Any]] = [] for key, (_size, center, props) in spots.items(): lines = _parcel_info_lines(props) if no_outline and key in enclosing: lines = [_enclosing_note(props), *lines] top = center[1] + (len(lines) - 1) / 2.0 * _INFO_LINE_GAP for row, text in enumerate(lines): if text: jibun.append( _text_entity( f"{drawing_id}:parcel-info:{key}:{row}", text, center[0], top - row * _INFO_LINE_GAP, JIBUN_LAYER_ID, _JIBUN_FONT_SIZE, JIBUN_COLOR, ) ) # 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다. entities.extend( _boundary_entities( drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd" ) ) entities.extend( _boundary_entities( drawing_id, sgg_rings, SGG_LAYER_ID, SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5], paper, "sgg", ) ) map_bbox = entities_bbox(entities) route = polyline_entity( drawing_id, [paper(point) for point in route_xy], ROUTE_LAYER_ID, ROUTE_COLOR, width=_ROUTE_WIDTH, ) if route: entities.append(route) entities.extend(jibun) # 필지 정보는 가장 위 — 선에 가리면 못 읽는다. # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). if map_bbox: column_x = map_bbox[2] + _COMPASS_MARGIN column_top = map_bbox[3] entities.extend( compass_entities( drawing_id, (column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0), _COMPASS_SIZE, ) ) entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0))) bbox = entities_bbox(entities) if bbox: min_bx, _min_by, max_bx, max_by = bbox entities.append( _text_entity( f"{drawing_id}:title", label, (min_bx + max_bx) / 2.0, max_by + 12.0, TITLE_LAYER_ID, _TITLE_FONT_SIZE, TABLE_LABEL_COLOR, ) ) entities.append( _text_entity( f"{drawing_id}:scale", f"S = 1/{DRAWING_SCALE_PLAN:,}", max_bx, max_by + 5.0, TITLE_LAYER_ID, _FONT_SIZE, TABLE_LABEL_COLOR, align="right", ) ) entities.extend( frame_entities( drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(CONTOUR_LAYER_ID, "등고선", locked=True), _layer(PARCEL_LAYER_ID, "필지 경계"), _layer(JIBUN_LAYER_ID, "필지 정보(지번·지목·면적·소유자)"), _layer(EMD_LAYER_ID, "읍면동·리 경계"), _layer(SGG_LAYER_ID, "시군구 경계"), _layer(ROUTE_LAYER_ID, "계획노선"), _layer(LEGEND_LAYER_ID, "범례"), _layer(TITLE_LAYER_ID, "표제"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], } def landuse_area_mm() -> tuple[float, float]: """지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽).""" return plan_area_mm()