Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_SheetTiles.py
T
eomsangdonandClaude Opus 5.5 bc1d5165ff feat(D 예정노선): 도엽 조각 — 공용 창고 storage/_shared/sheets · 보이는 도엽만 받기 (PLAN 606-1 · 606-2 · 요구 477)
- 새 D03_PlanAnalysis_SheetTiles.py — 도엽 도곽으로 자른 선 겹 조각(등고 · 계곡 = 전국 등고선 · 세류 · 물 면 · 도로 · 건물 · 지명 = 도엽 zip) · zip 은 프로젝트 · 공용 지형 창고에서 복사(원본 안 지움) 뒤 받기 · 빈 도엽 = 빈 조각 · 받기 실패 저장 안 함 · B04 함수 부르기만
- 라우터 길 둘 — 보이는 도엽 목록(bbox) · 조각 하나
- 새 UI_SheetTiles.ts — 보기 멈춘 뒤 보이는 도엽 · 없는 조각만 하나씩 · 12 장 이하 다 · 13~30 계곡선만 · 넘음 숨김 · 서클
- UI_Layers — 도엽 선 겹 원천을 조각으로 · 시험 606 새 · 475 옛 겹 길 단언 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:14:11 +09:00

255 lines
10 KiB
Python

"""도엽 조각 — 수치지형도 선 겹을 도엽(1:5,000) 한 장씩 공용 창고에(PLAN 606 · 요구 477).
자리 = `storage/_shared/sheets/<도엽>/`(모든 작업 폴더가 보는 정션 · 시놀로지로 데스크탑에도)
— 도엽 zip + 색인(그 도엽 하나 · B04 받기 함수에 이 폴더를 창고로 줌) + `layers/<겹>_<판>.geojson`.
조각 하나 = 도엽 도곽으로 자른 경위도 GeoJSON(자릿수 7) — 이웃 조각과 겹침 없음.
- 등고선 · 계곡선(25 m 배수) = 전국 등고선 파일에서 자름 · 그 도엽에 없으면(섬 등) 도엽 zip 등고선.
- 세류(하천중심선) · 물 면 셋 · 도로중심선 · 건물 · 지명 = 도엽 zip.
도엽 zip = 창고에 없으면 프로젝트 `map_sheets` · 공용 지형 창고(607)에서 복사(원본 안 지움) · 그래도
없으면 브이월드 받기. 받기 실패(로그인 · 망)는 저장 안 함(다음에 다시) · 검색 0(빈 도엽) = 빈 조각.
B04 함수는 부르기만(`prune_sheets` 안 부름 · 요구 491).
"""
from __future__ import annotations
import hashlib
import json
import logging
import math
import re
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import (
SHEET5K_SIZE_DEG,
latlon_to_sheet5k,
sheet5k_to_bounds,
)
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_terrain_store import NATIONAL_GPKG, TERRAIN_ROOT, _Lock
from config.config_system import STORAGE_BASE_DIR
logger = logging.getLogger(__name__)
SHEETS_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "sheets"
TILE_VERSION = 1
CONTOUR, INDEX = "등고선", "계곡선"
INDEX_STEP_M = 25
ZIP_LAYERS = ("하천중심선", "도로중심선", "건물", "지명", "실폭하천", "수계면E003", "수계면E005")
TILE_NAMES = (CONTOUR, INDEX, *ZIP_LAYERS)
# 보이는 도엽 수 — 이하 = 다 · 넘고 INDEX_MAX 이하 = 계곡선만 · 넘음 = 선 겹 숨김(목록 안 줌)
FULL_MAX, INDEX_MAX = 12, 30
SHEET_NO = re.compile(r"^\d{8}$")
_EMPTY = {"type": "FeatureCollection", "features": []}
Box = tuple[float, float, float, float]
def sheets_in_box(bbox: Box) -> tuple[int, list[str]]:
"""경위도 상자에 닿는 도엽 (수 · 번호 — 가운데부터). 수가 `INDEX_MAX` 넘으면 번호 없음."""
lon0, lat0, lon1, lat1 = bbox
step = SHEET5K_SIZE_DEG
i0, i1 = math.floor(min(lat0, lat1) / step), math.floor(max(lat0, lat1) / step)
j0, j1 = math.floor(min(lon0, lon1) / step), math.floor(max(lon0, lon1) / step)
count = (i1 - i0 + 1) * (j1 - j0 + 1)
if count > INDEX_MAX:
return count, []
mid_lat, mid_lon = (lat0 + lat1) / 2, (lon0 + lon1) / 2
cells = [(i, j) for i in range(i0, i1 + 1) for j in range(j0, j1 + 1)]
cells.sort(
key=lambda c: ((c[0] + 0.5) * step - mid_lat) ** 2 + ((c[1] + 0.5) * step - mid_lon) ** 2
)
return count, [latlon_to_sheet5k((i + 0.5) * step, (j + 0.5) * step) for i, j in cells]
def _gpkg_tag() -> str | None:
try:
stat = NATIONAL_GPKG.stat()
except OSError:
return None
return hashlib.sha1(f"{stat.st_size}:{stat.st_mtime_ns}".encode()).hexdigest()[:8]
def _tile_file(folder: Path, name: str) -> Path | None:
"""지금 판의 조각 파일 자리 — 등고 둘은 전국 파일 지문을 이름에(바뀌면 다시) ·
전국 파일 없는 PC 는 있는 조각을 믿음 · 그것도 없으면 None(zip 길)."""
layers = folder / "layers"
if name not in (CONTOUR, INDEX):
return layers / f"{name}_v{TILE_VERSION}.geojson"
tag = _gpkg_tag()
if tag:
return layers / f"{name}_v{TILE_VERSION}_{tag}.geojson"
found = sorted(layers.glob(f"{name}_v{TILE_VERSION}_*.geojson"))
return found[-1] if found else layers / f"{name}_v{TILE_VERSION}_zip.geojson"
def _write(path: Path, collection: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
part = path.with_suffix(".part")
text = json.dumps(
round_floats(collection, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":")
)
part.write_text(text, encoding="utf-8")
part.replace(path)
def _clipped(frame: Any, rect: Box) -> dict[str, Any]:
"""경위도 GeoDataFrame → 도곽으로 자른 FeatureCollection(빈 형상 뺌 · 날짜는 글로)."""
import shapely
frame = frame.copy()
frame["geometry"] = shapely.clip_by_rect(frame.geometry.values, *rect)
frame = frame[~frame.geometry.is_empty]
if frame.empty:
return dict(_EMPTY)
return json.loads(frame.to_json(drop_id=True, default=str))
def _project_zips(sheet_no: str) -> list[Path]:
"""옮겨 담을 후보 — 공용 지형 창고(607) · 프로젝트 `map_sheets`(새것부터)."""
found = list(
Path(STORAGE_BASE_DIR).glob(f"*/*/*/B04_PreProcess/processed/map_sheets/{sheet_no}.zip")
)
found.sort(key=lambda p: p.stat().st_mtime, reverse=True)
terrain = TERRAIN_ROOT / sheet_no / f"{sheet_no}.zip"
return ([terrain] if terrain.is_file() else []) + found
def ensure_zip(sheet_no: str) -> Path | None:
"""창고 도엽 zip — 없으면 옮겨 담기 → 받기. 빈 도엽(검색 0) = None · 받기 실패 = 오류."""
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import (
ensure_sheets,
get_sheet_path,
ingest_zip,
)
folder = SHEETS_ROOT / sheet_no
path = get_sheet_path(folder, sheet_no)
if path is not None:
return path
with _Lock(folder / ".zip.lock"):
path = get_sheet_path(folder, sheet_no)
if path is not None:
return path
for source in _project_zips(sheet_no):
try:
ingest_zip(folder, source, sheet_no)
logger.info("도엽 조각 zip 옮겨 담음 %s ← %s", sheet_no, source)
return get_sheet_path(folder, sheet_no)
except Exception as error: # noqa: BLE001 — 깨진 사본이면 다음 후보
logger.warning("도엽 zip 옮겨 담기 실패 %s (%s): %s", sheet_no, source, error)
result = ensure_sheets(folder, [sheet_no])
if sheet_no in result["available"]:
return Path(result["available"][sheet_no])
reason = result["failed"].get(sheet_no, "")
if reason.startswith("LookupError"):
return None
raise RuntimeError(f"도엽 {sheet_no} 받기 실패: {reason}")
def _zip_layer(sheet_no: str, name: str) -> Any | None:
"""창고 zip 의 겹 하나 → 경위도 GeoDataFrame(없음 = None)."""
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS, _read_layer_from_zip
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import _load_index
path = ensure_zip(sheet_no)
if path is None:
return None
entry = _load_index(SHEETS_ROOT / sheet_no)["sheets"][sheet_no]
prefix, code = SHEET_LAYERS[name]
return _read_layer_from_zip(path, prefix, code, entry["crs"])
def _gpkg_contours(rect: Box) -> Any | None:
"""전국 등고선에서 도곽 상자에 닿는 선 → 경위도 GeoDataFrame(`등고수치`) · 없음 = None."""
if not NATIONAL_GPKG.is_file():
return None
import pyogrio
lon0, lat0, lon1, lat1 = rect
t = np.linspace(0.0, 1.0, 11)
lon = np.concatenate(
[lon0 + (lon1 - lon0) * t, lon0 + (lon1 - lon0) * t, np.full(11, lon0), np.full(11, lon1)]
)
lat = np.concatenate(
[np.full(11, lat0), np.full(11, lat1), lat0 + (lat1 - lat0) * t, lat0 + (lat1 - lat0) * t]
)
x, y = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True).transform(lon, lat)
frame = pyogrio.read_dataframe(
NATIONAL_GPKG,
bbox=(x.min() - 5, y.min() - 5, x.max() + 5, y.max() + 5),
columns=["CTRLN_HG"],
)
if frame.empty:
return None
frame = frame.rename(columns={"CTRLN_HG": "등고수치"}).to_crs("EPSG:4326")
return frame[["등고수치", "geometry"]]
def _build_contours(sheet_no: str, rect: Box) -> None:
frame = _gpkg_contours(rect)
if frame is None:
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ELEVATION_KEYS
frame = _zip_layer(sheet_no, CONTOUR)
column = next((k for k in ELEVATION_KEYS if frame is not None and k in frame.columns), None)
frame = (
None
if column is None
else frame.rename(columns={column: "등고수치"})[["등고수치", "geometry"]]
)
full = dict(_EMPTY) if frame is None else _clipped(frame, rect)
index = {
**full,
"features": [
f
for f in full["features"]
if float(f["properties"].get("등고수치") or 0) % INDEX_STEP_M == 0
],
}
folder = SHEETS_ROOT / sheet_no
_write(_tile_file(folder, INDEX), index) # 계곡선 먼저 — 등고선 파일이 있으면 둘 다 있음
_write(_tile_file(folder, CONTOUR), full)
def ensure_tile(sheet_no: str, name: str) -> Path:
"""도엽 조각 파일 — 있으면 그대로 · 없으면 만듦(받기 실패 = RuntimeError · 저장 안 함)."""
if not SHEET_NO.match(sheet_no) or name not in TILE_NAMES:
raise KeyError(f"{sheet_no}/{name}")
folder = SHEETS_ROOT / sheet_no
path = _tile_file(folder, name)
if path.is_file():
return path
with _Lock(folder / f".{'contour' if name in (CONTOUR, INDEX) else name}.lock"):
path = _tile_file(folder, name)
if path.is_file():
return path
lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet_no)
rect = (lon0, lat0, lon1, lat1)
if name in (CONTOUR, INDEX):
_build_contours(sheet_no, rect)
path = _tile_file(folder, name)
else:
frame = _zip_layer(sheet_no, name)
_write(path, dict(_EMPTY) if frame is None else _clipped(frame, rect))
return path
def collect_project_zips() -> dict[str, int]:
"""한 번 — 프로젝트마다 받아 둔 도엽 zip 을 공용 창고로 복사(원본 안 지움 · 있으면 건너뜀)."""
nos = {
p.stem
for p in Path(STORAGE_BASE_DIR).glob("*/*/*/B04_PreProcess/processed/map_sheets/*.zip")
}
moved = failed = 0
for no in sorted(n for n in nos if SHEET_NO.match(n)):
try:
moved += ensure_zip(no) is not None
except Exception as error: # noqa: BLE001
failed += 1
logger.warning("도엽 zip 모으기 실패 %s: %s", no, error)
return {"sheets": len(nos), "stored": moved, "failed": failed}