feat(B07): 토적도(유토곡선)·유역도(수리집수면적유역도)를 도면으로 낸다
납품 도면 2장을 B07 도면 목록에 새로 붙였다. 계산은 하지 않는다 — 유토곡선은 B06 확정 시 저장한 산출물(longitudinal_sections.data.mass_haul)을, 유역도는 B04 세부유역 GeoJSON과 도엽 등고선·세류선을 읽어 좌표만 종이 mm로 옮긴다. - 도면 종류 확장: kind에 mass_haul·watershed 추가(Schema·Api_Fetch·openwebcad App.types), 12분류 라벨에 연결, 목록에 단장 도면 2건 상시 노출 - 엔진 신규: _Engine_Cad_MassHaul.py(축·곡선·평형선·띠 현·balloon·측점 테이블), _Engine_Cad_Basin.py(등고선·세류선 배경·노선·유역·구역별 정보표·방위표) - 척도 상수: 유토곡선 H 1/2,000 · 세로 1mm=50㎥, 유역도 1/6,000 (A1 고정) - 유토곡선 저장 payload에 띠·잔여의 기하 필드 추가 — 파이썬에 곡선 보간·토량 배분 로직을 복제하지 않기 위해 값을 낳는 쪽(TS 엔진)에서 함께 남긴다 - 유역도 배경은 여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취(clip_line_to_box) - 배수규격은 소요 관경이 아니라 규격관(recommended_diameter_mm)으로 표기 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,14 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
@@ -17,10 +21,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
infer_station_interval,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm
|
||||
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_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
@@ -33,7 +39,9 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import detail_basins_path
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_BASIN
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +49,9 @@ _STAGE_DIR = "B07_DesignDetail"
|
||||
|
||||
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
||||
MASS_HAUL_ID = "mass_haul"
|
||||
WATERSHED_ID = "watershed"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
@@ -94,6 +105,129 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _geojson_features(path: Path) -> list[dict[str, Any]]:
|
||||
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path)
|
||||
return []
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
return features if isinstance(features, list) else []
|
||||
|
||||
|
||||
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
|
||||
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
|
||||
if not isinstance(geometry, dict):
|
||||
return []
|
||||
kind = geometry.get("type")
|
||||
coordinates = geometry.get("coordinates")
|
||||
if not isinstance(coordinates, list) or not coordinates:
|
||||
return []
|
||||
if kind == "LineString":
|
||||
return [[(float(p[0]), float(p[1])) for p in coordinates if isinstance(p, list)]]
|
||||
if kind in ("MultiLineString", "Polygon"):
|
||||
return [
|
||||
[(float(p[0]), float(p[1])) for p in part if isinstance(p, list)]
|
||||
for part in coordinates
|
||||
if isinstance(part, list)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def clip_line_to_box(
|
||||
line: list[tuple[float, float]], box: tuple[float, float, float, float]
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""범위 안에 든 연속 구간만 조각으로 잘라 낸다(경계 한 점씩 물려 끊긴 티를 줄인다).
|
||||
|
||||
도엽 등고선 한 줄은 도엽 끝까지 이어지므로, 걸치면 통째로 남기면 도면이 A1을 넘는다.
|
||||
"""
|
||||
min_x, min_y, max_x, max_y = box
|
||||
runs: list[list[tuple[float, float]]] = []
|
||||
current: list[tuple[float, float]] = []
|
||||
for index, point in enumerate(line):
|
||||
x, y = point
|
||||
if min_x <= x <= max_x and min_y <= y <= max_y:
|
||||
if not current and index > 0:
|
||||
current.append(line[index - 1]) # 바깥쪽 직전 점까지 물린다
|
||||
current.append(point)
|
||||
elif current:
|
||||
current.append(point)
|
||||
runs.append(current)
|
||||
current = []
|
||||
if current:
|
||||
runs.append(current)
|
||||
return [run for run in runs if len(run) >= 2]
|
||||
|
||||
|
||||
def watershed_source(context: Any) -> dict[str, Any]:
|
||||
"""유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다.
|
||||
|
||||
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
|
||||
"""
|
||||
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{context.epsg}", always_xy=True)
|
||||
|
||||
def metric(point: tuple[float, float]) -> tuple[float, float]:
|
||||
x, y = to_metric.transform(point[0], point[1])
|
||||
return (float(x), float(y))
|
||||
|
||||
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
|
||||
basins: list[dict[str, Any]] = []
|
||||
for feature in _geojson_features(detail_basins_path(context.stored_path)):
|
||||
properties = feature.get("properties") or {}
|
||||
if properties.get("kind") != "detail_basin":
|
||||
continue
|
||||
rings = _geometry_lines(feature.get("geometry"))
|
||||
if not rings:
|
||||
continue
|
||||
basins.append({"ring": [metric(point) for point in rings[0]], "props": properties})
|
||||
|
||||
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
|
||||
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
|
||||
# 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다
|
||||
# (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기.
|
||||
usable_w_mm, usable_h_mm = map_area_mm()
|
||||
half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
|
||||
half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0
|
||||
extent = [*route_xy, *(point for basin in basins for point in basin["ring"])]
|
||||
if extent:
|
||||
center_x = (min(x for x, _y in extent) + max(x for x, _y in extent)) / 2.0
|
||||
center_y = (min(y for _x, y in extent) + max(y for _x, y in extent)) / 2.0
|
||||
# 노선·유역이 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다.
|
||||
min_x = min(center_x - half_w_m, min(x for x, _y in extent))
|
||||
max_x = max(center_x + half_w_m, max(x for x, _y in extent))
|
||||
min_y = min(center_y - half_h_m, min(y for _x, y in extent))
|
||||
max_y = max(center_y + half_h_m, max(y for _x, y in extent))
|
||||
else:
|
||||
min_x = min_y = -math.inf
|
||||
max_x = max_y = math.inf
|
||||
|
||||
box = (min_x, min_y, max_x, max_y)
|
||||
|
||||
def clip(line: list[tuple[float, float]]) -> list[list[tuple[float, float]]]:
|
||||
return clip_line_to_box(line, box)
|
||||
|
||||
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
|
||||
background: dict[str, list[list[tuple[float, float]]]] = {}
|
||||
for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)):
|
||||
lines: list[list[tuple[float, float]]] = []
|
||||
for feature in _geojson_features(sheet_dir / filename):
|
||||
for part in _geometry_lines(feature.get("geometry")):
|
||||
converted = [metric(point) for point in part]
|
||||
if len(converted) >= 2:
|
||||
lines.extend(clip(converted))
|
||||
background[key] = lines
|
||||
|
||||
return {
|
||||
"route_xy": route_xy,
|
||||
"basins": basins,
|
||||
"contours": background["contours"],
|
||||
"streams": background["streams"],
|
||||
}
|
||||
|
||||
|
||||
def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
@@ -127,6 +261,19 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
||||
for drawing_id, kind, label in (
|
||||
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
|
||||
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
|
||||
):
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind=kind,
|
||||
label=label,
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
return drawings
|
||||
|
||||
|
||||
@@ -306,11 +453,48 @@ def _read_drawing(
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
if drawing_id in (MASS_HAUL_ID, WATERSHED_ID):
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if drawing_id == MASS_HAUL_ID:
|
||||
# stored_design = 확정 종단 DB row의 mass_haul 산출물(라우터가 실어 준다).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("확정 종단에 유토곡선 산출물이 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
return (
|
||||
"mass_haul",
|
||||
"토적도(유토곡선)",
|
||||
build_mass_haul_drawing(longitudinal, stored_design, drawing_id),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if drawing_id == WATERSHED_ID:
|
||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("배수 유역 산출물이 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
return (
|
||||
"watershed",
|
||||
"유역도(배수 유역도)",
|
||||
build_watershed_drawing(
|
||||
drawing_id,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("basins") or [],
|
||||
stored_design.get("contours") or [],
|
||||
stored_design.get("streams") or [],
|
||||
interval,
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if _LONG_ID.fullmatch(drawing_id):
|
||||
source = _read_json(longitudinal_path)
|
||||
chunk = next(
|
||||
|
||||
Reference in New Issue
Block a user