diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 6d7d2a4af..7ce332dd7 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -326,14 +326,23 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심 /** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다. * * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. - * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */ -export async function fetchCachedSheetLayer(projectId: string, layer: string): Promise { + * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. + * `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6) + * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */ +export async function fetchCachedSheetLayer( + projectId: string, + layer: string, + scope?: SheetScope, +): Promise { + const range = scope ? `&scope=${scope}` : ""; return fetchCachedJson( 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 사용자 지시). @@ -439,7 +448,11 @@ export async function preloadSurfaceAssets( // 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다. report("배경 지도를 준비하는 중…", null); 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), + ), + ), ); // 위성사진은 로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다. await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, { diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py new file mode 100644 index 000000000..cde050a88 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 1760e03cd..db067e4ab 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -18,6 +18,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) +from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402 + # 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일) from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402 @@ -96,12 +98,13 @@ async def get_vworld_map( # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) 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: """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. + `scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6). """ pool = get_db_pool() try: @@ -153,11 +156,10 @@ async def get_project_geojson( # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. # 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은 # 그대로고 뜻 없는 자리만 사라진다. - return cached_file_response( - request, - await asyncio.to_thread(_display_copy, filepath), - "application/geo+json", - ) + display = await asyncio.to_thread(_display_copy, filepath) + if scope: + display = await asyncio.to_thread(clipped_sheet, project_root, display, scope) + return cached_file_response(request, display, "application/geo+json") if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index a91f14ddf..955af7888 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -273,7 +273,7 @@ export function drawPreparedFeature( ): void { const feature = layer.features[index]; if (!feature || feature.kind !== "line") return; - drawLineParts(context, feature, affineOf(view)); + drawLineParts(context, feature, affineOf(view), view, Infinity); // 몇 가닥뿐 — 안 거름 } /** 점과 선분 사이 거리(px). */ @@ -358,9 +358,19 @@ function drawLineParts( context: CanvasRenderingContext2D, feature: PreparedFeature, affine: Affine, + view: ViewState, + pad: number, ): void { // affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수. 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) { const coords = part.coords; if (coords.length < 4) continue; @@ -368,19 +378,51 @@ function drawLineParts( // 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지). // 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시). 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) { if (weights && weights[i / 2] < tolerance) continue; const x = coords[i] * affine.ax + affine.bx; const y = coords[i + 1] * affine.ay + affine.by; - if (started) context.lineTo(x, y); - else { - context.moveTo(x, y); - started = true; + const side = sideOf(x, y); + if (kept === 0) { + firstX = x; + 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 (part.closed) context.closePath(); + if (moves === 0) continue; context.stroke(); } } @@ -424,8 +466,8 @@ const CULL_MARGIN = 32; const LABEL_GAP_PX = 10; const LABEL_ROW_PX = 14; -function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { - const margin = CULL_MARGIN; +function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState, pad = 0): boolean { + const margin = CULL_MARGIN + pad; const minX = feature.minX * affine.ax + affine.bx; const maxX = feature.maxX * affine.ax + affine.bx; const minY = feature.minY * affine.ay + affine.by; @@ -506,21 +548,25 @@ export function pickLevelStep( * * `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이 * 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면 - * 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */ + * 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. + * + * `padPx` = 화면 밖 판정을 이만큼 넓힘 — 그림을 돌린 화면(B05 노선 편집)은 모서리가 화면 사각형 + * 밖에서 들어오므로 반 대각선만큼 준다. */ export function drawPreparedLayer( context: CanvasRenderingContext2D, layer: PreparedLayer, view: ViewState, marker: MarkerKind, everyM?: number, + padPx = 0, ): void { const affine = affineOf(view); const step = everyM !== undefined && everyM > 0 ? everyM : 0; for (const feature of layer.features) { 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); - else drawLineParts(context, feature, affine); + else drawLineParts(context, feature, affine, view, padPx); } } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 1680299ea..042f952c1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -10,7 +10,11 @@ import { stateKey } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; 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 { computeMapRect, @@ -439,10 +443,12 @@ export function reconcilePipes( } /** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다. - * 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */ + * 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. + * `scope` = 서버가 자른 범위(기본 배수유역도 = 유역 + 여유 · 노선 편집 = "route" · null = 통째). */ export async function fetchDrainageLayers( projectId: string, onProgress: (ratio: number) => void, + scope: SheetScope | null = "drainage", ): Promise<{ meta: VWorldMeta; layers: Array; @@ -453,7 +459,12 @@ export async function fetchDrainageLayers( DRAINAGE_LAYERS.map(async (layer) => { try { // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. - return [layer, await fetchCachedSheetLayer(projectId, layer)] as const; + const collection = await fetchCachedSheetLayer( + projectId, + layer, + scope ?? undefined, + ); + return [layer, collection] as const; } catch { return [layer, null] as const; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index f3a802301..2c29847f4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -25,11 +25,11 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { showToast } from "@ui/ui_template_elements"; import { bindMinorContourToggle, + fetchRouteSheets, loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; -import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { analysisTerrain, fetchRoutePlan, @@ -601,11 +601,11 @@ export async function mountRouteEditor( host.onHide(() => (historyControls.listen(false), viewMemory.flush())); host.onDispose(dispose); - // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── + // ── 자료 읽기 — 노선 두 벌 + 노선 띠로 자른 도엽(509-1) ── try { const [plan, drainage, sources] = await Promise.all([ fetchRoutePlan(projectId), - fetchDrainageLayers(projectId, () => {}), + fetchRouteSheets(projectId, () => plannedLine), fetchTerrainSources(projectId).catch(() => null), ]); if (closed) return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 41ad14506..0805c6e14 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -23,6 +23,8 @@ import { prepareMetricPolylines, } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; 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 지도가 쓰는 것과 같은 키. */ const SHEET_ELEVATION_KEYS = ["등고수치"]; @@ -102,18 +104,61 @@ export async function loadRouteEditContours( 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, + 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; + /** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */ + line?: () => ReadonlyArray; +}; + +/** 노선 편집이 받는 도엽 — 노선 띠로 자른 것(509-1). `line` = 지금 노선(배경 준비 때 읽음). */ +export async function fetchRouteSheets( + projectId: string, + line: () => ReadonlyArray, +): Promise { + return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line }; +} + /** * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). - * 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다. + * 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의 + * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다. */ export async function loadRouteEditBackground( projectId: string, - drainage: { - meta: VWorldMeta; - layers: ReadonlyArray; - }, + clipped: SheetLayers, options: { surfaceModelId: number | null; intervalM: number; smooth: boolean }, ): 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 otherSheets = drainage.layers .filter(([layer]) => layer !== "도엽_등고선") diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 4e3ef7720..6aedb6f1f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -45,7 +45,7 @@ const LOCKED_WIDTH = 4; * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ -const CONTOUR_BAND_M = 300; +export const CONTOUR_BAND_M = 300; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; @@ -171,6 +171,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.translate(-view.width / 2, -view.height / 2); } + // 돌린 화면은 모서리가 화면 사각형 밖에서 들어옴 — 화면 밖 거르기를 반 대각선만큼 넓힘(509-3). + const pad = scene.rotationRad ? Math.hypot(view.width, view.height) / 2 : 0; context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(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.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(); if (scene.contours) { // 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다. @@ -203,12 +206,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.strokeStyle = style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)"; context.lineWidth = 0.5; - drawPreparedLayer(context, scene.minorContours, view, "dot"); + drawPreparedLayer(context, scene.minorContours, view, "dot", undefined, pad); } // 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②). context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8"; 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) { diff --git a/resources/tester/helper_509_1_sheet_covers.cjs b/resources/tester/helper_509_1_sheet_covers.cjs new file mode 100644 index 000000000..0c509db96 --- /dev/null +++ b/resources/tester/helper_509_1_sheet_covers.cjs @@ -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), + }), +); diff --git a/resources/tester/helper_509_3_offscreen_skip.cjs b/resources/tester/helper_509_3_offscreen_skip.cjs new file mode 100644 index 000000000..b60260bd5 --- /dev/null +++ b/resources/tester/helper_509_3_offscreen_skip.cjs @@ -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, + }), +); diff --git a/resources/tester/test_509_1_sheet_clip_route.py b/resources/tester/test_509_1_sheet_clip_route.py new file mode 100644 index 000000000..aa23f7091 --- /dev/null +++ b/resources/tester/test_509_1_sheet_clip_route.py @@ -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 diff --git a/resources/tester/test_509_3_offscreen_skip.py b/resources/tester/test_509_3_offscreen_skip.py new file mode 100644 index 000000000..fc50f20e4 --- /dev/null +++ b/resources/tester/test_509_3_offscreen_skip.py @@ -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 diff --git a/resources/tester/test_509_6_sheet_clip_drainage.py b/resources/tester/test_509_6_sheet_clip_drainage.py new file mode 100644 index 000000000..1cc5e3aac --- /dev/null +++ b/resources/tester/test_509_6_sheet_clip_drainage.py @@ -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