Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

# Conflicts:
#	B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py
#	B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py
#	resources/tester/test_b07_plan_kinds.py
This commit is contained in:
2026-10-08 13:56:56 +09:00
13 changed files with 541 additions and 641 deletions
@@ -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
@@ -22,6 +22,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_COMPASS_MARGIN,
_COMPASS_SIZE,
@@ -60,6 +61,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 → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다.
@@ -88,6 +94,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 = {
@@ -104,7 +116,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
@@ -479,6 +502,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)},
}
@@ -54,6 +54,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
@@ -66,7 +70,7 @@ _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄
def plan_title(name: str) -> str:
return f"계획평면도({name})"
return f"평면도({name})"
# ── 종류 ─────────────────────────────────────────────
@@ -76,11 +80,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):
@@ -113,6 +144,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
@@ -412,6 +444,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])
)
if "ip_table" in wanted:
pieces["ip_table"] = ip_table_piece(
pieces, _load_design_curves(project_root), route, box, chunk, total
+14 -10
View File
@@ -29,14 +29,12 @@ export const DRAWING_GROUPS: readonly {
idPrefix?: string;
}[] = [
{ label: "표지", kind: "cover" },
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장.
{ label: "계획평면도", kind: "plan" },
{ label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", kind: "cross_standard" },
{ label: "횡단면도", kind: "cross" },
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로.
{ label: "평면도", kind: "plan" },
{ label: "종단도", kind: "longitudinal" },
// 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5).
{ label: "횡단도", kind: "cross" },
{ label: "토적도(유토곡선)", kind: "mass_haul" },
{ label: "유역도(배수 유역도)", kind: "watershed" },
{ label: "용지도", idPrefix: "landuse" },
];
/** B06 확정 산출물 기반 도면 목록 패널. */
@@ -91,7 +89,7 @@ export function buildDrawingSidePanel(
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed");
panel.append(buildPlanSection(plans, drawingButton, plan));
continue;
}
@@ -102,8 +100,9 @@ export function buildDrawingSidePanel(
(item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`),
)
: drawings.filter((item) => item.id === group.blankId);
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
if (items.length <= 1) {
const cross = group.kind === "cross";
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다 — 횡단도는 늘 묶음.
if (items.length <= 1 && !cross) {
const [drawing] = items;
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
if (!drawing) {
@@ -135,6 +134,11 @@ export function buildDrawingSidePanel(
sectionTitle.className = "ui-collapsible__title";
sectionTitle.textContent = `${group.label} ${items.length}`;
section.append(sectionTitle);
if (cross) {
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
section.append(drawingButton(standard, standard.label));
}
for (const drawing of items) {
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
section.append(drawingButton(drawing, drawing.label));
@@ -1,6 +1,6 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
@@ -47,7 +47,7 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
await action();
controls.onChanged();
} catch (error) {
showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error");
showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error");
}
}
@@ -148,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
return form;
}
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
@@ -156,7 +156,7 @@ export function buildPlanSection(
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`));
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
const kinds = new Map<string, DesignDrawingItem[]>();
for (const item of items) {
@@ -173,7 +173,8 @@ export function buildPlanSection(
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
if (controls) {
// × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404).
if (controls && sheets[0].kind === "plan") {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
@@ -192,7 +193,7 @@ export function buildPlanSection(
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
const tools = el("details", "b07-plan-tools");
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
tools.addEventListener("toggle", () => {
const load = (): void => {
if (!tools.open || tools.dataset.loaded) return;
tools.dataset.loaded = "true";
void fetchPlanKinds(controls.projectId)
@@ -204,7 +205,11 @@ export function buildPlanSection(
"error",
);
});
});
};
tools.addEventListener("toggle", load);
// 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6).
tools.open = true;
load();
section.append(tools);
}
return section;
@@ -80,8 +80,9 @@
cursor: not-allowed;
}
/* 그리드 제목은 두 열을 가로지른다 */
.b07-drawing-group[data-kind="cross"] h3 {
/* 그리드 제목 · 표준 횡단면도는 두 열을 가로지른다 */
.b07-drawing-group[data-kind="cross"] h3,
.b07-drawing-group[data-kind="cross"] [data-drawing-id="cross_standard"] {
grid-column: 1 / -1;
}
+141 -57
View File
@@ -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])
+55 -8
View File
@@ -7,6 +7,7 @@
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
"""
import json
@@ -132,7 +133,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):
@@ -141,14 +148,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:
@@ -193,16 +200,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"}
@@ -297,3 +304,43 @@ def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None:
space = ezdxf.readfile(tmp_path / "ip.dxf").modelspace()
assert skipped == {} and len(space.query("LINE")) == 34 and len(space.query("TEXT")) == 17
assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")}
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", "임야", "국"]