feat(B07): 용지도 — 등고선 배경 + 연속지적도·행정구역·범례
빈 도각이던 용지도를 실제 도면으로 만듦. 축척·도곽·장 나눔은 계획평면도와 같고(1/1,200) 배경도 같은 창구(map_background)를 씀. - 연속지적도 필지 경계 + 지번(jibun) 표기. 작은 필지는 솎고(종이 6x3 mm 미만), 도곽을 통째로 감싸는 임야 대필지는 도곽 중심에 지번만 적음 — 그러지 않으면 노선이 대필지 안에 들어앉을 때 지번이 통째로 사라짐. - 시군구·읍면동/리 경계를 파선·일점쇄선으로 구분. 지적·행정 경계는 도곽 범위로 절취 — 자르지 않으면 산지 대필지 하나가 도면을 10 km 로 벌림(실측 8,368 mm). - 도면용 색을 화면용과 따로 정함(등고선을 가장 옅게, 행정 경계를 굵고 진하게). - 범례를 유역도 정보표 자리(오른쪽 칸 방위표 아래)에 넣음. - _geometry_lines 가 MultiPolygon 을 편다 — 지적·행정구역이 그 형식임. 검증(용화_LAS): 콘텐츠 734.3x489.2 mm ≤ A1 작도영역, 지번 「산77-15임」이 지적 속성과 일치, 도면 목록에 landuse 가 실림. 이 노선은 임야 대필지 한 곳에 통째로 들어가 경계선이 도곽 안에 없음 — 자료대로의 정상 결과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export interface DesignDrawingItem {
|
||||
| "mass_haul"
|
||||
| "watershed"
|
||||
| "plan"
|
||||
| "landuse"
|
||||
| "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
@@ -97,6 +98,7 @@ export interface DesignDrawingResponse {
|
||||
| "mass_haul"
|
||||
| "watershed"
|
||||
| "plan"
|
||||
| "landuse"
|
||||
| "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다.
|
||||
|
||||
사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고
|
||||
연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를
|
||||
넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」.
|
||||
|
||||
지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`·
|
||||
`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 —
|
||||
지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다.
|
||||
|
||||
축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다.
|
||||
|
||||
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
TABLE_LABEL_COLOR,
|
||||
_layer,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
_FONT_SIZE,
|
||||
_TITLE_FONT_SIZE,
|
||||
MM,
|
||||
plan_area_mm,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
LANDUSE_KIND = "landuse"
|
||||
LANDUSE_LABEL = "용지도"
|
||||
|
||||
CONTOUR_LAYER_ID = "b07-landuse-contour"
|
||||
PARCEL_LAYER_ID = "b07-landuse-parcel"
|
||||
JIBUN_LAYER_ID = "b07-landuse-jibun"
|
||||
EMD_LAYER_ID = "b07-landuse-emd"
|
||||
SGG_LAYER_ID = "b07-landuse-sgg"
|
||||
ROUTE_LAYER_ID = "b07-landuse-route"
|
||||
LEGEND_LAYER_ID = "b07-landuse-legend"
|
||||
TITLE_LAYER_ID = "b07-landuse-title"
|
||||
|
||||
# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경
|
||||
# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」).
|
||||
CONTOUR_COLOR = "#9aa3ad"
|
||||
PARCEL_COLOR = "#8c6b4f"
|
||||
JIBUN_COLOR = "#5c4632"
|
||||
EMD_COLOR = "#2f7d4f"
|
||||
SGG_COLOR = "#a63d3d"
|
||||
ROUTE_COLOR = "#ffe066"
|
||||
LEGEND_COLOR = TABLE_LABEL_COLOR
|
||||
|
||||
_ROUTE_WIDTH = 3
|
||||
_SGG_WIDTH = 3
|
||||
_EMD_WIDTH = 2
|
||||
_JIBUN_FONT_SIZE = 1.8
|
||||
_LEGEND_FONT_SIZE = 2.4
|
||||
# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다.
|
||||
_JIBUN_MIN_W_MM = 6.0
|
||||
_JIBUN_MIN_H_MM = 3.0
|
||||
|
||||
_LEGEND_ROW_H = 6.0
|
||||
_LEGEND_SAMPLE_W = 12.0
|
||||
_LEGEND_GAP = 3.0
|
||||
_LEGEND_TOP_GAP = 8.0
|
||||
|
||||
# 범례 항목 (표기 이름, 색, 선굵기, 파선).
|
||||
_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = (
|
||||
("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None),
|
||||
("필지 경계", PARCEL_COLOR, 1, None),
|
||||
("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]),
|
||||
("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]),
|
||||
("등고선", CONTOUR_COLOR, 1, None),
|
||||
)
|
||||
|
||||
|
||||
def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]:
|
||||
"""고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다."""
|
||||
xs = [x for x, _y in ring]
|
||||
ys = [y for _x, y in ring]
|
||||
return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
|
||||
|
||||
|
||||
def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]:
|
||||
"""범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다."""
|
||||
entities: list[dict[str, Any]] = []
|
||||
x, y = origin
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:legend:title",
|
||||
"범 례",
|
||||
x + _LEGEND_SAMPLE_W / 2.0 + 6.0,
|
||||
y,
|
||||
LEGEND_LAYER_ID,
|
||||
_LEGEND_FONT_SIZE + 0.6,
|
||||
LEGEND_COLOR,
|
||||
)
|
||||
)
|
||||
for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS):
|
||||
row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H
|
||||
sample = polyline_entity(
|
||||
drawing_id,
|
||||
[(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)],
|
||||
LEGEND_LAYER_ID,
|
||||
color,
|
||||
suffix=f":legend:{index}",
|
||||
dash=dash,
|
||||
width=width,
|
||||
)
|
||||
if sample:
|
||||
entities.append(sample)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:legend:label:{index}",
|
||||
label,
|
||||
x + _LEGEND_SAMPLE_W + _LEGEND_GAP,
|
||||
row_y,
|
||||
LEGEND_LAYER_ID,
|
||||
_LEGEND_FONT_SIZE,
|
||||
LEGEND_COLOR,
|
||||
align="left",
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _boundary_entities(
|
||||
drawing_id: str,
|
||||
rings: list[list[tuple[float, float]]],
|
||||
layer_id: str,
|
||||
color: str,
|
||||
width: int,
|
||||
dash: list[int] | None,
|
||||
paper: Any,
|
||||
tag: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
entities: list[dict[str, Any]] = []
|
||||
for index, ring in enumerate(rings):
|
||||
line = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in ring],
|
||||
layer_id,
|
||||
color,
|
||||
suffix=f":{tag}:{index}",
|
||||
dash=dash,
|
||||
width=width,
|
||||
)
|
||||
if line:
|
||||
entities.append(line)
|
||||
return entities
|
||||
|
||||
|
||||
def build_landuse_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
route_xy: list[tuple[float, float]],
|
||||
contours: list[list[tuple[float, float]]],
|
||||
parcels: list[dict[str, Any]],
|
||||
emd_rings: list[list[tuple[float, float]]],
|
||||
sgg_rings: list[list[tuple[float, float]]],
|
||||
) -> dict[str, Any]:
|
||||
"""용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다.
|
||||
|
||||
`parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는
|
||||
라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다.
|
||||
"""
|
||||
everything = [
|
||||
*route_xy,
|
||||
*(point for line in contours for point in line),
|
||||
*(point for parcel in parcels for point in parcel.get("ring") or []),
|
||||
]
|
||||
if not everything:
|
||||
raise FileNotFoundError(
|
||||
"용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요."
|
||||
)
|
||||
min_x = min(x for x, _y in everything)
|
||||
min_y = min(y for _x, y in everything)
|
||||
|
||||
def paper(point: tuple[float, float]) -> tuple[float, float]:
|
||||
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
# 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다.
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour"
|
||||
)
|
||||
)
|
||||
# 필지 경계 + 지번.
|
||||
jibun: list[dict[str, Any]] = []
|
||||
for index, parcel in enumerate(parcels):
|
||||
ring = parcel.get("ring") or []
|
||||
label_at = parcel.get("label_at")
|
||||
if len(ring) >= 2:
|
||||
outline = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in ring],
|
||||
PARCEL_LAYER_ID,
|
||||
PARCEL_COLOR,
|
||||
suffix=f":parcel:{index}",
|
||||
)
|
||||
if outline:
|
||||
entities.append(outline)
|
||||
elif label_at is None:
|
||||
continue
|
||||
if label_at is not None:
|
||||
# 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다.
|
||||
center = paper(tuple(label_at))
|
||||
else:
|
||||
paper_ring = [paper(point) for point in ring]
|
||||
width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring)
|
||||
height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring)
|
||||
# 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다.
|
||||
if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM:
|
||||
continue
|
||||
center = _ring_center(paper_ring)
|
||||
text = (parcel.get("props") or {}).get("jibun")
|
||||
if not isinstance(text, str) or not text:
|
||||
continue
|
||||
jibun.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:jibun:{index}",
|
||||
text,
|
||||
center[0],
|
||||
center[1],
|
||||
JIBUN_LAYER_ID,
|
||||
_JIBUN_FONT_SIZE,
|
||||
JIBUN_COLOR,
|
||||
)
|
||||
)
|
||||
# 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다.
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd"
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id,
|
||||
sgg_rings,
|
||||
SGG_LAYER_ID,
|
||||
SGG_COLOR,
|
||||
_SGG_WIDTH,
|
||||
[14, 5, 3, 5],
|
||||
paper,
|
||||
"sgg",
|
||||
)
|
||||
)
|
||||
map_bbox = entities_bbox(entities)
|
||||
route = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in route_xy],
|
||||
ROUTE_LAYER_ID,
|
||||
ROUTE_COLOR,
|
||||
width=_ROUTE_WIDTH,
|
||||
)
|
||||
if route:
|
||||
entities.append(route)
|
||||
entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다.
|
||||
|
||||
# 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리).
|
||||
if map_bbox:
|
||||
column_x = map_bbox[2] + _COMPASS_MARGIN
|
||||
column_top = map_bbox[3]
|
||||
entities.extend(
|
||||
compass_entities(
|
||||
drawing_id,
|
||||
(column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0),
|
||||
_COMPASS_SIZE,
|
||||
)
|
||||
)
|
||||
entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0)))
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
min_bx, _min_by, max_bx, max_by = bbox
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:title",
|
||||
label,
|
||||
(min_bx + max_bx) / 2.0,
|
||||
max_by + 12.0,
|
||||
TITLE_LAYER_ID,
|
||||
_TITLE_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:scale",
|
||||
f"S = 1/{DRAWING_SCALE_PLAN:,}",
|
||||
max_bx,
|
||||
max_by + 5.0,
|
||||
TITLE_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="right",
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id,
|
||||
entities_bbox(entities) or bbox,
|
||||
fit=False,
|
||||
fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(CONTOUR_LAYER_ID, "등고선", locked=True),
|
||||
_layer(PARCEL_LAYER_ID, "필지 경계"),
|
||||
_layer(JIBUN_LAYER_ID, "지번"),
|
||||
_layer(EMD_LAYER_ID, "읍면동·리 경계"),
|
||||
_layer(SGG_LAYER_ID, "시군구 경계"),
|
||||
_layer(ROUTE_LAYER_ID, "계획노선"),
|
||||
_layer(LEGEND_LAYER_ID, "범례"),
|
||||
_layer(TITLE_LAYER_ID, "표제"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def landuse_area_mm() -> tuple[float, float]:
|
||||
"""지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽)."""
|
||||
return plan_area_mm()
|
||||
@@ -34,6 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
use_title_fields,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
LANDUSE_ID,
|
||||
MASS_HAUL_ID,
|
||||
PLAN_ID,
|
||||
WATERSHED_ID,
|
||||
@@ -44,6 +45,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_json,
|
||||
_recompute_confirmed_design,
|
||||
_store_confirmed_drawing,
|
||||
landuse_source,
|
||||
plan_source,
|
||||
watershed_source,
|
||||
)
|
||||
@@ -303,6 +305,15 @@ async def get_design_drawing(
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
source_design = await asyncio.to_thread(watershed_source, context)
|
||||
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
|
||||
)
|
||||
elif PLAN_ID.fullmatch(drawing_id):
|
||||
# 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
|
||||
@@ -21,6 +21,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import (
|
||||
build_blank_drawing,
|
||||
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,
|
||||
@@ -52,6 +56,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
CONTOUR_FILE as CONTOUR_FILE,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
LANDUSE_ID as LANDUSE_ID,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
PLAN_ID as PLAN_ID,
|
||||
)
|
||||
@@ -79,6 +86,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
clip_line_to_box as clip_line_to_box,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
landuse_source as landuse_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
plan_source as plan_source,
|
||||
)
|
||||
@@ -119,7 +129,6 @@ BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
||||
("blank_plan_lidar", "계획평면도(라이다)"),
|
||||
("blank_cross_standard", "표준 횡단면도"),
|
||||
("blank_standard", "표준도"),
|
||||
("blank_landuse", "용지도"),
|
||||
)
|
||||
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
|
||||
|
||||
@@ -177,6 +186,19 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
|
||||
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", "표지"),
|
||||
@@ -409,6 +431,8 @@ def _read_drawing(
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
elif PLAN_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)
|
||||
@@ -437,6 +461,27 @@ 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 PLAN_ID.fullmatch(drawing_id):
|
||||
# stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -15,6 +15,7 @@ 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_Plan import (
|
||||
plan_area_mm,
|
||||
plan_chunks,
|
||||
@@ -76,7 +77,7 @@ def _basins_crs(context: Any, payload: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
|
||||
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
|
||||
"""LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다."""
|
||||
if not isinstance(geometry, dict):
|
||||
return []
|
||||
kind = geometry.get("type")
|
||||
@@ -91,6 +92,15 @@ def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
|
||||
for part in coordinates
|
||||
if isinstance(part, list)
|
||||
]
|
||||
# 연속지적도·행정구역은 MultiPolygon 이다 — 폴리곤마다 고리를 모두 편다(2026-09-04).
|
||||
if kind == "MultiPolygon":
|
||||
return [
|
||||
[(float(p[0]), float(p[1])) for p in ring if isinstance(p, list)]
|
||||
for polygon in coordinates
|
||||
if isinstance(polygon, list)
|
||||
for ring in polygon
|
||||
if isinstance(ring, list)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@@ -336,6 +346,130 @@ 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]]]:
|
||||
"""행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀)."""
|
||||
rings: list[list[tuple[float, float]]] = []
|
||||
for ring in _metric_lines(path, crs):
|
||||
rings.extend(clip_line_to_box(ring, box))
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
def watershed_source(context: Any) -> dict[str, Any]:
|
||||
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
|
||||
|
||||
|
||||
@@ -10,7 +10,16 @@ class DesignDrawingItem(BaseModel):
|
||||
|
||||
id: str
|
||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"]
|
||||
kind: Literal[
|
||||
"cover",
|
||||
"longitudinal",
|
||||
"cross",
|
||||
"mass_haul",
|
||||
"watershed",
|
||||
"plan",
|
||||
"landuse",
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
chainage_m: float | None = None
|
||||
confirmed: bool = False
|
||||
@@ -33,7 +42,16 @@ class DesignDrawingResponse(BaseModel):
|
||||
route_id: int
|
||||
id: str
|
||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"]
|
||||
kind: Literal[
|
||||
"cover",
|
||||
"longitudinal",
|
||||
"cross",
|
||||
"mass_haul",
|
||||
"watershed",
|
||||
"plan",
|
||||
"landuse",
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
drawing: dict[str, Any]
|
||||
confirmed: bool = False
|
||||
|
||||
@@ -41,7 +41,7 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||||
{ label: "표준도", blankId: "blank_standard" },
|
||||
{ label: "용지도", blankId: "blank_landuse" },
|
||||
{ label: "용지도", idPrefix: "landuse" },
|
||||
];
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
|
||||
Reference in New Issue
Block a user