revert(B04): 도엽 표시용 사본 철회 — 도엽 산출물을 원본 그대로 전송

배수유역이 어디까지 뻗을지 알 수 없어 도엽 자료를 잘라 두 벌로 나누지 않는다
(2026-08-01 사용자 지시). 분석 경로는 원래부터 원본을 읽었고 사본은 전송용이었으나,
사본 자체를 없앤다.

- B04_wf1_Surface_Engine_SheetView.py 삭제, SHEET_VIEW_* 설정 제거
- /geojson의 도엽 레이어는 원본 파일 그대로 + ETag로 전송
  (재직렬화 제거만으로 요청당 2.0초 -> 0.006초, 두 번째 요청부터 304)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 11:57:12 +09:00
co-authored by Claude Opus 5
parent 07a2cf1393
commit d033e58880
3 changed files with 3 additions and 180 deletions
@@ -1,165 +0,0 @@
# B04_wf1_Surface_Engine_SheetView.py
# 도엽 병합본의 화면 표시용 사본 생성.
#
# 배경(2026-08-01 실측): 9매 병합 등고선(도엽_등고선.geojson)은 37.3MB다. 도엽 zip 원본은
# 9매 합쳐 8.9MB지만, 등고선 레이어만 뽑아 비압축 텍스트로 펼치면서 커진다
# (정점 815,471개 × 좌표 소수 14자리). 화면은 프로젝트 주변만 보므로 그만큼 보낼 이유가 없다.
#
# 분석용 원본은 건드리지 않는다 — 배수유역 계산은 상류 유역까지 필요해 넓은 범위를 쓴다.
# 여기서는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 별도 사본을 만들어 전송에만 쓴다.
from __future__ import annotations
import json
import logging
import math
from pathlib import Path
from typing import Any
from config.config_system import (
SHEET_VIEW_COORD_DECIMALS,
SHEET_VIEW_MARGIN_M,
SHEET_VIEW_SUFFIX,
)
logger = logging.getLogger(__name__)
_METERS_PER_LAT_DEGREE = 111_320.0
def view_path_for(source_path: Path) -> Path:
"""원본 geojson 경로에 대응하는 표시용 사본 경로."""
return source_path.with_name(f"{source_path.stem}{SHEET_VIEW_SUFFIX}{source_path.suffix}")
def _crop_box(processed_dir: Path) -> tuple[float, float, float, float] | None:
"""표시 범위(경도/위도 최소·최대). VWorld 메타의 프로젝트 범위 + 여유폭."""
meta_path = processed_dir / "vworld_white_meta.json"
if not meta_path.is_file():
meta_path = processed_dir / "vworld_satellite_meta.json"
if not meta_path.is_file():
return None
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
lon_min = float(meta["lon_min"])
lon_max = float(meta["lon_max"])
lat_min = float(meta["lat_min"])
lat_max = float(meta["lat_max"])
except (OSError, ValueError, KeyError) as exc:
logger.warning("도엽 표시본: 메타 범위 읽기 실패 (%s)", exc)
return None
lat_margin = SHEET_VIEW_MARGIN_M / _METERS_PER_LAT_DEGREE
center_lat_rad = math.radians((lat_min + lat_max) / 2.0)
lon_scale = max(math.cos(center_lat_rad), 0.1)
lon_margin = SHEET_VIEW_MARGIN_M / (_METERS_PER_LAT_DEGREE * lon_scale)
return (
lon_min - lon_margin,
lat_min - lat_margin,
lon_max + lon_margin,
lat_max + lat_margin,
)
def _round_coordinates(value: Any) -> Any:
"""좌표 배열을 재귀적으로 훑어 소수 자릿수를 줄인다(고도값 포함).
shapely는 좌표를 튜플로 돌려주므로 list/tuple 둘 다 받는다.
"""
if isinstance(value, (list, tuple)):
if value and isinstance(value[0], (int, float)):
return [round(float(number), SHEET_VIEW_COORD_DECIMALS) for number in value]
return [_round_coordinates(item) for item in value]
return value
def _feature_bbox(coordinates: Any) -> tuple[float, float, float, float] | None:
"""지오메트리 좌표 전체를 훑어 bbox를 구한다."""
lons: list[float] = []
lats: list[float] = []
def walk(node: Any) -> None:
if isinstance(node, list):
if node and isinstance(node[0], (int, float)):
lons.append(float(node[0]))
lats.append(float(node[1]))
return
for item in node:
walk(item)
walk(coordinates)
if not lons:
return None
return (min(lons), min(lats), max(lons), max(lats))
def build_sheet_view(source_path: Path, processed_dir: Path) -> Path | None:
"""도엽 병합본에서 표시용 사본을 만들어 경로를 반환한다.
- 표시 범위(프로젝트 + 여유폭)와 겹치는 지물만 남긴다(경계에서 자르지 않고 통째로 유지).
- 좌표 소수 자릿수를 줄이고 공백 없는 JSON으로 기록한다.
- 이미 최신 사본이 있으면 그대로 돌려준다(원본보다 오래되면 다시 만든다).
- 표시 범위를 알 수 없으면 None — 호출측이 원본을 그대로 보내면 된다.
"""
view_path = view_path_for(source_path)
if view_path.is_file() and view_path.stat().st_mtime >= source_path.stat().st_mtime:
return view_path
box = _crop_box(processed_dir)
if box is None:
return None
min_lon, min_lat, max_lon, max_lat = box
try:
data = json.loads(source_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("도엽 표시본: 원본 읽기 실패 %s (%s)", source_path.name, exc)
return None
from shapely.geometry import box, mapping, shape
crop_area = box(min_lon, min_lat, max_lon, max_lat)
kept: list[dict[str, Any]] = []
for feature in data.get("features") or []:
geometry = feature.get("geometry") or {}
coordinates = geometry.get("coordinates")
if coordinates is None:
continue
bbox = _feature_bbox(coordinates)
if bbox is None:
continue
if bbox[0] > max_lon or bbox[2] < min_lon or bbox[1] > max_lat or bbox[3] < min_lat:
continue
# 등고선 한 가닥은 도엽 끝까지 이어진다. 겹친다고 통째로 남기면 화면 밖 구간까지
# 보내게 되므로 표시 범위에서 잘라낸다(고도 등 속성은 그대로 유지).
try:
clipped = shape(geometry).intersection(crop_area)
except Exception:
continue
if clipped.is_empty:
continue
clipped_geometry = mapping(clipped)
feature["geometry"] = {
"type": clipped_geometry["type"],
"coordinates": _round_coordinates(clipped_geometry["coordinates"]),
}
kept.append(feature)
payload = {"type": "FeatureCollection", "features": kept}
if data.get("crs"):
payload["crs"] = data["crs"]
tmp_path = view_path.with_suffix(".tmp")
tmp_path.write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8"
)
tmp_path.replace(view_path)
logger.info(
"도엽 표시본 생성: %s %d건 → %s (%.1fMB → %.1fMB)",
source_path.name,
len(kept),
view_path.name,
source_path.stat().st_size / 1e6,
view_path.stat().st_size / 1e6,
)
return view_path
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetView import build_sheet_view
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -149,12 +148,9 @@ async def get_project_geojson(
)
if layer in _SHEET_GEOJSON_FILES:
view_path = await asyncio.to_thread(build_sheet_view, filepath, target_dir)
return cached_file_response(
request,
view_path or filepath,
"application/geo+json",
)
# 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시).
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
return cached_file_response(request, filepath, "application/geo+json")
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
-8
View File
@@ -516,14 +516,6 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto
MAP_SHEETS_DIRNAME = "map_sheets"
MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json"
# 도엽 병합본(도엽_*.geojson)의 화면 표시용 사본 — 분석용 원본은 그대로 두고,
# 브라우저로 보낼 때만 프로젝트 주변으로 잘라 좌표 자릿수를 줄인 사본을 쓴다.
# (9매 병합 등고선 원본 37MB → 표시용 1MB 안팎, 2026-08-01)
SHEET_VIEW_SUFFIX = "_view"
SHEET_VIEW_MARGIN_M = 500.0
# 소수 7자리 ≈ 1cm — 1:5,000 도엽 표시 정밀도에 충분하다.
SHEET_VIEW_COORD_DECIMALS = 7
# 배경 지도(위성·하이브리드·백지도) 내려받을 범위의 여유폭.
# 라이다 범위와 계획노선을 합친 뒤 이만큼 넓혀서 받는다 — 계획노선이 라이다 범위를 벗어나도
# 배경이 잘리지 않게 한다. 브이월드 위성사진은 더 높은 해상도를 주지 않으므로 도엽만큼