perf(509-1 · 509-3 · 509-6 도엽 등고선 범위 · 화면 밖 건너뜀): 노선 편집 = 노선 띠(저장 노선 bbox + 300 + 여유 300 m)로 자른 도엽 · 배수유역도 = 세부 유역 ∪ 줌아웃 한계 + 300 m 로 따로 자른 도엽(서버 _derived/sheet_<범위> · 사본 지문 + 범위 열쇠) · 띠가 범위 밖인 초안 = 통째 사본 · 미리 받기도 범위본 · 공용 선 그리기 = 같은 쪽 화면 밖 구간 끊음(돌린 화면 = 반 대각선 넓힘) — 요구 317
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -326,14 +326,23 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심
|
|||||||
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
|
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
|
||||||
*
|
*
|
||||||
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
||||||
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */
|
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다.
|
||||||
export async function fetchCachedSheetLayer<T>(projectId: string, layer: string): Promise<T> {
|
* `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6)
|
||||||
|
* — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */
|
||||||
|
export async function fetchCachedSheetLayer<T>(
|
||||||
|
projectId: string,
|
||||||
|
layer: string,
|
||||||
|
scope?: SheetScope,
|
||||||
|
): Promise<T> {
|
||||||
|
const range = scope ? `&scope=${scope}` : "";
|
||||||
return fetchCachedJson<T>(
|
return fetchCachedJson<T>(
|
||||||
projectId,
|
projectId,
|
||||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`,
|
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SheetScope = "route" | "drainage";
|
||||||
|
|
||||||
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
|
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
|
||||||
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
|
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
|
||||||
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
|
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
|
||||||
@@ -439,7 +448,11 @@ export async function preloadSurfaceAssets(
|
|||||||
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
|
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
|
||||||
report("배경 지도를 준비하는 중…", null);
|
report("배경 지도를 준비하는 중…", null);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)),
|
DRAINAGE_SHEET_LAYERS.flatMap((layer) =>
|
||||||
|
(["route", "drainage"] as const).map((scope) =>
|
||||||
|
fetchCachedSheetLayer(projectId, layer, scope).catch(() => null),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
||||||
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""도엽 표시본 잘라 두기 — 평면도 = 노선 띠 · 배수유역도 = 유역 + 여유(509-1 · 509-6 · 서버 단독 ③).
|
||||||
|
|
||||||
|
도엽 사본은 약 6.7×8.3 km 라 노선 편집 · 배수유역도가 통째로 받으면 그릴 꼭짓점의 9 할이
|
||||||
|
화면에 안 드는 헛일이다(요구 316). 화면마다 **보는 범위**만 잘라 보낸다 — 형상은 그대로 ·
|
||||||
|
범위 밖만 사라짐. 분석(배수유역 · 관 자리)은 원본 도엽을 읽으므로 이 사본과 상관없다.
|
||||||
|
|
||||||
|
- 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠)
|
||||||
|
- `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로
|
||||||
|
나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정).
|
||||||
|
- `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2)
|
||||||
|
+ `DRAIN_SPARE_M`.
|
||||||
|
- 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위)
|
||||||
|
· 지워도 다시 셈 · 범위를 못 정하면 사본 그대로.
|
||||||
|
- 경위도 ↔ m 는 화면과 같은 가정(위성 메타의 m 사각형 = 경위도 사각형 · `metricToScreen`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shapely import clip_by_rect
|
||||||
|
from shapely.geometry import mapping, shape
|
||||||
|
|
||||||
|
from common_util.common_util_derived_store import (
|
||||||
|
derived_dir,
|
||||||
|
derived_key,
|
||||||
|
file_stamp,
|
||||||
|
prune_derived,
|
||||||
|
)
|
||||||
|
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||||
|
from common_util.common_util_route_geometry import (
|
||||||
|
expected_route_csv_path,
|
||||||
|
planned_route_initial_path,
|
||||||
|
planned_route_working_path,
|
||||||
|
read_planned_route_csv,
|
||||||
|
)
|
||||||
|
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SCOPES = ("route", "drainage")
|
||||||
|
CLIP_VERSION = 1
|
||||||
|
ROUTE_BAND_M = 300.0 # = B05 `CONTOUR_BAND_M`(등고선을 보일 노선 둘레 띠)
|
||||||
|
ROUTE_SPARE_M = 300.0 # 띠 밖 여유 — 노선을 이만큼 옮겨도 다시 안 받음
|
||||||
|
DRAIN_SPARE_M = 300.0 # 유역 · 줌아웃 한계 밖 여유 — 화면 가로세로 비 · 테두리
|
||||||
|
ZOOM_OUT_ROUTE_FACTOR = 2.0 # = B05 `ZOOM_OUT_ROUTE_FACTOR`(노선 범위 몇 배까지 멀리 보나)
|
||||||
|
SNAP_M = 50.0
|
||||||
|
|
||||||
|
Rect = tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_meta(processed: Path) -> dict[str, Any] | None:
|
||||||
|
for name in ("vworld_satellite_meta.json", "vworld_meta.json"):
|
||||||
|
try:
|
||||||
|
meta = json.loads((processed / name).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
keys = ("x_min", "y_min", "width_meters", "height_meters")
|
||||||
|
if all(
|
||||||
|
meta.get(k) is not None for k in keys + ("lon_min", "lon_max", "lat_min", "lat_max")
|
||||||
|
):
|
||||||
|
return meta
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_lonlat(meta: dict[str, Any], x: float, y: float) -> tuple[float, float]:
|
||||||
|
lon = meta["lon_min"] + (x - meta["x_min"]) / (meta["width_meters"] or 1) * (
|
||||||
|
meta["lon_max"] - meta["lon_min"]
|
||||||
|
)
|
||||||
|
lat = meta["lat_min"] + (y - meta["y_min"]) / (meta["height_meters"] or 1) * (
|
||||||
|
meta["lat_max"] - meta["lat_min"]
|
||||||
|
)
|
||||||
|
return lon, lat
|
||||||
|
|
||||||
|
|
||||||
|
def _to_metric(meta: dict[str, Any], lon: float, lat: float) -> tuple[float, float]:
|
||||||
|
x = (
|
||||||
|
meta["x_min"]
|
||||||
|
+ (lon - meta["lon_min"])
|
||||||
|
/ ((meta["lon_max"] - meta["lon_min"]) or 1)
|
||||||
|
* (meta["width_meters"])
|
||||||
|
)
|
||||||
|
y = (
|
||||||
|
meta["y_min"]
|
||||||
|
+ (lat - meta["lat_min"])
|
||||||
|
/ ((meta["lat_max"] - meta["lat_min"]) or 1)
|
||||||
|
* (meta["height_meters"])
|
||||||
|
)
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds(points: list[tuple[float, float]]) -> Rect | None:
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
xs = [p[0] for p in points]
|
||||||
|
ys = [p[1] for p in points]
|
||||||
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
|
||||||
|
def _grow(rect: Rect, dx: float, dy: float | None = None) -> Rect:
|
||||||
|
dy = dx if dy is None else dy
|
||||||
|
return rect[0] - dx, rect[1] - dy, rect[2] + dx, rect[3] + dy
|
||||||
|
|
||||||
|
|
||||||
|
def _union(a: Rect, b: Rect | None) -> Rect:
|
||||||
|
if b is None:
|
||||||
|
return a
|
||||||
|
return min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])
|
||||||
|
|
||||||
|
|
||||||
|
def _route_points(project_root: Path) -> list[tuple[float, float]]:
|
||||||
|
"""저장 노선(`read_route_plan` planned 와 같은 차례 — 수정본 · 초기 · 예상노선)."""
|
||||||
|
for path in (
|
||||||
|
planned_route_working_path(project_root),
|
||||||
|
planned_route_initial_path(project_root),
|
||||||
|
expected_route_csv_path(project_root),
|
||||||
|
):
|
||||||
|
route = read_planned_route_csv(path) if path.is_file() else None
|
||||||
|
if route is not None and len(route.vertices) >= 2:
|
||||||
|
return [(float(v.x), float(v.y)) for v in route.vertices]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float, float]]:
|
||||||
|
"""세부 유역 꼭짓점(m) — 관마다 모이는 유역(B05 배수유역도가 그리는 것)."""
|
||||||
|
path = project_root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME
|
||||||
|
try:
|
||||||
|
features = json.loads(path.read_text(encoding="utf-8")).get("features") or []
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
points: list[tuple[float, float]] = []
|
||||||
|
for feature in features:
|
||||||
|
try:
|
||||||
|
lon0, lat0, lon1, lat1 = shape(feature["geometry"]).bounds
|
||||||
|
except Exception: # 깨진 피처 하나가 범위를 막지 않음
|
||||||
|
continue
|
||||||
|
points += [_to_metric(meta, lon0, lat0), _to_metric(meta, lon1, lat1)]
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None:
|
||||||
|
"""범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None."""
|
||||||
|
route = _bounds(_route_points(project_root))
|
||||||
|
if route is None:
|
||||||
|
return None
|
||||||
|
if scope == "route":
|
||||||
|
rect = _grow(route, ROUTE_BAND_M + ROUTE_SPARE_M)
|
||||||
|
else:
|
||||||
|
basins = _bounds(_basin_points(project_root, meta))
|
||||||
|
if basins is None:
|
||||||
|
return None
|
||||||
|
half = (ZOOM_OUT_ROUTE_FACTOR - 1) / 2
|
||||||
|
zoom_out = _grow(route, (route[2] - route[0]) * half, (route[3] - route[1]) * half)
|
||||||
|
rect = _grow(_union(zoom_out, basins), DRAIN_SPARE_M)
|
||||||
|
steps = [round(value / SNAP_M, 6) for value in rect] # 경위도 되셈의 끝자리 흔들림 걷음
|
||||||
|
return (
|
||||||
|
math.floor(steps[0]) * SNAP_M,
|
||||||
|
math.floor(steps[1]) * SNAP_M,
|
||||||
|
math.ceil(steps[2]) * SNAP_M,
|
||||||
|
math.ceil(steps[3]) * SNAP_M,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coord_bounds(coords: Any) -> Rect:
|
||||||
|
"""GeoJSON 좌표(중첩 목록)의 bbox — shapely 를 만들기 전 거르기."""
|
||||||
|
stack = [coords]
|
||||||
|
x0 = y0 = math.inf
|
||||||
|
x1 = y1 = -math.inf
|
||||||
|
while stack:
|
||||||
|
item = stack.pop()
|
||||||
|
if item and isinstance(item[0], (int, float)):
|
||||||
|
x, y = item[0], item[1]
|
||||||
|
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
|
||||||
|
else:
|
||||||
|
stack.extend(item)
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def clip_collection(collection: dict[str, Any], rect: Rect) -> dict[str, Any]:
|
||||||
|
"""사각형(경위도) 밖을 걷은 컬렉션. 안에 다 든 피처는 그대로 · 걸친 것만 잘라 냄."""
|
||||||
|
west, south, east, north = rect
|
||||||
|
features = []
|
||||||
|
for feature in collection.get("features") or []:
|
||||||
|
geometry = feature.get("geometry")
|
||||||
|
if not geometry or not geometry.get("coordinates"):
|
||||||
|
continue
|
||||||
|
x0, y0, x1, y1 = _coord_bounds(geometry["coordinates"])
|
||||||
|
if x1 < west or x0 > east or y1 < south or y0 > north:
|
||||||
|
continue
|
||||||
|
if not (x0 >= west and x1 <= east and y0 >= south and y1 <= north):
|
||||||
|
cut = clip_by_rect(shape(geometry), *rect) # 점은 bbox 로 이미 갈림
|
||||||
|
if cut.is_empty:
|
||||||
|
continue
|
||||||
|
geometry = round_floats(mapping(cut), LONLAT_DIGITS)
|
||||||
|
features.append({**feature, "geometry": geometry})
|
||||||
|
return {**collection, "features": features, "clip_lonlat": [round(v, 7) for v in rect]}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_compact(path: Path, value: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(handle, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(value, file, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
os.replace(temporary, path)
|
||||||
|
except BaseException:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path:
|
||||||
|
"""`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로."""
|
||||||
|
try:
|
||||||
|
meta = _read_meta(project_root / "B04_PreProcess" / "processed")
|
||||||
|
rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None
|
||||||
|
if rect_m is None or meta is None:
|
||||||
|
return source
|
||||||
|
west, south = _to_lonlat(meta, rect_m[0], rect_m[1])
|
||||||
|
east, north = _to_lonlat(meta, rect_m[2], rect_m[3])
|
||||||
|
kind = f"sheet_{scope}"
|
||||||
|
key = derived_key(CLIP_VERSION, source.name, file_stamp(source), rect_m)
|
||||||
|
target = derived_dir(project_root, kind) / f"{key}.json"
|
||||||
|
if target.is_file():
|
||||||
|
return target
|
||||||
|
collection = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
_write_compact(target, clip_collection(collection, (west, south, east, north)))
|
||||||
|
prune_derived(project_root, kind, keep=4) # 레이어 둘 × 두 벌
|
||||||
|
logger.info(
|
||||||
|
"도엽 %s 범위 사본: %s (%.1fMB → %.1fMB)",
|
||||||
|
scope,
|
||||||
|
source.name,
|
||||||
|
source.stat().st_size / 1e6,
|
||||||
|
target.stat().st_size / 1e6,
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
except Exception as exc: # 자르기 실패가 화면을 막으면 안 됨 — 통째 사본
|
||||||
|
logger.warning("도엽 %s 범위 사본 실패 (%s): %s", scope, source.name, exc)
|
||||||
|
return source
|
||||||
@@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
|
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
|
||||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||||
|
|
||||||
|
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402
|
||||||
|
|
||||||
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
||||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
||||||
|
|
||||||
@@ -96,12 +98,13 @@ async def get_vworld_map(
|
|||||||
# GeoJSON 조회 API
|
# GeoJSON 조회 API
|
||||||
@router.get("/{project_id}/geojson", response_model=None)
|
@router.get("/{project_id}/geojson", response_model=None)
|
||||||
async def get_project_geojson(
|
async def get_project_geojson(
|
||||||
project_id: UUID, layer: str, request: Request
|
project_id: UUID, layer: str, request: Request, scope: str | None = None
|
||||||
) -> dict[str, Any] | Response | JSONResponse:
|
) -> dict[str, Any] | Response | JSONResponse:
|
||||||
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
|
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
|
||||||
|
|
||||||
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
|
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
|
||||||
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
|
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
|
||||||
|
`scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6).
|
||||||
"""
|
"""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
try:
|
||||||
@@ -153,11 +156,10 @@ async def get_project_geojson(
|
|||||||
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
|
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
|
||||||
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
|
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
|
||||||
# 그대로고 뜻 없는 자리만 사라진다.
|
# 그대로고 뜻 없는 자리만 사라진다.
|
||||||
return cached_file_response(
|
display = await asyncio.to_thread(_display_copy, filepath)
|
||||||
request,
|
if scope:
|
||||||
await asyncio.to_thread(_display_copy, filepath),
|
display = await asyncio.to_thread(clipped_sheet, project_root, display, scope)
|
||||||
"application/geo+json",
|
return cached_file_response(request, display, "application/geo+json")
|
||||||
)
|
|
||||||
|
|
||||||
if layer == "등고선":
|
if layer == "등고선":
|
||||||
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
|
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function drawPreparedFeature(
|
|||||||
): void {
|
): void {
|
||||||
const feature = layer.features[index];
|
const feature = layer.features[index];
|
||||||
if (!feature || feature.kind !== "line") return;
|
if (!feature || feature.kind !== "line") return;
|
||||||
drawLineParts(context, feature, affineOf(view));
|
drawLineParts(context, feature, affineOf(view), view, Infinity); // 몇 가닥뿐 — 안 거름
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 점과 선분 사이 거리(px). */
|
/** 점과 선분 사이 거리(px). */
|
||||||
@@ -358,9 +358,19 @@ function drawLineParts(
|
|||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
feature: PreparedFeature,
|
feature: PreparedFeature,
|
||||||
affine: Affine,
|
affine: Affine,
|
||||||
|
view: ViewState,
|
||||||
|
pad: number,
|
||||||
): void {
|
): void {
|
||||||
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
|
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
|
||||||
const tolerance = LOD_PX / affine.ax;
|
const tolerance = LOD_PX / affine.ax;
|
||||||
|
// 화면 밖 구간 건너뛰기(509-3) — 두 끝이 **같은 쪽** 화면 밖이면 그 구간은 안 보임 → 선을 끊음.
|
||||||
|
// 한쪽이라도 화면 쪽이거나 서로 다른 쪽(모서리를 질러 갈 수 있음)이면 그대로 그림.
|
||||||
|
const left = -CULL_MARGIN - pad;
|
||||||
|
const top = -CULL_MARGIN - pad;
|
||||||
|
const right = view.width + CULL_MARGIN + pad;
|
||||||
|
const bottom = view.height + CULL_MARGIN + pad;
|
||||||
|
const sideOf = (x: number, y: number): number =>
|
||||||
|
(x < left ? 1 : x > right ? 2 : 0) | (y < top ? 4 : y > bottom ? 8 : 0);
|
||||||
for (const part of feature.parts) {
|
for (const part of feature.parts) {
|
||||||
const coords = part.coords;
|
const coords = part.coords;
|
||||||
if (coords.length < 4) continue;
|
if (coords.length < 4) continue;
|
||||||
@@ -368,19 +378,51 @@ function drawLineParts(
|
|||||||
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
||||||
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
let started = false;
|
let kept = 0;
|
||||||
|
let moves = 0;
|
||||||
|
let fromFirst = false;
|
||||||
|
let penDown = false;
|
||||||
|
let firstX = 0;
|
||||||
|
let firstY = 0;
|
||||||
|
let firstSide = 0;
|
||||||
|
let prevX = 0;
|
||||||
|
let prevY = 0;
|
||||||
|
let prevSide = 0;
|
||||||
for (let i = 0; i < coords.length; i += 2) {
|
for (let i = 0; i < coords.length; i += 2) {
|
||||||
if (weights && weights[i / 2] < tolerance) continue;
|
if (weights && weights[i / 2] < tolerance) continue;
|
||||||
const x = coords[i] * affine.ax + affine.bx;
|
const x = coords[i] * affine.ax + affine.bx;
|
||||||
const y = coords[i + 1] * affine.ay + affine.by;
|
const y = coords[i + 1] * affine.ay + affine.by;
|
||||||
if (started) context.lineTo(x, y);
|
const side = sideOf(x, y);
|
||||||
else {
|
if (kept === 0) {
|
||||||
context.moveTo(x, y);
|
firstX = x;
|
||||||
started = true;
|
firstY = y;
|
||||||
|
firstSide = side;
|
||||||
|
} else if (side & prevSide) {
|
||||||
|
penDown = false;
|
||||||
|
} else {
|
||||||
|
if (!penDown) {
|
||||||
|
context.moveTo(prevX, prevY);
|
||||||
|
if (moves === 0) fromFirst = kept === 1;
|
||||||
|
moves += 1;
|
||||||
|
}
|
||||||
|
context.lineTo(x, y);
|
||||||
|
penDown = true;
|
||||||
|
}
|
||||||
|
kept += 1;
|
||||||
|
prevX = x;
|
||||||
|
prevY = y;
|
||||||
|
prevSide = side;
|
||||||
|
}
|
||||||
|
if (part.closed && kept > 2) {
|
||||||
|
// 한 줄로 이어졌으면 closePath(모서리 이음 그대로) · 끊겼으면 닫는 구간만 따로.
|
||||||
|
if (moves === 1 && fromFirst && penDown) context.closePath();
|
||||||
|
else if (!(prevSide & firstSide)) {
|
||||||
|
if (!penDown) context.moveTo(prevX, prevY);
|
||||||
|
context.lineTo(firstX, firstY);
|
||||||
|
moves += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!started) continue;
|
if (moves === 0) continue;
|
||||||
if (part.closed) context.closePath();
|
|
||||||
context.stroke();
|
context.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,8 +466,8 @@ const CULL_MARGIN = 32;
|
|||||||
const LABEL_GAP_PX = 10;
|
const LABEL_GAP_PX = 10;
|
||||||
const LABEL_ROW_PX = 14;
|
const LABEL_ROW_PX = 14;
|
||||||
|
|
||||||
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
|
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState, pad = 0): boolean {
|
||||||
const margin = CULL_MARGIN;
|
const margin = CULL_MARGIN + pad;
|
||||||
const minX = feature.minX * affine.ax + affine.bx;
|
const minX = feature.minX * affine.ax + affine.bx;
|
||||||
const maxX = feature.maxX * affine.ax + affine.bx;
|
const maxX = feature.maxX * affine.ax + affine.bx;
|
||||||
const minY = feature.minY * affine.ay + affine.by;
|
const minY = feature.minY * affine.ay + affine.by;
|
||||||
@@ -506,21 +548,25 @@ export function pickLevelStep(
|
|||||||
*
|
*
|
||||||
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
|
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
|
||||||
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
|
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
|
||||||
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
|
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다.
|
||||||
|
*
|
||||||
|
* `padPx` = 화면 밖 판정을 이만큼 넓힘 — 그림을 돌린 화면(B05 노선 편집)은 모서리가 화면 사각형
|
||||||
|
* 밖에서 들어오므로 반 대각선만큼 준다. */
|
||||||
export function drawPreparedLayer(
|
export function drawPreparedLayer(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
layer: PreparedLayer,
|
layer: PreparedLayer,
|
||||||
view: ViewState,
|
view: ViewState,
|
||||||
marker: MarkerKind,
|
marker: MarkerKind,
|
||||||
everyM?: number,
|
everyM?: number,
|
||||||
|
padPx = 0,
|
||||||
): void {
|
): void {
|
||||||
const affine = affineOf(view);
|
const affine = affineOf(view);
|
||||||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||||||
for (const feature of layer.features) {
|
for (const feature of layer.features) {
|
||||||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
||||||
if (!isVisible(feature, affine, view)) continue;
|
if (!isVisible(feature, affine, view, padPx)) continue;
|
||||||
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
|
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
|
||||||
else drawLineParts(context, feature, affine);
|
else drawLineParts(context, feature, affine, view, padPx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { stateKey } from "../A00_Common/b_page_state";
|
|||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import { themeColor } from "@ui/ui_template_palette";
|
import { themeColor } from "@ui/ui_template_palette";
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
import {
|
||||||
|
DRAINAGE_SHEET_LAYERS,
|
||||||
|
fetchCachedSheetLayer,
|
||||||
|
type SheetScope,
|
||||||
|
} from "../A00_Common/b_asset_cache";
|
||||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import {
|
import {
|
||||||
computeMapRect,
|
computeMapRect,
|
||||||
@@ -439,10 +443,12 @@ export function reconcilePipes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다.
|
/** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다.
|
||||||
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */
|
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다.
|
||||||
|
* `scope` = 서버가 자른 범위(기본 배수유역도 = 유역 + 여유 · 노선 편집 = "route" · null = 통째). */
|
||||||
export async function fetchDrainageLayers(
|
export async function fetchDrainageLayers(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
onProgress: (ratio: number) => void,
|
onProgress: (ratio: number) => void,
|
||||||
|
scope: SheetScope | null = "drainage",
|
||||||
): Promise<{
|
): Promise<{
|
||||||
meta: VWorldMeta;
|
meta: VWorldMeta;
|
||||||
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
|
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
|
||||||
@@ -453,7 +459,12 @@ export async function fetchDrainageLayers(
|
|||||||
DRAINAGE_LAYERS.map(async (layer) => {
|
DRAINAGE_LAYERS.map(async (layer) => {
|
||||||
try {
|
try {
|
||||||
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
||||||
return [layer, await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)] as const;
|
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||||
|
projectId,
|
||||||
|
layer,
|
||||||
|
scope ?? undefined,
|
||||||
|
);
|
||||||
|
return [layer, collection] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [layer, null] as const;
|
return [layer, null] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import {
|
import {
|
||||||
bindMinorContourToggle,
|
bindMinorContourToggle,
|
||||||
|
fetchRouteSheets,
|
||||||
loadRouteEditBackground,
|
loadRouteEditBackground,
|
||||||
type RouteEditContours,
|
type RouteEditContours,
|
||||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||||
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
||||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
|
||||||
import {
|
import {
|
||||||
analysisTerrain,
|
analysisTerrain,
|
||||||
fetchRoutePlan,
|
fetchRoutePlan,
|
||||||
@@ -601,11 +601,11 @@ export async function mountRouteEditor(
|
|||||||
host.onHide(() => (historyControls.listen(false), viewMemory.flush()));
|
host.onHide(() => (historyControls.listen(false), viewMemory.flush()));
|
||||||
host.onDispose(dispose);
|
host.onDispose(dispose);
|
||||||
|
|
||||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
// ── 자료 읽기 — 노선 두 벌 + 노선 띠로 자른 도엽(509-1) ──
|
||||||
try {
|
try {
|
||||||
const [plan, drainage, sources] = await Promise.all([
|
const [plan, drainage, sources] = await Promise.all([
|
||||||
fetchRoutePlan(projectId),
|
fetchRoutePlan(projectId),
|
||||||
fetchDrainageLayers(projectId, () => {}),
|
fetchRouteSheets(projectId, () => plannedLine),
|
||||||
fetchTerrainSources(projectId).catch(() => null),
|
fetchTerrainSources(projectId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
prepareMetricPolylines,
|
prepareMetricPolylines,
|
||||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
|
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||||
|
import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render";
|
||||||
|
|
||||||
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
|
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
|
||||||
const SHEET_ELEVATION_KEYS = ["등고수치"];
|
const SHEET_ELEVATION_KEYS = ["등고수치"];
|
||||||
@@ -102,18 +104,61 @@ export async function loadRouteEditContours(
|
|||||||
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
|
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 잘라 받은 도엽(`clip_lonlat`)이 노선 띠(bbox + `bandM`)를 덮나 — 안 잘렸으면 덮음(509-1).
|
||||||
|
* 경위도 ↔ m 는 화면 가정 그대로(메타의 m 사각형 = 경위도 사각형). */
|
||||||
|
export function sheetCoversBand(
|
||||||
|
collection: GeoJsonCollection | null,
|
||||||
|
meta: VWorldMeta,
|
||||||
|
line: ReadonlyArray<readonly [number, number]>,
|
||||||
|
bandM: number,
|
||||||
|
): boolean {
|
||||||
|
const clip = (collection as { clip_lonlat?: number[] } | null)?.clip_lonlat;
|
||||||
|
if (!clip || line.length < 2) return true;
|
||||||
|
const xs = line.map((point) => point[0]);
|
||||||
|
const ys = line.map((point) => point[1]);
|
||||||
|
const lon = (x: number): number =>
|
||||||
|
meta.lon_min + ((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min);
|
||||||
|
const lat = (y: number): number =>
|
||||||
|
meta.lat_min + ((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min);
|
||||||
|
return (
|
||||||
|
lon(Math.min(...xs) - bandM) >= clip[0] &&
|
||||||
|
lat(Math.min(...ys) - bandM) >= clip[1] &&
|
||||||
|
lon(Math.max(...xs) + bandM) <= clip[2] &&
|
||||||
|
lat(Math.max(...ys) + bandM) <= clip[3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SheetLayers = {
|
||||||
|
meta: VWorldMeta;
|
||||||
|
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
|
||||||
|
/** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */
|
||||||
|
line?: () => ReadonlyArray<readonly [number, number]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 노선 편집이 받는 도엽 — 노선 띠로 자른 것(509-1). `line` = 지금 노선(배경 준비 때 읽음). */
|
||||||
|
export async function fetchRouteSheets(
|
||||||
|
projectId: string,
|
||||||
|
line: () => ReadonlyArray<readonly [number, number]>,
|
||||||
|
): Promise<SheetLayers & { meta: VWorldMeta }> {
|
||||||
|
return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
|
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
|
||||||
* 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다.
|
* 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의
|
||||||
|
* 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다.
|
||||||
*/
|
*/
|
||||||
export async function loadRouteEditBackground(
|
export async function loadRouteEditBackground(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
drainage: {
|
clipped: SheetLayers,
|
||||||
meta: VWorldMeta;
|
|
||||||
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
|
|
||||||
},
|
|
||||||
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
|
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
|
||||||
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
|
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
|
||||||
|
// ponytail: 여는 때 · 지형 바꿀 때만 잼 — 편집 도중 여유 밖으로 끌면 다음 열 때 통째로 받음.
|
||||||
|
const drainage = clipped.layers.every(([, collection]) =>
|
||||||
|
sheetCoversBand(collection, clipped.meta, clipped.line?.() ?? [], CONTOUR_BAND_M),
|
||||||
|
)
|
||||||
|
? clipped
|
||||||
|
: await fetchDrainageLayers(projectId, () => {}, null);
|
||||||
const normalizer = createNormalizer(drainage.meta);
|
const normalizer = createNormalizer(drainage.meta);
|
||||||
const otherSheets = drainage.layers
|
const otherSheets = drainage.layers
|
||||||
.filter(([layer]) => layer !== "도엽_등고선")
|
.filter(([layer]) => layer !== "도엽_등고선")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const LOCKED_WIDTH = 4;
|
|||||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||||
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
||||||
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
||||||
const CONTOUR_BAND_M = 300;
|
export const CONTOUR_BAND_M = 300;
|
||||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||||
const CURVE_HANDLE_PX = 5;
|
const CURVE_HANDLE_PX = 5;
|
||||||
@@ -171,6 +171,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
|||||||
context.translate(-view.width / 2, -view.height / 2);
|
context.translate(-view.width / 2, -view.height / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 돌린 화면은 모서리가 화면 사각형 밖에서 들어옴 — 화면 밖 거르기를 반 대각선만큼 넓힘(509-3).
|
||||||
|
const pad = scene.rotationRad ? Math.hypot(view.width, view.height) / 2 : 0;
|
||||||
context.save();
|
context.save();
|
||||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||||
@@ -192,7 +194,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
|||||||
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
||||||
context.clip();
|
context.clip();
|
||||||
}
|
}
|
||||||
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
|
for (const layer of scene.otherSheets)
|
||||||
|
drawPreparedLayer(context, layer, view, "dot", undefined, pad);
|
||||||
context.restore();
|
context.restore();
|
||||||
if (scene.contours) {
|
if (scene.contours) {
|
||||||
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
||||||
@@ -203,12 +206,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
|||||||
context.strokeStyle =
|
context.strokeStyle =
|
||||||
style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)";
|
style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)";
|
||||||
context.lineWidth = 0.5;
|
context.lineWidth = 0.5;
|
||||||
drawPreparedLayer(context, scene.minorContours, view, "dot");
|
drawPreparedLayer(context, scene.minorContours, view, "dot", undefined, pad);
|
||||||
}
|
}
|
||||||
// 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②).
|
// 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②).
|
||||||
context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8";
|
context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8";
|
||||||
context.lineWidth = 0.8;
|
context.lineWidth = 0.8;
|
||||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
|
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad);
|
||||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||||
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
|
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
|
||||||
if (scene.pickedContour >= 0) {
|
if (scene.pickedContour >= 0) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/* 509-1 노선 편집 — 잘라 받은 도엽이 지금 노선 띠를 덮나(`sheetCoversBand`) 헬퍼.
|
||||||
|
* 무거운 import(보관함 · 그리기 · 배수유역 칸)는 빈 값으로 갈음 — 이 함수는 안 씀.
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
const STUBS = {
|
||||||
|
b_asset_cache: {},
|
||||||
|
B04_PreProcess_UI_MapRender: {},
|
||||||
|
B04_PreProcess_UI_MapRender_Prepare: {},
|
||||||
|
B05_Profile_UI_Drainage_Parts: {},
|
||||||
|
B05_Profile_UI_RouteEdit_Render: { CONTOUR_BAND_M: 300 },
|
||||||
|
};
|
||||||
|
const { load } = makeLoader({ root: ROOT, stubs: STUBS });
|
||||||
|
const { sheetCoversBand } = load("B05_Profile", "B05_Profile_UI_RouteEdit_Contour.ts");
|
||||||
|
|
||||||
|
// 서버 시험과 같은 메타 — 1 m = 0.00001 도.
|
||||||
|
const META = {
|
||||||
|
x_min: 0,
|
||||||
|
y_min: 0,
|
||||||
|
width_meters: 10000,
|
||||||
|
height_meters: 10000,
|
||||||
|
lon_min: 127,
|
||||||
|
lon_max: 127.1,
|
||||||
|
lat_min: 37,
|
||||||
|
lat_max: 37.1,
|
||||||
|
};
|
||||||
|
// 노선 (5000,5000)~(5500,5200) + 600 m → 4400 ~ 6100 · 4400 ~ 5800.
|
||||||
|
const CLIP = { clip_lonlat: [127.044, 37.044, 127.061, 37.058] };
|
||||||
|
const line = [
|
||||||
|
[5000, 5000],
|
||||||
|
[5500, 5200],
|
||||||
|
];
|
||||||
|
const shifted = (dx, dy) => line.map(([x, y]) => [x + dx, y + dy]);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
saved: sheetCoversBand(CLIP, META, line, 300),
|
||||||
|
within: sheetCoversBand(CLIP, META, shifted(250, -250), 300),
|
||||||
|
beyondEast: sheetCoversBand(CLIP, META, shifted(400, 0), 300),
|
||||||
|
beyondSouth: sheetCoversBand(CLIP, META, shifted(0, -400), 300),
|
||||||
|
noClip: sheetCoversBand({ features: [] }, META, shifted(5000, 0), 300),
|
||||||
|
noSheet: sheetCoversBand(null, META, line, 300),
|
||||||
|
noLine: sheetCoversBand(CLIP, META, [], 300),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/* 509-3 공용 선 그리기 화면 밖 구간 건너뛰기 헬퍼 — `B04_PreProcess_UI_MapRender.ts` 의
|
||||||
|
* `drawPreparedLayer` 를 가짜 캔버스로 돌려 그린 구간 · 호출 수를 JSON 으로 낸다.
|
||||||
|
* 판정 = 화면(+ 여백)에 걸치는 원래 구간이 모두 그려졌나 · 화면 밖 구간은 줄었나.
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) });
|
||||||
|
const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts");
|
||||||
|
|
||||||
|
const W = 800;
|
||||||
|
const H = 600;
|
||||||
|
const VIEW = {
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
scale: 1,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
mapRect: { x: 0, y: 0, width: W, height: H },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 그린 선분을 모으는 가짜 캔버스. */
|
||||||
|
function fakeContext() {
|
||||||
|
const calls = { moveTo: 0, lineTo: 0, stroke: 0, closePath: 0 };
|
||||||
|
const segments = [];
|
||||||
|
let start = null;
|
||||||
|
let pen = null;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
segments,
|
||||||
|
beginPath() {
|
||||||
|
start = pen = null;
|
||||||
|
},
|
||||||
|
moveTo(x, y) {
|
||||||
|
calls.moveTo += 1;
|
||||||
|
start = pen = [x, y];
|
||||||
|
},
|
||||||
|
lineTo(x, y) {
|
||||||
|
calls.lineTo += 1;
|
||||||
|
segments.push([pen, [x, y]]);
|
||||||
|
pen = [x, y];
|
||||||
|
},
|
||||||
|
closePath() {
|
||||||
|
calls.closePath += 1;
|
||||||
|
segments.push([pen, start]);
|
||||||
|
pen = start;
|
||||||
|
},
|
||||||
|
stroke() {
|
||||||
|
calls.stroke += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화면 px 점렬 → 한 피처 레이어(정규 좌표 = px / 화면 크기 · weights 없음 = 다 그림). */
|
||||||
|
function layerOf(points, closed = false) {
|
||||||
|
const coords = [];
|
||||||
|
points.forEach(([x, y]) => coords.push(x / W, y / H));
|
||||||
|
const xs = points.map((p) => p[0] / W);
|
||||||
|
const ys = points.map((p) => p[1] / H);
|
||||||
|
return {
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
kind: "line",
|
||||||
|
parts: [{ coords: Float64Array.from(coords), closed, weights: null }],
|
||||||
|
minX: Math.min(...xs),
|
||||||
|
maxX: Math.max(...xs),
|
||||||
|
minY: Math.min(...ys),
|
||||||
|
maxY: Math.max(...ys),
|
||||||
|
labelAnchorX: 0,
|
||||||
|
labelAnchorY: 0,
|
||||||
|
labelText: null,
|
||||||
|
labelValue: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선분이 사각형(여백 32)에 걸치나 — Liang–Barsky. */
|
||||||
|
function touches([a, b], m = 32) {
|
||||||
|
let t0 = 0;
|
||||||
|
let t1 = 1;
|
||||||
|
const dx = b[0] - a[0];
|
||||||
|
const dy = b[1] - a[1];
|
||||||
|
const edges = [
|
||||||
|
[-dx, a[0] + m],
|
||||||
|
[dx, W + m - a[0]],
|
||||||
|
[-dy, a[1] + m],
|
||||||
|
[dy, H + m - a[1]],
|
||||||
|
];
|
||||||
|
for (const [p, q] of edges) {
|
||||||
|
if (p === 0) {
|
||||||
|
if (q < 0) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const t = q / p;
|
||||||
|
if (p < 0) t0 = Math.max(t0, t);
|
||||||
|
else t1 = Math.min(t1, t);
|
||||||
|
if (t0 > t1) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = ([a, b]) => [a, b].map((p) => p.map((v) => v.toFixed(6)).join(",")).join(">");
|
||||||
|
|
||||||
|
function run(points, closed = false, pad = 0) {
|
||||||
|
const context = fakeContext();
|
||||||
|
render.drawPreparedLayer(context, layerOf(points, closed), VIEW, "dot", undefined, pad);
|
||||||
|
const drawn = new Set(context.segments.map(key));
|
||||||
|
const rev = new Set(context.segments.map(([a, b]) => key([b, a])));
|
||||||
|
const original = [];
|
||||||
|
for (let i = 1; i < points.length; i += 1) original.push([points[i - 1], points[i]]);
|
||||||
|
if (closed) original.push([points[points.length - 1], points[0]]);
|
||||||
|
const visible = original.filter((segment) => touches(segment));
|
||||||
|
const missing = visible.filter((s) => !drawn.has(key(s)) && !rev.has(key(s))).length;
|
||||||
|
return { calls: context.calls, drawn: context.segments.length, visible: visible.length, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 다 화면 안 — 예전과 같음(moveTo 1 · lineTo n-1 · stroke 1).
|
||||||
|
const inside = run([
|
||||||
|
[100, 100],
|
||||||
|
[200, 150],
|
||||||
|
[300, 120],
|
||||||
|
[400, 300],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2) 화면 밖 왼쪽으로 길게 돌다 돌아옴 — 밖 구간은 안 그림 · 보이는 구간 다 그림.
|
||||||
|
const wander = [[400, 300]];
|
||||||
|
for (let i = 0; i < 200; i += 1) wander.push([-100 - (i % 7) * 10, 300 + i]);
|
||||||
|
wander.push([400, 500]);
|
||||||
|
const leftLoop = run(wander);
|
||||||
|
|
||||||
|
// 3) 왼쪽 밖 → 아래 밖(모서리를 질러 감) — 쪽이 다르면 그대로 그림.
|
||||||
|
const corner = run([
|
||||||
|
[-50, 300],
|
||||||
|
[300, 700],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4) 닫힌 고리 다 안 = closePath · 일부 밖 = 닫는 구간 따로.
|
||||||
|
const ringIn = run(
|
||||||
|
[
|
||||||
|
[100, 100],
|
||||||
|
[300, 100],
|
||||||
|
[300, 300],
|
||||||
|
[100, 300],
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const ringOut = run(
|
||||||
|
[
|
||||||
|
[100, 100],
|
||||||
|
[-200, 100],
|
||||||
|
[-200, 300],
|
||||||
|
[100, 300],
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5) 다 밖(한쪽) = 안 그림(stroke 0).
|
||||||
|
const allOut = run([
|
||||||
|
[-200, 10],
|
||||||
|
[-300, 200],
|
||||||
|
[-250, 500],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6) 넓힘(pad) = 무한이면 하나도 안 거름(고른 등고선 덧그리기).
|
||||||
|
const padded = run(wander, false, Infinity);
|
||||||
|
|
||||||
|
// 7) 무작위 선 500 가닥 — 보이는 구간 빠짐 0.
|
||||||
|
let seed = 7;
|
||||||
|
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) * 2 - 0.5;
|
||||||
|
let randomMissing = 0;
|
||||||
|
let randomDrawn = 0;
|
||||||
|
let randomTotal = 0;
|
||||||
|
for (let n = 0; n < 500; n += 1) {
|
||||||
|
const points = [];
|
||||||
|
let x = rand() * W * 2;
|
||||||
|
let y = rand() * H * 2;
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
x += (rand() - 0.25) * 200;
|
||||||
|
y += (rand() - 0.25) * 200;
|
||||||
|
points.push([x, y]);
|
||||||
|
}
|
||||||
|
const result = run(points, n % 5 === 0);
|
||||||
|
randomMissing += result.missing;
|
||||||
|
randomDrawn += result.drawn;
|
||||||
|
randomTotal += points.length - (n % 5 === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
inside,
|
||||||
|
leftLoop,
|
||||||
|
corner,
|
||||||
|
ringIn,
|
||||||
|
ringOut,
|
||||||
|
allOut,
|
||||||
|
padded,
|
||||||
|
randomMissing,
|
||||||
|
randomDrawn,
|
||||||
|
randomTotal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""509-1 평면도(B05 노선 편집)용 도엽 — 노선 둘레 띠로 잘라 둠(서버 · 파생 저장본).
|
||||||
|
|
||||||
|
- 범위 = 저장 노선 bbox + 띠 300 + 여유 300 m(50 m 눈금 바깥 맞춤) · `clip_lonlat` 붙음
|
||||||
|
- 안에 다 든 가닥 그대로 · 밖 가닥 빠짐 · 걸친 가닥은 범위 안 꼭짓점만
|
||||||
|
- 같은 입력 = 같은 저장본(다시 안 셈) · 사본이 바뀌면 다시 셈
|
||||||
|
- 노선 없음 · 모르는 범위 = 사본 그대로
|
||||||
|
- 원본 · 사본 파일은 안 바뀜 · 저장본은 `_derived/sheet_route/` 에만
|
||||||
|
- 화면 쪽 `sheetCoversBand` — 띠가 범위 밖이면 통째 사본을 받음 · 상수 짝(띠 300 · 줌아웃 2 배)
|
||||||
|
|
||||||
|
임시 폴더에 사업지(1 m = 0.00001 도)를 지어 시험 — 공유 storage · DB 안 씀.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from B04_PreProcess import B04_PreProcess_Engine_SheetClip as clip
|
||||||
|
from common_util.common_util_route_geometry import planned_route_working_path, write_route_csv
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parents[1]
|
||||||
|
META = {
|
||||||
|
"x_min": 0,
|
||||||
|
"y_min": 0,
|
||||||
|
"width_meters": 10000,
|
||||||
|
"height_meters": 10000,
|
||||||
|
"lon_min": 127.0,
|
||||||
|
"lon_max": 127.1,
|
||||||
|
"lat_min": 37.0,
|
||||||
|
"lat_max": 37.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lonlat(x: float, y: float) -> list[float]:
|
||||||
|
return [127.0 + x * 1e-5, 37.0 + y * 1e-5]
|
||||||
|
|
||||||
|
|
||||||
|
def line_feature(points: list[tuple[float, float]], level: int) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {"등고수치": level},
|
||||||
|
"geometry": {"type": "LineString", "coordinates": [lonlat(x, y) for x, y in points]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def project(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||||
|
processed.mkdir(parents=True)
|
||||||
|
(processed / "vworld_satellite_meta.json").write_text(json.dumps(META), encoding="utf-8")
|
||||||
|
write_route_csv(
|
||||||
|
planned_route_working_path(tmp_path), [{"x": 5000, "y": 5000}, {"x": 5500, "y": 5200}]
|
||||||
|
)
|
||||||
|
source = processed / "도엽_등고선.display.geojson"
|
||||||
|
source.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
line_feature([(5100, 5100), (5200, 5150), (5300, 5100)], 100), # 안
|
||||||
|
line_feature([(9000, 9000), (9500, 9100)], 105), # 밖
|
||||||
|
line_feature([(x, 5100) for x in range(0, 10001, 100)], 110), # 걸침
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return tmp_path, source
|
||||||
|
|
||||||
|
|
||||||
|
def test_범위는_노선_bbox_더하기_600m(project):
|
||||||
|
root, _ = project
|
||||||
|
assert clip.clip_rect_m(root, "route", META) == (4400, 4400, 6100, 5800)
|
||||||
|
assert clip.ROUTE_BAND_M + clip.ROUTE_SPARE_M == 600
|
||||||
|
|
||||||
|
|
||||||
|
def test_자르기(project):
|
||||||
|
root, source = project
|
||||||
|
before = source.read_bytes()
|
||||||
|
out = clip.clipped_sheet(root, source, "route")
|
||||||
|
assert out.parent == root / "_derived" / "sheet_route"
|
||||||
|
data = json.loads(out.read_text(encoding="utf-8"))
|
||||||
|
assert data["clip_lonlat"] == pytest.approx([127.044, 37.044, 127.061, 37.058])
|
||||||
|
levels = [f["properties"]["등고수치"] for f in data["features"]]
|
||||||
|
assert levels == [100, 110]
|
||||||
|
inside = data["features"][0]["geometry"]
|
||||||
|
assert inside["coordinates"] == [lonlat(5100, 5100), lonlat(5200, 5150), lonlat(5300, 5100)]
|
||||||
|
crossing = data["features"][1]["geometry"]["coordinates"]
|
||||||
|
assert 15 <= len(crossing) <= 20 # 101 꼭짓점 → 범위 안(4400~6100)만 + 두 끝
|
||||||
|
assert all(127.044 - 1e-9 <= lon <= 127.061 + 1e-9 for lon, _ in crossing)
|
||||||
|
assert source.read_bytes() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_같은_입력은_다시_안_셈(project):
|
||||||
|
root, source = project
|
||||||
|
first = clip.clipped_sheet(root, source, "route")
|
||||||
|
stamp = first.stat().st_mtime_ns
|
||||||
|
again = clip.clipped_sheet(root, source, "route")
|
||||||
|
assert again == first and again.stat().st_mtime_ns == stamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_사본이_바뀌면_다시_셈(project):
|
||||||
|
root, source = project
|
||||||
|
first = clip.clipped_sheet(root, source, "route")
|
||||||
|
data = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
data["features"] = data["features"][:1]
|
||||||
|
source.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
os.utime(source, ns=(source.stat().st_atime_ns, source.stat().st_mtime_ns + 10**9))
|
||||||
|
second = clip.clipped_sheet(root, source, "route")
|
||||||
|
assert second != first
|
||||||
|
assert len(json.loads(second.read_text(encoding="utf-8"))["features"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_노선을_조금_옮기면_같은_열쇠(project):
|
||||||
|
root, source = project
|
||||||
|
first = clip.clipped_sheet(root, source, "route")
|
||||||
|
write_route_csv(
|
||||||
|
planned_route_working_path(root), [{"x": 5010, "y": 5010}, {"x": 5490, "y": 5190}]
|
||||||
|
)
|
||||||
|
assert clip.clipped_sheet(root, source, "route") == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_범위를_못_정하면_사본_그대로(project):
|
||||||
|
root, source = project
|
||||||
|
assert clip.clipped_sheet(root, source, "nope") == source
|
||||||
|
planned_route_working_path(root).unlink()
|
||||||
|
assert clip.clipped_sheet(root, source, "route") == source
|
||||||
|
assert not (root / "_derived" / "sheet_route").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_화면_띠_덮음_판정():
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", str(HERE / "helper_509_1_sheet_covers.cjs")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
r = json.loads(proc.stdout)
|
||||||
|
assert r["saved"] and r["within"]
|
||||||
|
assert not r["beyondEast"] and not r["beyondSouth"]
|
||||||
|
assert r["noClip"] and r["noSheet"] and r["noLine"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_상수_짝():
|
||||||
|
render = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Render.ts").read_text("utf-8")
|
||||||
|
zoom = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_ZoomRange.ts").read_text("utf-8")
|
||||||
|
band = re.search(r"export const CONTOUR_BAND_M = (\d+)", render)
|
||||||
|
factor = re.search(r"export const ZOOM_OUT_ROUTE_FACTOR = (\d+)", zoom)
|
||||||
|
assert band and float(band.group(1)) == clip.ROUTE_BAND_M
|
||||||
|
assert factor and float(factor.group(1)) == clip.ZOOM_OUT_ROUTE_FACTOR
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""509-3 공용 선 그리기(B04 지도 · 배수유역도 · B05 평면) — 화면 밖 구간 건너뛰기.
|
||||||
|
|
||||||
|
- 두 끝이 같은 쪽 화면 밖인 구간만 안 그림 · 화면(+ 여백 32 px)에 걸치는 구간은 다 그림
|
||||||
|
- 다 안이면 예전과 같은 호출(moveTo 1 · stroke 1 · 닫힌 고리 = closePath)
|
||||||
|
- 넓힘(padPx) 무한 = 안 거름(고른 등고선 덧그리기)
|
||||||
|
|
||||||
|
Node 헬퍼(`helper_509_3_offscreen_skip.cjs`)가 진짜 TS 를 가짜 캔버스로 돌린다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def r():
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", os.path.join(HERE, "helper_509_3_offscreen_skip.cjs")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_다_안이면_예전과_같음(r):
|
||||||
|
assert r["inside"]["calls"] == {"moveTo": 1, "lineTo": 3, "stroke": 1, "closePath": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_한쪽_밖_고리는_건너뜀(r):
|
||||||
|
loop = r["leftLoop"]
|
||||||
|
assert loop["missing"] == 0
|
||||||
|
assert loop["calls"]["lineTo"] == 2 # 밖 200 구간 → 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_모서리_지르는_구간은_그림(r):
|
||||||
|
assert r["corner"]["drawn"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_닫힌_고리(r):
|
||||||
|
assert r["ringIn"]["calls"]["closePath"] == 1 and r["ringIn"]["missing"] == 0
|
||||||
|
assert r["ringOut"]["missing"] == 0 and r["ringOut"]["calls"]["closePath"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_다_밖이면_안_그림(r):
|
||||||
|
assert r["allOut"]["calls"]["stroke"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_넓힘_무한은_안_거름(r):
|
||||||
|
assert r["padded"]["calls"]["lineTo"] == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_무작위_선_보이는_구간_빠짐_없음(r):
|
||||||
|
assert r["randomMissing"] == 0
|
||||||
|
assert r["randomDrawn"] < r["randomTotal"] / 4
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""509-6 배수유역도용 도엽 — 평면도와 따로 줄임(유역 + 여유).
|
||||||
|
|
||||||
|
- 범위 = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox 2 배) + 여유 300 m
|
||||||
|
- 평면도(route) 범위와 따로 저장(`_derived/sheet_drainage/`) · 유역이 없으면 사본 그대로
|
||||||
|
- 배수유역 계산 값 그대로 — 유역 · 관 파일 · 원본 도엽은 안 바뀜 · 배수유역 엔진은 이 사본을 안 읽음
|
||||||
|
|
||||||
|
임시 폴더 사업지(1 m = 0.00001 도) — 공유 storage · DB 안 씀.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from B04_PreProcess import B04_PreProcess_Engine_SheetClip as clip
|
||||||
|
from common_util.common_util_route_geometry import planned_route_working_path, write_route_csv
|
||||||
|
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
META = {
|
||||||
|
"x_min": 0,
|
||||||
|
"y_min": 0,
|
||||||
|
"width_meters": 10000,
|
||||||
|
"height_meters": 10000,
|
||||||
|
"lon_min": 127.0,
|
||||||
|
"lon_max": 127.1,
|
||||||
|
"lat_min": 37.0,
|
||||||
|
"lat_max": 37.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lonlat(x: float, y: float) -> list[float]:
|
||||||
|
return [127.0 + x * 1e-5, 37.0 + y * 1e-5]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def project(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||||
|
processed.mkdir(parents=True)
|
||||||
|
(processed / "vworld_satellite_meta.json").write_text(json.dumps(META), encoding="utf-8")
|
||||||
|
write_route_csv(
|
||||||
|
planned_route_working_path(tmp_path), [{"x": 5000, "y": 5000}, {"x": 5500, "y": 5200}]
|
||||||
|
)
|
||||||
|
ring = [lonlat(x, y) for x, y in [(4800, 5100), (6000, 5100), (6000, 6500), (4800, 6500)]]
|
||||||
|
basins = tmp_path / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME
|
||||||
|
basins.parent.mkdir(parents=True)
|
||||||
|
basins.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {"chainage_m": 100.0},
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": [ring + [ring[0]]]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
source = processed / "도엽_등고선.display.geojson"
|
||||||
|
lines = [[lonlat(x, y) for x in range(0, 10001, 100)] for y in (4000, 4700, 5500, 6700, 7000)]
|
||||||
|
source.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {"등고수치": i},
|
||||||
|
"geometry": {"type": "LineString", "coordinates": c},
|
||||||
|
}
|
||||||
|
for i, c in enumerate(lines)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return tmp_path, source
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(folder: Path) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
str(p.relative_to(folder)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||||
|
for p in sorted(folder.rglob("*"))
|
||||||
|
if p.is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_범위는_유역_줌아웃_더하기_여유(project):
|
||||||
|
root, _ = project
|
||||||
|
# 줌아웃 4750~5750 · 4900~5300 ∪ 유역 4800~6000 · 5100~6500 → + 300 m
|
||||||
|
assert clip.clip_rect_m(root, "drainage", META) == (4450, 4600, 6300, 6800)
|
||||||
|
|
||||||
|
|
||||||
|
def test_평면도와_따로(project):
|
||||||
|
root, source = project
|
||||||
|
drainage = clip.clipped_sheet(root, source, "drainage")
|
||||||
|
route = clip.clipped_sheet(root, source, "route")
|
||||||
|
assert drainage.parent.name == "sheet_drainage" and route.parent.name == "sheet_route"
|
||||||
|
levels = [
|
||||||
|
f["properties"]["등고수치"]
|
||||||
|
for f in json.loads(drainage.read_text(encoding="utf-8"))["features"]
|
||||||
|
]
|
||||||
|
assert levels == [1, 2, 3] # y 4700 · 5500 · 6700 만 범위 안
|
||||||
|
route_levels = [
|
||||||
|
f["properties"]["등고수치"]
|
||||||
|
for f in json.loads(route.read_text(encoding="utf-8"))["features"]
|
||||||
|
]
|
||||||
|
assert route_levels == [1, 2] # 노선 띠 4400~5800
|
||||||
|
|
||||||
|
|
||||||
|
def test_유역이_없으면_사본_그대로(project):
|
||||||
|
root, source = project
|
||||||
|
(root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME).unlink()
|
||||||
|
assert clip.clipped_sheet(root, source, "drainage") == source
|
||||||
|
|
||||||
|
|
||||||
|
def test_배수유역_값_그대로(project):
|
||||||
|
root, source = project
|
||||||
|
drainage_folder = root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME
|
||||||
|
processed = root / "B04_PreProcess" / "processed"
|
||||||
|
before = (_digest(drainage_folder), _digest(processed))
|
||||||
|
clip.clipped_sheet(root, source, "drainage")
|
||||||
|
clip.clipped_sheet(root, source, "route")
|
||||||
|
assert (_digest(drainage_folder), _digest(processed)) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_배수유역_엔진은_잘린_사본을_안_읽음():
|
||||||
|
"""분석 · 관 자리 · 유역 계산 길에 SheetClip · `_derived/sheet_` 가 없음."""
|
||||||
|
for path in (ROOT / "B04_PreProcess").glob("B04_PreProcess_*Watershed*.py"):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
assert "SheetClip" not in text and "sheet_route" not in text, path.name
|
||||||
|
basins = (ROOT / "B04_PreProcess" / "B04_PreProcess_Router_Basins.py").read_text("utf-8")
|
||||||
|
assert "SheetClip" not in basins
|
||||||
Reference in New Issue
Block a user