도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)

- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지)
- 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함
- 새 라이브러리: ezdxf(Python), maplibre-contour(npm)
- 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPipe EngineeringandClaude Opus 5.5 committed 2026-11-06 15:03:21 +09:00
1 parent 14f07181db
commit e3f386addf
33 files changed
+3454 -482

No files matched your search

+76 -15
View File
@@ -27,12 +27,24 @@ from .batch import BatchError
DEM_DIR = Path(os.environ.get("SPIPE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "dem"))
DEM_KEEP = int(os.environ.get("SPIPE_DEM_KEEP", "8"))
# Server-provided DEMs (e.g. the national 90 m DEM), registered once with
# `python -m backend.dem register <raster|zip> <name> <label>`; never trimmed.
BASE_DIR = Path(os.environ.get("SPIPE_BASE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "base_dem"))
_NAME_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_-")
RASTER_EXTENSIONS = (".tif", ".tiff", ".asc", ".img", ".dem", ".bil", ".flt", ".hgt", ".vrt")
MAX_CELLS_FROM_POINTS = 25_000_000
_ID_OK = set("0123456789abcdef")
def _path(dem_id: str) -> Path:
if dem_id.startswith("base-"):
name = dem_id[5:]
if not name or not set(name) <= _NAME_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = BASE_DIR / f"{name}.tif"
if not p.exists():
raise BatchError("서버 기본 DEM이 없습니다.")
return p
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = DEM_DIR / f"{dem_id}.tif"
@@ -79,10 +91,12 @@ def _info(dem_id: str, src, name: str) -> dict:
}
def _store(src_path: str, name: str) -> dict:
DEM_DIR.mkdir(parents=True, exist_ok=True)
dem_id = uuid.uuid4().hex
out = DEM_DIR / f"{dem_id}.tif"
def _store(src_path: str, name: str, base: str | None = None) -> dict:
"""Copy a raster into the store as a tiled GeoTIFF (base: a permanent server DEM)."""
folder = BASE_DIR if base else DEM_DIR
folder.mkdir(parents=True, exist_ok=True)
dem_id = f"base-{base}" if base else uuid.uuid4().hex
out = folder / f"{base if base else dem_id}.tif"
try:
with rasterio.open(src_path) as src:
if src.count < 1:
@@ -105,10 +119,36 @@ def _store(src_path: str, name: str) -> dict:
out.unlink(missing_ok=True)
raise
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
_trim()
if not base:
_trim()
return info
def register_base(src: str, name: str, label: str) -> dict:
"""Register a raster (or a zip holding one) as a permanent server DEM."""
if not name or not set(name) <= _NAME_OK:
raise BatchError("이름은 영문 소문자·숫자·-·_ 만 쓸 수 있습니다.")
if src.lower().endswith(".zip"):
with zipfile.ZipFile(src) as z:
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS]
if not names:
raise BatchError("zip 안에 래스터가 없습니다.")
src = "/vsizip/" + os.path.abspath(src).replace("\\", "/") + "/" + names[0]
return _store(src, label, base=name)
def list_base() -> list[dict]:
if not BASE_DIR.exists():
return []
out = []
for j in sorted(BASE_DIR.glob("*.json")):
try:
out.append(json.loads(j.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return out
def import_dem(upload_path: str, filename: str) -> dict:
"""Store an uploaded raster (or a zip holding one, e.g. ASC + PRJ) as a DEM."""
ext = os.path.splitext(filename.lower())[1]
@@ -292,16 +332,27 @@ MAX_HYDRO_CELLS = 1_000_000
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
def _hydro_grid(dem_id: str):
def _hydro_grid(dem_id: str, bbox: list | None = None, bbox_crs: str | None = None):
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
on a priority-flood filled surface (pits and flats drain)."""
on a priority-flood filled surface (pits and flats drain). `bbox` [x0, y0, x1, y1]
in `bbox_crs` limits the area (needed for country-wide DEMs)."""
import heapq
from rasterio.windows import Window, from_bounds
with rasterio.open(_path(dem_id)) as src:
k = max(1, int(np.ceil(np.sqrt(src.width * src.height / MAX_HYDRO_CELLS))))
h, w = max(2, src.height // k), max(2, src.width // k)
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
transform = src.transform @ src.transform.scale(src.width / w, src.height / h)
win = Window(0, 0, src.width, src.height)
if bbox:
tr = Transformer.from_crs(_crs_of(bbox_crs), src.crs, always_xy=True)
xs, ys = tr.transform([bbox[0], bbox[2], bbox[0], bbox[2]], [bbox[1], bbox[1], bbox[3], bbox[3]])
win = from_bounds(min(xs), min(ys), max(xs), max(ys), src.transform).round_offsets().round_lengths()
win = win.intersection(Window(0, 0, src.width, src.height))
elif src.width * src.height > 16 * MAX_HYDRO_CELLS:
raise BatchError("DEM이 너무 넓습니다. 관망 범위로 잘라서 계산하세요.")
k = max(1, int(np.ceil(np.sqrt(win.width * win.height / MAX_HYDRO_CELLS))))
h, w = max(2, int(win.height) // k), max(2, int(win.width) // k)
z = src.read(1, window=win, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
wt = src.window_transform(win)
transform = wt @ wt.scale(win.width / w, win.height / h)
crs = src.crs
if crs.is_geographic:
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
@@ -353,9 +404,9 @@ def _hydro_grid(dem_id: str):
return {"h": h, "w": w, "recv": recv, "acc": acc, "transform": transform, "crs": crs, "cell_area": abs(transform.a * transform.e)}
def streams(dem_id: str, min_area_km2: float) -> dict:
def streams(dem_id: str, min_area_km2: float, bbox: list | None = None, bbox_crs: str | None = None) -> dict:
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
g = _hydro_grid(dem_id)
g = _hydro_grid(dem_id, bbox, bbox_crs)
thr = max(2.0, min_area_km2 * 1e6 / g["cell_area"])
w, tr = g["w"], g["transform"]
idx = np.where((g["acc"] >= thr) & (g["recv"] >= 0))[0]
@@ -385,11 +436,11 @@ def streams(dem_id: str, min_area_km2: float) -> dict:
return {"type": "FeatureCollection", "features": feats}
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0, bbox: list | None = None) -> dict:
"""Catchment polygon upstream of each pour point (snapped to the largest flow within snap_m)."""
from rasterio import features as rfeatures
g = _hydro_grid(dem_id)
g = _hydro_grid(dem_id, bbox, points_crs)
h, w, tr = g["h"], g["w"], g["transform"]
to_dem = Transformer.from_crs(_crs_of(points_crs), g["crs"], always_xy=True)
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
@@ -430,3 +481,13 @@ def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float =
feats.append({"type": "Feature", "properties": {"point": k, "area_km2": round(float(mask.sum() * g["cell_area"] / 1e6), 4)},
"geometry": shapely.geometry.mapping(poly)})
return {"type": "FeatureCollection", "features": feats}
if __name__ == "__main__":
import sys
if len(sys.argv) == 5 and sys.argv[1] == "register":
info = register_base(sys.argv[2], sys.argv[3], sys.argv[4])
print(f"registered {info['id']}: {info['name']} {info['width']}x{info['height']} res {info['resolution']} z {info['min']}~{info['max']}")
else:
print("usage: python -m backend.dem register <raster|zip> <name> <label>")