feat(B07): 용지도 = 평면도 종류 · 필지 상자 라벨 노선 비킴 · 배수유역도 평면도 묶음 · 제목 평면도(93-1~93-4)
- 조각 넷(필지 경계 · 필지 정보 · 읍면동 경계 · 시군구 경계) · 기본 종류 「용지도」 · 옛 종류 폴더에 한 번 더함 - 필지 라벨 = (필지 ∩ 도곽) − 노선 버퍼 대표점 + 상자 · 지시선 · 노선 · 상자끼리 안 겹침(새 Router_Support_Landuse) - 옛 따로 용지도 길(landuse_source · build_landuse_drawing) 지움 - 배수유역도 목록 항목에 plan_kind 실음 · 종류 제목 「평면도(…)」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
@@ -1,372 +0,0 @@
|
||||
"""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()
|
||||
@@ -179,6 +179,14 @@ def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> li
|
||||
return entities
|
||||
|
||||
|
||||
def balloon_size(lines: list[str], font: float = _FONT_SIZE) -> tuple[float, float]:
|
||||
"""balloon (폭, 높이) mm — 글 크기에 비례 · 한글 등 넓은 글자는 1.65 칸."""
|
||||
scale = font / _FONT_SIZE
|
||||
chars = max((sum(1.65 if ord(c) > 0x2E7F else 1.0 for c in line) for line in lines), default=0)
|
||||
width = (chars * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X) * scale
|
||||
return width, (len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y) * scale
|
||||
|
||||
|
||||
def _balloon_entities(
|
||||
drawing_id: str,
|
||||
seed: str,
|
||||
@@ -186,10 +194,14 @@ def _balloon_entities(
|
||||
anchor: tuple[float, float],
|
||||
center: tuple[float, float],
|
||||
shape: str,
|
||||
layer: str = BAND_LAYER_ID,
|
||||
color: str = BAND_COLOR,
|
||||
font: float = _FONT_SIZE,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""balloon 도형 + 지시선 + 6줄 문자. shape = hexagon | ellipse | rect."""
|
||||
width = max(len(line) for line in lines) * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X
|
||||
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
|
||||
"""balloon 도형 + 지시선 + 여러 줄 문자(빈 줄은 자리만). shape = hexagon | ellipse | rect."""
|
||||
scale = font / _FONT_SIZE
|
||||
width, height = balloon_size(lines, font)
|
||||
line_h, pad_y = _BALLOON_LINE_H * scale, _BALLOON_PAD_Y * scale
|
||||
cx, cy = center
|
||||
half_w, half_h = width / 2.0, height / 2.0
|
||||
if shape == "hexagon":
|
||||
@@ -222,9 +234,7 @@ def _balloon_entities(
|
||||
]
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
shape_entity = polyline_entity(
|
||||
drawing_id, outline, BAND_LAYER_ID, BAND_COLOR, suffix=f":balloon:{seed}"
|
||||
)
|
||||
shape_entity = polyline_entity(drawing_id, outline, layer, color, suffix=f":balloon:{seed}")
|
||||
if shape_entity:
|
||||
entities.append(shape_entity)
|
||||
# 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 —
|
||||
@@ -234,23 +244,25 @@ def _balloon_entities(
|
||||
leader = polyline_entity(
|
||||
drawing_id,
|
||||
[(corner_x, corner_y), (anchor[0], corner_y), anchor],
|
||||
BAND_LAYER_ID,
|
||||
BAND_COLOR,
|
||||
layer,
|
||||
color,
|
||||
suffix=f":balloon:leader:{seed}",
|
||||
)
|
||||
if leader:
|
||||
entities.append(leader)
|
||||
first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75
|
||||
first_y = cy + half_h - pad_y - line_h * 0.75
|
||||
for index, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:balloon:text:{seed}:{index}",
|
||||
line,
|
||||
cx,
|
||||
first_y - index * _BALLOON_LINE_H,
|
||||
BAND_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
BAND_COLOR,
|
||||
first_y - index * line_h,
|
||||
layer,
|
||||
font,
|
||||
color,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
@@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
@@ -58,6 +59,11 @@ PIECES: tuple[tuple[str, str, bool], ...] = (
|
||||
("endpoints", "시종점", False),
|
||||
("refuge", "대피소 · 사토장", False),
|
||||
("drainage", "배수시설 기호", False),
|
||||
# 용지도(93장) — 연속지적도 · 행정구역
|
||||
("parcel", "필지 경계", False),
|
||||
("parcel_info", "필지 정보", False),
|
||||
("emd", "읍면동 경계", False),
|
||||
("sgg", "시군구 경계", False),
|
||||
)
|
||||
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
|
||||
# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다.
|
||||
@@ -86,6 +92,12 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
"name": "라이다",
|
||||
"pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"],
|
||||
},
|
||||
# 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계).
|
||||
{
|
||||
"id": "landuse",
|
||||
"name": "용지도",
|
||||
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
|
||||
},
|
||||
)
|
||||
|
||||
_COLORS = {
|
||||
@@ -102,7 +114,18 @@ _COLORS = {
|
||||
"endpoints": "#ff9d4d",
|
||||
"refuge": "#7ddc7d",
|
||||
"drainage": STRUCTURE_COLOR,
|
||||
"parcel": "#8c6b4f",
|
||||
"parcel_info": "#5c4632",
|
||||
"emd": "#2f7d4f",
|
||||
"sgg": "#a63d3d",
|
||||
}
|
||||
# 행정 · 지적 경계 (선굵기, 파선)
|
||||
_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"parcel": (1, None),
|
||||
"emd": (2, [8, 4]),
|
||||
"sgg": (3, [14, 5, 3, 5]),
|
||||
}
|
||||
_PARCEL_FONT = 1.8
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
_LABEL_FONT = 2.5
|
||||
@@ -475,6 +498,25 @@ def _piece_entities(
|
||||
text(
|
||||
f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
)
|
||||
elif piece in _BOUNDARY_STYLE:
|
||||
width, dash = _BOUNDARY_STYLE[piece]
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}", width=width, dash=dash)
|
||||
elif piece == "parcel_info":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
out.extend(
|
||||
_balloon_entities(
|
||||
f"{drawing_id}:{piece}",
|
||||
str(index),
|
||||
item["lines"],
|
||||
paper(item["anchor"]),
|
||||
paper(item["center"]),
|
||||
"rect",
|
||||
layer=layer,
|
||||
color=color,
|
||||
font=_PARCEL_FONT,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
LANDUSE_ID,
|
||||
MASS_HAUL_ID,
|
||||
WATERSHED_ID,
|
||||
_cross_sheet_plan,
|
||||
@@ -47,7 +46,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_drawing,
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
landuse_source,
|
||||
watershed_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
@@ -355,15 +353,6 @@ async def get_design_drawing(
|
||||
source_design = await asyncio.to_thread(
|
||||
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
|
||||
)
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
# 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
source_design = await asyncio.to_thread(
|
||||
landuse_source, context, longitudinal, drawing_id
|
||||
)
|
||||
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
||||
_read_drawing,
|
||||
project_root,
|
||||
|
||||
@@ -24,16 +24,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
LANDUSE_LABEL,
|
||||
build_landuse_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
@@ -56,7 +51,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401
|
||||
_BASIN_MAX_DISTANCE_M,
|
||||
CONTOUR_FILE,
|
||||
LANDUSE_ID,
|
||||
LIDAR_ID,
|
||||
PLAN_ID,
|
||||
STREAM_FILE,
|
||||
@@ -67,7 +61,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F4
|
||||
_geometry_lines,
|
||||
_too_far_from_route,
|
||||
clip_line_to_box,
|
||||
landuse_source,
|
||||
lidar_source,
|
||||
plan_source,
|
||||
plan_stations,
|
||||
@@ -158,20 +151,6 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
plan_sheets = plan_chunks(plan_stations(longitudinal))
|
||||
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}"
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind="landuse",
|
||||
label=LANDUSE_LABEL
|
||||
if len(plan_sheets) <= 1
|
||||
else f"{LANDUSE_LABEL} {chunk['number']}장",
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
||||
for drawing_id, kind, label in (
|
||||
(COVER_ID, "cover", "표지"),
|
||||
@@ -185,6 +164,12 @@ def _drawing_list(
|
||||
kind=kind,
|
||||
label=label,
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
# 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다.
|
||||
**(
|
||||
{"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"}
|
||||
if drawing_id == WATERSHED_ID
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
|
||||
@@ -424,8 +409,6 @@ def _read_drawing(
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
kind = "plan"
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
kind = "landuse"
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
@@ -478,27 +461,6 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if LANDUSE_ID.fullmatch(drawing_id):
|
||||
# stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("용지도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or LANDUSE_LABEL)
|
||||
return (
|
||||
"landuse",
|
||||
label,
|
||||
build_landuse_drawing(
|
||||
drawing_id,
|
||||
label,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("contours") or [],
|
||||
stored_design.get("parcels") or [],
|
||||
stored_design.get("emd_rings") or [],
|
||||
stored_design.get("sgg_rings") or [],
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if drawing_id == WATERSHED_ID:
|
||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -16,7 +16,6 @@ from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
plan_area_mm,
|
||||
@@ -366,14 +365,6 @@ def _plan_structures(context: Any) -> list[dict[str, Any]]:
|
||||
return [point for point in points if isinstance(point, dict)]
|
||||
|
||||
|
||||
LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$")
|
||||
|
||||
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
|
||||
PARCEL_FILE = "연속지적도_bounds.geojson"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
|
||||
|
||||
def _clip_rings(
|
||||
path: Path, crs: str, box: tuple[float, float, float, float]
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
@@ -384,112 +375,6 @@ def _clip_rings(
|
||||
return rings
|
||||
|
||||
|
||||
def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool:
|
||||
"""점이 고리 안에 드는지 (반직선 교차 판정)."""
|
||||
x, y = point
|
||||
inside = False
|
||||
for index in range(len(ring)):
|
||||
x1, y1 = ring[index - 1]
|
||||
x2, y2 = ring[index]
|
||||
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def _clip_parcels(
|
||||
path: Path, crs: str, box: tuple[float, float, float, float]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함).
|
||||
|
||||
필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 —
|
||||
피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다.
|
||||
|
||||
두 가지를 함께 낸다.
|
||||
- `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면
|
||||
산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm).
|
||||
- `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이
|
||||
들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측:
|
||||
용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감).
|
||||
"""
|
||||
if not path.is_file():
|
||||
return []
|
||||
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
min_x, min_y, max_x, max_y = box
|
||||
center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
|
||||
parcels: list[dict[str, Any]] = []
|
||||
for feature in _geojson_features(path):
|
||||
properties = feature.get("properties") or {}
|
||||
for ring in _geometry_lines(feature.get("geometry")):
|
||||
converted = [
|
||||
(float(x), float(y))
|
||||
for x, y in (transformer.transform(point[0], point[1]) for point in ring)
|
||||
]
|
||||
if len(converted) < 3:
|
||||
continue
|
||||
if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x:
|
||||
continue
|
||||
if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y:
|
||||
continue
|
||||
parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2]
|
||||
for part in parts:
|
||||
parcels.append({"ring": part, "props": properties})
|
||||
if not parts and _contains(converted, center):
|
||||
parcels.append({"ring": [], "props": properties, "label_at": center})
|
||||
return parcels
|
||||
|
||||
|
||||
def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]:
|
||||
"""용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다.
|
||||
|
||||
축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다.
|
||||
"""
|
||||
match = LANDUSE_ID.fullmatch(drawing_id)
|
||||
if not match:
|
||||
raise ValueError("올바르지 않은 용지도 ID입니다.")
|
||||
chunks = plan_chunks(plan_stations(longitudinal))
|
||||
number = int(match.group(1)) if match.group(1) else 1
|
||||
chunk = next((item for item in chunks if item["number"] == number), None)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.")
|
||||
total = len(chunks)
|
||||
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
|
||||
|
||||
route_xy = [
|
||||
(vertex.x, vertex.y)
|
||||
for vertex in context.vertices
|
||||
if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m
|
||||
]
|
||||
background = map_background(
|
||||
Path(context.project_root),
|
||||
context.crs,
|
||||
DRAWING_SCALE_PLAN,
|
||||
plan_area_mm(),
|
||||
route_xy,
|
||||
)
|
||||
# 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다.
|
||||
area_w_mm, area_h_mm = plan_area_mm()
|
||||
half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0
|
||||
center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0
|
||||
box = (
|
||||
min(center_x - half_w, min(x for x, _y in route_xy)),
|
||||
min(center_y - half_h, min(y for _x, y in route_xy)),
|
||||
max(center_x + half_w, max(x for x, _y in route_xy)),
|
||||
max(center_y + half_h, max(y for _x, y in route_xy)),
|
||||
)
|
||||
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
|
||||
label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장"
|
||||
return {
|
||||
"label": label,
|
||||
"route_xy": route_xy,
|
||||
"contours": background["contours"],
|
||||
"parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box),
|
||||
"emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box),
|
||||
"sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box),
|
||||
}
|
||||
|
||||
|
||||
LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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_Router_Support_Basin import _clip_rings, _geojson_features
|
||||
|
||||
LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg")
|
||||
|
||||
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
|
||||
PARCEL_FILE = "연속지적도_bounds.geojson"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
|
||||
PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm)
|
||||
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],
|
||||
) -> 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 landuse_pieces(
|
||||
project_root: Path,
|
||||
crs: str,
|
||||
box: tuple[float, float, float, float],
|
||||
route_xy: list[tuple[float, float]],
|
||||
) -> 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)},
|
||||
"emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)},
|
||||
"sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)},
|
||||
}
|
||||
@@ -53,6 +53,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
LANDUSE_PIECES,
|
||||
landuse_pieces,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
@@ -65,7 +69,7 @@ _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄
|
||||
|
||||
|
||||
def plan_title(name: str) -> str:
|
||||
return f"계획평면도({name})"
|
||||
return f"평면도({name})"
|
||||
|
||||
|
||||
# ── 종류 ─────────────────────────────────────────────
|
||||
@@ -75,11 +79,38 @@ def _kinds_dir(project_root: Path) -> Path:
|
||||
return _design_root(project_root) / "plan_kinds"
|
||||
|
||||
|
||||
# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
|
||||
_LANDUSE_MARK = "_landuse_added"
|
||||
|
||||
|
||||
def _add_landuse_once(folder: Path) -> None:
|
||||
"""93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다."""
|
||||
mark = folder / _LANDUSE_MARK
|
||||
if mark.exists():
|
||||
return
|
||||
landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse")
|
||||
documents = []
|
||||
for path in folder.glob("*.json"):
|
||||
try:
|
||||
documents.append(_read_json(path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
names = {str(document.get("name")) for document in documents}
|
||||
if not (folder / "landuse.json").exists() and landuse["name"] not in names:
|
||||
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
|
||||
atomic_write_json(
|
||||
folder / "landuse.json",
|
||||
{"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order},
|
||||
)
|
||||
mark.touch()
|
||||
|
||||
|
||||
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷."""
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류."""
|
||||
folder = _kinds_dir(project_root)
|
||||
if not folder.is_dir():
|
||||
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
|
||||
_add_landuse_once(folder)
|
||||
rows: list[tuple[int, str, dict[str, Any]]] = []
|
||||
for path in folder.glob("*.json"):
|
||||
if not KIND_ID.fullmatch(path.stem):
|
||||
@@ -112,6 +143,7 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]:
|
||||
folder / f"{kind['id']}.json",
|
||||
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
|
||||
)
|
||||
(folder / _LANDUSE_MARK).touch()
|
||||
return kinds
|
||||
|
||||
|
||||
@@ -411,6 +443,11 @@ def build_pieces(
|
||||
pieces["lidar"] = {"image": image, "box": list(image_box)}
|
||||
except (FileNotFoundError, ValueError, OSError):
|
||||
pieces["lidar"] = {"image": None, "box": None}
|
||||
if set(LANDUSE_PIECES) & set(wanted):
|
||||
# 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다.
|
||||
pieces.update(
|
||||
landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route])
|
||||
)
|
||||
|
||||
folder = _design_root(project_root) / "plan_pieces" / str(number)
|
||||
for piece, data in pieces.items():
|
||||
|
||||
@@ -1,48 +1,89 @@
|
||||
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6)."""
|
||||
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3).
|
||||
|
||||
import pytest
|
||||
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
|
||||
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
|
||||
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
|
||||
"""
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
JIBUN_LAYER_ID,
|
||||
MM,
|
||||
build_landuse_drawing,
|
||||
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_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 (
|
||||
PARCEL_FONT,
|
||||
ROUTE_CLEAR_M,
|
||||
clip_parcels,
|
||||
place_labels,
|
||||
)
|
||||
|
||||
|
||||
def _square(x0: float, size: float) -> list[tuple[float, float]]:
|
||||
return [(x0, 0.0), (x0 + size, 0.0), (x0 + size, size), (x0, size), (x0, 0.0)]
|
||||
BOX = (0.0, 0.0, 400.0, 300.0)
|
||||
|
||||
|
||||
def test_one_label_group_per_parcel() -> None:
|
||||
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": ""}
|
||||
parcels = [
|
||||
{"ring": _square(0.0, 40.0), "props": full},
|
||||
# 도곽에 잘려 두 조각이 된 필지 — 정보는 큰 조각에 한 번만
|
||||
{"ring": _square(100.0, 10.0), "props": split},
|
||||
{"ring": _square(200.0, 30.0), "props": split},
|
||||
# 도곽을 통째로 감싼 필지 — 경계 없이 정보만
|
||||
{"ring": [], "props": {"pnu": "C", "jibun": "산77"}, "label_at": (500.0, 500.0)},
|
||||
]
|
||||
drawing = build_landuse_drawing("landuse", "용지도", [], [], parcels, [], [])
|
||||
texts = [e for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID]
|
||||
labels = [e["shapeData"]["label"] for e in texts]
|
||||
assert labels == ["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡", "산77"]
|
||||
groups = {round(e["shapeData"]["basePoint"]["x"], 6) for e in texts}
|
||||
assert len(groups) == 3
|
||||
# 쪼개진 필지는 큰 조각(200~230 m) 가운데에
|
||||
split_x = {
|
||||
e["shapeData"]["basePoint"]["x"] for e in texts if e["shapeData"]["label"] == "산1임"
|
||||
two_parts = {
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]],
|
||||
}
|
||||
assert split_x.pop() == pytest.approx(215.0 * MM)
|
||||
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) -> None:
|
||||
"""작은 구역이 큰 필지 하나 안에 통째로 — 경계선이 없어도 어느 필지인지 한 번 적는다 (68-2)."""
|
||||
import json
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_parcels
|
||||
|
||||
def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None:
|
||||
props = {
|
||||
"pnu": "P1",
|
||||
"sgg_nm": "봉화군",
|
||||
@@ -53,27 +94,70 @@ def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None:
|
||||
"parea": 1234567,
|
||||
"owner_nm": "국",
|
||||
}
|
||||
big = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]]
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": props,
|
||||
"geometry": {"type": "Polygon", "coordinates": [big]},
|
||||
}
|
||||
],
|
||||
}
|
||||
path = tmp_path / "parcels.geojson"
|
||||
path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8")
|
||||
parcels = _clip_parcels(path, "EPSG:4326", (4.0, 4.0, 6.0, 6.0))
|
||||
assert len(parcels) == 1 and parcels[0]["ring"] == []
|
||||
|
||||
route = [(4.5, 4.5), (5.5, 5.5)]
|
||||
drawing = build_landuse_drawing("landuse", "용지도", route, [], parcels, [], [])
|
||||
labels = [
|
||||
e["shapeData"]["label"] for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID
|
||||
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㎡",
|
||||
"국",
|
||||
]
|
||||
notes = [label for label in labels if label.startswith("구역 전체가 이 필지 안")]
|
||||
assert notes == ["구역 전체가 이 필지 안 (봉화군 재산면 갈산리)"]
|
||||
assert labels[1:] == ["산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])
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
④ 고른 조각만 도면 층으로 얹힘
|
||||
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
⑥ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -123,7 +124,13 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
|
||||
|
||||
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"]
|
||||
assert [k["name"] for k in list_kinds(root)] == [
|
||||
"지형",
|
||||
"노선배치도",
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
]
|
||||
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
|
||||
assert added["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(ValueError):
|
||||
@@ -132,14 +139,14 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
add_kind(root, "빈 것", [])
|
||||
delete_kind(root, "terrain") # 기본 넷도 지움
|
||||
again = list_kinds(root) # 다시 읽어 같음
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"]
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
|
||||
assert again[-1]["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_kind(root, "terrain")
|
||||
for kind in list(again):
|
||||
delete_kind(root, kind["id"])
|
||||
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
|
||||
|
||||
|
||||
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
|
||||
@@ -184,16 +191,16 @@ def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
|
||||
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
for (
|
||||
kind
|
||||
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
|
||||
for kind in DEFAULT_KINDS:
|
||||
if kind["id"] != "landuse":
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "old.json").write_text(
|
||||
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
|
||||
)
|
||||
(kind,) = list_kinds(root)
|
||||
kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐
|
||||
assert set(kind["pieces"]) == outline | {"station"}
|
||||
|
||||
|
||||
@@ -230,3 +237,43 @@ def test_drainage_symbol_piece() -> None:
|
||||
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
|
||||
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
|
||||
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
|
||||
|
||||
|
||||
def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
for order, kind in enumerate(DEFAULT_KINDS[:4]): # 93장 앞에 내려 둔 기본 넷
|
||||
(folder / f"{kind['id']}.json").write_text(
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
kinds = list_kinds(root)
|
||||
assert [k["id"] for k in kinds][-1] == "landuse"
|
||||
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
|
||||
delete_kind(root, "landuse")
|
||||
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
|
||||
|
||||
|
||||
def test_landuse_pieces_become_layers() -> None:
|
||||
pieces = {
|
||||
"parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]},
|
||||
"emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]},
|
||||
"sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]},
|
||||
"parcel_info": {
|
||||
"items": [
|
||||
{"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]}
|
||||
]
|
||||
},
|
||||
}
|
||||
drawing = build_plan_kind_drawing(
|
||||
"plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces
|
||||
)
|
||||
names = {layer["id"] for layer in drawing["layers"]}
|
||||
assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names
|
||||
labels = [
|
||||
e["shapeData"]["label"]
|
||||
for e in drawing["entities"]
|
||||
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
|
||||
]
|
||||
assert labels == ["1-1", "임야", "국"]
|
||||
|
||||
Reference in New Issue
Block a user