Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-22 05:56:36 +09:00
13 changed files with 1009 additions and 38 deletions
+17 -4
View File
@@ -326,14 +326,23 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
*
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 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>(
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),
),
),
);
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
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
+8 -6
View File
@@ -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"
+59 -13
View File
@@ -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);
}
}
+14 -3
View File
@@ -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<readonly [DrainageLayer, GeoJsonCollection | null]>;
@@ -453,7 +459,12 @@ export async function fetchDrainageLayers(
DRAINAGE_LAYERS.map(async (layer) => {
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 {
return [layer, null] as const;
}
+3 -3
View File
@@ -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 {
fetchRoutePlan,
fetchTerrainSources,
@@ -596,11 +596,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;
@@ -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<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 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
* 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다.
* 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의
* 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다.
*/
export async function loadRouteEditBackground(
projectId: string,
drainage: {
meta: VWorldMeta;
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
},
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 !== "도엽_등고선")
@@ -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) {
@@ -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