Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py
eomsangdonandClaude Opus 5 d662b0725d feat(B07): 용지도 — 등고선 배경 + 연속지적도·행정구역·범례
빈 도각이던 용지도를 실제 도면으로 만듦. 축척·도곽·장 나눔은 계획평면도와
같고(1/1,200) 배경도 같은 창구(map_background)를 씀.

- 연속지적도 필지 경계 + 지번(jibun) 표기. 작은 필지는 솎고(종이 6x3 mm 미만),
  도곽을 통째로 감싸는 임야 대필지는 도곽 중심에 지번만 적음 — 그러지 않으면
  노선이 대필지 안에 들어앉을 때 지번이 통째로 사라짐.
- 시군구·읍면동/리 경계를 파선·일점쇄선으로 구분. 지적·행정 경계는 도곽 범위로
  절취 — 자르지 않으면 산지 대필지 하나가 도면을 10 km 로 벌림(실측 8,368 mm).
- 도면용 색을 화면용과 따로 정함(등고선을 가장 옅게, 행정 경계를 굵고 진하게).
- 범례를 유역도 정보표 자리(오른쪽 칸 방위표 아래)에 넣음.
- _geometry_lines 가 MultiPolygon 을 편다 — 지적·행정구역이 그 형식임.

검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역, 지번 「산77-15임」이 지적
속성과 일치, 도면 목록에 landuse 가 실림. 이 노선은 임야 대필지 한 곳에 통째로
들어가 경계선이 도곽 안에 없음 — 자료대로의 정상 결과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:51:23 +09:00

340 lines
11 KiB
Python

"""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
_LEGEND_FONT_SIZE = 2.4
# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다.
_JIBUN_MIN_W_MM = 6.0
_JIBUN_MIN_H_MM = 3.0
_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 _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"
)
)
# 필지 경계 + 지번.
jibun: list[dict[str, Any]] = []
for index, parcel in enumerate(parcels):
ring = parcel.get("ring") or []
label_at = parcel.get("label_at")
if len(ring) >= 2:
outline = polyline_entity(
drawing_id,
[paper(point) for point in ring],
PARCEL_LAYER_ID,
PARCEL_COLOR,
suffix=f":parcel:{index}",
)
if outline:
entities.append(outline)
elif label_at is None:
continue
if label_at is not None:
# 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다.
center = paper(tuple(label_at))
else:
paper_ring = [paper(point) for point in ring]
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)
# 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다.
if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM:
continue
center = _ring_center(paper_ring)
text = (parcel.get("props") or {}).get("jibun")
if not isinstance(text, str) or not text:
continue
jibun.append(
_text_entity(
f"{drawing_id}:jibun:{index}",
text,
center[0],
center[1],
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()