도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, 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:
1 parent
14f07181db
commit
e3f386addf
33 files changed
+3454
-482
No files matched your search
+76
-15
@@ -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>")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Drawing sheets (src/drawing.ts model) -> DXF R2018 via ezdxf.
|
||||
|
||||
The browser sends the same item list it renders as SVG: lines, texts, circles
|
||||
and solid fills in millimetres (origin bottom-left). R2018 stores text as
|
||||
UTF-8, so Korean needs no code page; widths become lineweights, colours true
|
||||
colours, fills solid hatches. Each item keeps its layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
import ezdxf
|
||||
from ezdxf import colors
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
|
||||
from .batch import BatchError
|
||||
|
||||
MAX_ITEMS = 400_000
|
||||
_LINEWEIGHTS = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]
|
||||
_FONTS = {"ulleungM": ("HY울릉도M.TTF", "HY울릉도M"), "malgun": ("malgun.ttf", "Malgun Gothic")}
|
||||
_HEX = re.compile(r"^#([0-9a-fA-F]{6})$")
|
||||
_ALIGN = {
|
||||
("start", "bottom"): TextEntityAlignment.LEFT, ("middle", "bottom"): TextEntityAlignment.CENTER, ("end", "bottom"): TextEntityAlignment.RIGHT,
|
||||
("start", "middle"): TextEntityAlignment.MIDDLE_LEFT, ("middle", "middle"): TextEntityAlignment.MIDDLE_CENTER, ("end", "middle"): TextEntityAlignment.MIDDLE_RIGHT,
|
||||
("start", "top"): TextEntityAlignment.TOP_LEFT, ("middle", "top"): TextEntityAlignment.TOP_CENTER, ("end", "top"): TextEntityAlignment.TOP_RIGHT,
|
||||
}
|
||||
|
||||
|
||||
def _rgb(color: str | None) -> int | None:
|
||||
m = _HEX.match(color or "")
|
||||
return colors.rgb2int(tuple(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4))) if m else None
|
||||
|
||||
|
||||
def _lineweight(width_mm: float | None) -> int:
|
||||
w = (width_mm or 0.25) * 100
|
||||
return min(_LINEWEIGHTS[1:], key=lambda v: abs(v - w))
|
||||
|
||||
|
||||
def _pts(raw) -> list[tuple[float, float]]:
|
||||
try:
|
||||
return [(float(p[0]), float(p[1])) for p in raw]
|
||||
except (TypeError, ValueError, IndexError) as e:
|
||||
raise BatchError("도면 좌표가 올바르지 않습니다.") from e
|
||||
|
||||
|
||||
def drawing_to_dxf(drawing: dict) -> bytes:
|
||||
items = drawing.get("items") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
raise BatchError("도면이 비어 있습니다.")
|
||||
if len(items) > MAX_ITEMS:
|
||||
raise BatchError("도면 요소가 너무 많습니다.")
|
||||
doc = ezdxf.new("R2018", setup=True) # standard linetypes incl. DASHED / DOT
|
||||
doc.units = ezdxf.units.MM
|
||||
doc.header["$LWDISPLAY"] = 1
|
||||
# Text style: the sheet's font (HY울릉도M by default) by file and family
|
||||
# name; CAD substitutes when the font is not installed on that PC.
|
||||
font_file, family = _FONTS.get(str(drawing.get("font") or ""), _FONTS["malgun"])
|
||||
style = doc.styles.add("MALGUN", font=font_file)
|
||||
style.set_extended_font_data(family=family)
|
||||
msp = doc.modelspace()
|
||||
layers: set[str] = set()
|
||||
|
||||
def attrs(it: dict, color: str | None) -> dict:
|
||||
layer = str(it.get("layer") or "0")[:255]
|
||||
if layer not in layers:
|
||||
doc.layers.add(layer)
|
||||
layers.add(layer)
|
||||
a = {"layer": layer}
|
||||
m = _HEX.match(color or "")
|
||||
if m and max(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) < 64:
|
||||
a["color"] = 7 # near-black -> white/black by CAD background (AutoCAD model space is dark)
|
||||
elif m:
|
||||
a["true_color"] = _rgb(color)
|
||||
return a
|
||||
|
||||
def is_white(color: str | None) -> bool:
|
||||
m = _HEX.match(color or "")
|
||||
return bool(m) and min(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) > 240
|
||||
|
||||
for it in items:
|
||||
kind = it.get("kind")
|
||||
if kind == "line":
|
||||
pts = _pts(it.get("pts") or [])
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
a = attrs(it, it.get("color") or "#111111")
|
||||
a["lineweight"] = _lineweight(it.get("width"))
|
||||
if it.get("dash") == "dashed":
|
||||
a["linetype"] = "DASHED"
|
||||
elif it.get("dash") == "dotted":
|
||||
a["linetype"] = "DOT"
|
||||
pl = msp.add_lwpolyline(pts, close=bool(it.get("closed")), dxfattribs=a)
|
||||
if a.get("linetype"):
|
||||
pl.dxf.ltscale = max(0.05, (it.get("width") or 0.25) * 1.2)
|
||||
elif kind == "fill":
|
||||
pts = _pts(it.get("pts") or [])
|
||||
# White boxes only mask lines behind labels on paper; in CAD they hide geometry.
|
||||
if len(pts) < 3 or is_white(it.get("fill")):
|
||||
continue
|
||||
a = attrs(it, it.get("fill"))
|
||||
h = msp.add_hatch(dxfattribs=a)
|
||||
h.paths.add_polyline_path(pts, is_closed=True)
|
||||
op = float(it.get("opacity") if it.get("opacity") is not None else 1)
|
||||
if op < 1:
|
||||
h.transparency = max(0.0, min(1.0, 1 - op))
|
||||
elif kind == "circle":
|
||||
x, y, r = float(it["x"]), float(it["y"]), float(it["r"])
|
||||
if it.get("fill") and it.get("fill") != "none":
|
||||
h = msp.add_hatch(dxfattribs=attrs(it, it.get("fill")))
|
||||
h.paths.add_edge_path().add_arc((x, y), r, 0, 360)
|
||||
a = attrs(it, it.get("color") or "#111111")
|
||||
a["lineweight"] = _lineweight(it.get("width") or 0.2)
|
||||
msp.add_circle((x, y), r, dxfattribs=a)
|
||||
elif kind == "text":
|
||||
text = str(it.get("text") or "")
|
||||
if not text:
|
||||
continue
|
||||
a = attrs(it, it.get("color") or "#111111")
|
||||
a.update(style="MALGUN", height=float(it.get("size") or 2.5) * 0.8, rotation=float(it.get("rotate") or 0))
|
||||
t = msp.add_text(text, dxfattribs=a)
|
||||
t.set_placement((float(it["x"]), float(it["y"])), align=_ALIGN.get((it.get("anchor") or "start", it.get("vAnchor") or "bottom"), TextEntityAlignment.LEFT))
|
||||
# Open zoomed to the sheet (extents + initial model-space view).
|
||||
w, h = float(drawing.get("width") or 420), float(drawing.get("height") or 297)
|
||||
doc.set_modelspace_vport(height=h * 1.05, center=(w / 2, h / 2))
|
||||
buf = io.StringIO()
|
||||
doc.write(buf)
|
||||
# R2018 text is UTF-8 and AutoCAD ignores $DWGCODEPAGE, but GDAL-based readers
|
||||
# (QGIS etc.) decode with it; ezdxf always writes ANSI_1252 on save.
|
||||
out = buf.getvalue().replace("$DWGCODEPAGE\n 3\nANSI_1252\n", "$DWGCODEPAGE\n 3\nANSI_65001\n", 1)
|
||||
# ezdxf writes "unknown" extents (+-1e20); give CAD the sheet so zoom-extents works.
|
||||
out = out.replace("$EXTMIN\n 10\n1e+20\n 20\n1e+20\n 30\n1e+20\n", "$EXTMIN\n 10\n0.0\n 20\n0.0\n 30\n0.0\n", 1)
|
||||
out = out.replace("$EXTMAX\n 10\n-1e+20\n 20\n-1e+20\n 30\n-1e+20\n", f"$EXTMAX\n 10\n{w}\n 20\n{h}\n 30\n0.0\n", 1)
|
||||
return out.encode("utf-8")
|
||||
+48
-2
@@ -16,6 +16,7 @@ from .columnar import to_columnar
|
||||
from .gisio import read_vector
|
||||
from . import dem as dem_store
|
||||
from . import gisops
|
||||
from .drawing_dxf import drawing_to_dxf
|
||||
from urllib.parse import quote
|
||||
from .jobs import get_job
|
||||
from .epanet import EN, EpanetAPI, EpanetException, FLOW_UNITS, PRESS_UNITS, QUAL_TYPES
|
||||
@@ -186,6 +187,12 @@ async def dem_upload(file: UploadFile = File(...)):
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/dem-base")
|
||||
async def dem_base():
|
||||
"""Server-provided DEMs (registered with `python -m backend.dem register ...`)."""
|
||||
return await _dem_run(dem_store.list_base)
|
||||
|
||||
|
||||
@app.get("/api/dem/{dem_id}")
|
||||
async def dem_get(dem_id: str):
|
||||
return await _dem_run(dem_store.dem_info, dem_id)
|
||||
@@ -219,9 +226,9 @@ async def dem_hydro(dem_id: str, request: Request):
|
||||
"""Body: {"op": "streams", "minAreaKm2": 0.1} or {"op": "watershed", "points": [[x, y]], "crs": "...", "snap": m}."""
|
||||
body = await request.json()
|
||||
if body.get("op") == "streams":
|
||||
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), timeout=600)
|
||||
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), body.get("bbox"), body.get("crs"), timeout=600)
|
||||
if body.get("op") == "watershed":
|
||||
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), timeout=600)
|
||||
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), body.get("bbox"), timeout=600)
|
||||
raise HTTPException(status_code=400, detail="op must be streams or watershed")
|
||||
|
||||
|
||||
@@ -257,6 +264,45 @@ async def gis_reproject(request: Request):
|
||||
return await _dem_run(gisops.reproject_fc, body.get("featureCollection"), body.get("from"), body.get("to"))
|
||||
|
||||
|
||||
_UESC = re.compile(r"\\U\+([0-9A-Fa-f]{4})")
|
||||
_CRLF = "\r\n"
|
||||
|
||||
|
||||
def dxf_to_cp949(text: str) -> bytes:
|
||||
"""R12 DXF from the browser (non-ASCII written as backslash-U+XXXX) -> Korean
|
||||
code page (ANSI_949), which Korean CAD reads; characters outside CP949 stay escaped."""
|
||||
def unesc(m: re.Match) -> str:
|
||||
ch = chr(int(m.group(1), 16))
|
||||
try:
|
||||
ch.encode("cp949")
|
||||
return ch
|
||||
except UnicodeEncodeError:
|
||||
return m.group(0)
|
||||
|
||||
out = _UESC.sub(unesc, text)
|
||||
ver = _CRLF.join(["$ACADVER", "1", "AC1009", ""])
|
||||
out = out.replace(ver, ver + _CRLF.join(["9", "$DWGCODEPAGE", "3", "ANSI_949", ""]), 1)
|
||||
return out.encode("cp949", errors="replace")
|
||||
|
||||
|
||||
@app.post("/api/drawing/dxf")
|
||||
async def drawing_dxf(request: Request):
|
||||
"""Body: {"drawing": {...items}, "name": ...} -> DXF R2018 (ezdxf, UTF-8 text);
|
||||
legacy {"dxf": "<R12 DXF text>"} -> CP949 R12."""
|
||||
body = await request.json()
|
||||
name = str(body.get("name") or "drawing") + ".dxf"
|
||||
if isinstance(body.get("drawing"), dict):
|
||||
data = await _dem_run(drawing_to_dxf, body["drawing"], timeout=120)
|
||||
return Response(content=data, media_type="application/dxf",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
|
||||
text = str(body.get("dxf") or "")
|
||||
if not text.startswith("0" + _CRLF + "SECTION") or len(text) > 50_000_000:
|
||||
raise HTTPException(status_code=400, detail="DXF가 올바르지 않습니다.")
|
||||
name = str(body.get("name") or "drawing") + ".dxf"
|
||||
return Response(content=dxf_to_cp949(text), media_type="application/dxf",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -10,3 +10,5 @@ rasterio==1.5.2
|
||||
pyproj==3.8.0
|
||||
shapely==2.2.0
|
||||
scipy==1.18.1
|
||||
# Drawing output (DXF R2018: Unicode text, lineweights, hatches)
|
||||
ezdxf==1.4.5
|
||||
@@ -0,0 +1,35 @@
|
||||
"""/api/drawing/dxf: browser R12 DXF (Unicode escapes) -> Korean code page (ANSI_949)."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pyogrio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app, dxf_to_cp949
|
||||
|
||||
DXF = "\r\n".join([
|
||||
"0", "SECTION", "2", "HEADER", "9", "$ACADVER", "1", "AC1009", "0", "ENDSEC",
|
||||
"0", "SECTION", "2", "ENTITIES",
|
||||
"0", "TEXT", "8", "TITLE", "10", "1", "20", "2", "30", "0", "40", "2.5", "1", r"\U+C218\U+B9AC \U+C885\U+B2E8\U+BA74\U+B3C4 \U+1F600",
|
||||
"0", "ENDSEC", "0", "EOF", "",
|
||||
])
|
||||
|
||||
|
||||
def test_conversion_reads_back_as_korean():
|
||||
data = dxf_to_cp949(DXF)
|
||||
assert b"$DWGCODEPAGE\r\n3\r\nANSI_949" in data
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = os.path.join(tmp, "t.dxf")
|
||||
open(p, "wb").write(data)
|
||||
meta, _, _, fields = pyogrio.raw.read(p)
|
||||
text = fields[list(meta["fields"]).index("Text")][0]
|
||||
assert text.startswith("수리 종단면도")
|
||||
assert r"\U+1F600" in data.decode("cp949") # outside CP949: kept escaped
|
||||
|
||||
|
||||
def test_endpoint():
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/drawing/dxf", json={"dxf": DXF, "name": "수리 종단면도"})
|
||||
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
|
||||
assert "수리".encode("cp949") in r.content
|
||||
assert c.post("/api/drawing/dxf", json={"dxf": "nope"}).status_code == 400
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Drawing sheet -> DXF R2018 (ezdxf): Unicode text, layers, lineweights, hatches."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import ezdxf
|
||||
import pyogrio
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.batch import BatchError
|
||||
from backend.drawing_dxf import drawing_to_dxf
|
||||
from backend.main import app
|
||||
|
||||
DRAWING = {
|
||||
"width": 420, "height": 297, "title": "t",
|
||||
"items": [
|
||||
{"kind": "line", "layer": "FRAME", "pts": [[0, 0], [420, 0], [420, 297], [0, 297]], "closed": True, "width": 0.7},
|
||||
{"kind": "line", "layer": "HGL_1", "pts": [[50, 200], [300, 150]], "width": 0.45, "color": "#1d4ed8", "dash": "dashed"},
|
||||
{"kind": "fill", "layer": "LEGEND", "pts": [[10, 10], [30, 10], [30, 20], [10, 20]], "fill": "#ffffff", "opacity": 0.9},
|
||||
{"kind": "circle", "layer": "NODE", "x": 100, "y": 100, "r": 1.2, "fill": "#dc2626", "color": "#dc2626"},
|
||||
{"kind": "text", "layer": "TITLE", "x": 210, "y": 280, "text": "송수관로 수리 종단면도 ○ ‰ m³", "size": 7, "anchor": "middle", "vAnchor": "middle", "bold": True},
|
||||
{"kind": "text", "layer": "TABLE", "x": 60, "y": 60, "text": "J-101", "size": 2, "rotate": 90, "anchor": "middle", "vAnchor": "middle"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_r2018_with_unicode_layers_and_styles():
|
||||
data = drawing_to_dxf(DRAWING)
|
||||
doc = ezdxf.read(io.StringIO(data.decode("utf-8")))
|
||||
assert doc.dxfversion == "AC1032" and doc.units == ezdxf.units.MM
|
||||
msp = doc.modelspace()
|
||||
texts = [e.dxf.text for e in msp.query("TEXT")]
|
||||
assert "송수관로 수리 종단면도 ○ ‰ m³" in texts
|
||||
hgl = msp.query('LWPOLYLINE[layer=="HGL_1"]')[0]
|
||||
assert hgl.dxf.linetype == "DASHED" and hgl.dxf.lineweight == 40 and hgl.rgb == (29, 78, 216)
|
||||
assert len(msp.query("HATCH")) == 1 and len(msp.query("CIRCLE")) == 1 # white mask box dropped
|
||||
assert {"FRAME", "HGL_1", "NODE", "TITLE", "TABLE"} <= {l.dxf.name for l in doc.layers}
|
||||
rotated = [e for e in msp.query("TEXT") if e.dxf.text == "J-101"][0]
|
||||
assert rotated.dxf.rotation == 90
|
||||
# GDAL (what GIS tools use) reads the Korean text back too.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = os.path.join(tmp, "d.dxf")
|
||||
open(p, "wb").write(data)
|
||||
meta, _, _, fields = pyogrio.raw.read(p)
|
||||
gtexts = [t for t in fields[list(meta["fields"]).index("Text")] if t]
|
||||
assert any("송수관로" in t for t in gtexts)
|
||||
|
||||
|
||||
def test_errors_and_endpoint():
|
||||
with pytest.raises(BatchError):
|
||||
drawing_to_dxf({"items": []})
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/drawing/dxf", json={"drawing": DRAWING, "name": "종단면도"})
|
||||
assert r.status_code == 200 and b"AC1032" in r.content
|
||||
assert "송수관로".encode("utf-8") in r.content
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Cross-check SPipe's server analyses against USEPA WNTR (an independent EPANET
|
||||
front end). Skipped when wntr is not installed (it is a dev-only dependency).
|
||||
|
||||
WNTR works in SI (m, m³/s); Net1/Net3 are US units (psi, GPM), so values are
|
||||
converted before comparing. WNTR bundles its own EPANET build, so small
|
||||
numerical differences are allowed.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from backend.batch import Network
|
||||
from backend.epanet import EN
|
||||
from backend.jobs import job_criticality, job_fireflow
|
||||
|
||||
wntr = pytest.importorskip("wntr")
|
||||
|
||||
PUBLIC = Path(__file__).resolve().parents[2] / "public"
|
||||
PSI_TO_M = 0.3048 / 0.4333 # EPANET's own psi <-> ft of water factor
|
||||
GPM_TO_M3S = 6.30902e-5
|
||||
|
||||
|
||||
def wntr_pressure(wn) -> "object":
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
res = wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=os.path.join(tmp, "w"))
|
||||
return res.node["pressure"]
|
||||
|
||||
|
||||
def spipe_pressure(inp: bytes) -> dict:
|
||||
with Network(inp) as n:
|
||||
js = n.junction_indices()
|
||||
r = n.simulate(node_props=(EN.PRESSURE,), nodes=js)
|
||||
return {n.node_ids[j - 1]: r.nodes[EN.PRESSURE][:, k] * PSI_TO_M for k, j in enumerate(js)}, r.times
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["Net1.inp", "Net3.inp"])
|
||||
def test_baseline_pressures_match(name):
|
||||
path = PUBLIC / name
|
||||
ours, times = spipe_pressure(path.read_bytes())
|
||||
p = wntr_pressure(wntr.network.WaterNetworkModel(str(path)))
|
||||
diffs = []
|
||||
for nid, series in ours.items():
|
||||
w = p[nid].reindex(times).to_numpy()
|
||||
diffs.append(np.nanmax(np.abs(series - w)))
|
||||
assert max(diffs) < 0.01, f"max pressure difference {max(diffs):.3f} m"
|
||||
|
||||
|
||||
def test_pipe_criticality_matches_closing_pipes_in_wntr():
|
||||
path = PUBLIC / "Net1.inp"
|
||||
ours = {e["id"]: e for e in job_criticality(path.read_bytes(), {"threshold": 40})["links"]}
|
||||
checked = 0
|
||||
for pid, e in ours.items():
|
||||
if e.get("disconnected") or e.get("minPressure") is None:
|
||||
continue # isolated nodes: both engines report meaningless negative heads
|
||||
wn = wntr.network.WaterNetworkModel(str(path))
|
||||
wn.get_link(pid).initial_status = wntr.network.LinkStatus.Closed
|
||||
p = wntr_pressure(wn)
|
||||
junctions = [n for n, _ in wn.junctions()]
|
||||
wmin = float(p[junctions].min().min())
|
||||
assert abs(e["minPressure"] * PSI_TO_M - wmin) < 0.01, f"pipe {pid}: {e['minPressure'] * PSI_TO_M:.2f} vs {wmin:.2f} m"
|
||||
checked += 1
|
||||
assert checked >= 10
|
||||
|
||||
|
||||
def test_fire_flow_pressure_drop_matches_extra_demand_in_wntr():
|
||||
path = PUBLIC / "Net1.inp"
|
||||
flow_gpm = 500.0
|
||||
r = job_fireflow(path.read_bytes(), {"nodes": ["22"], "flow": flow_gpm, "start": 0, "end": 24})
|
||||
ours = r["hydrants"][0]["firePressure"] * PSI_TO_M
|
||||
wn = wntr.network.WaterNetworkModel(str(path))
|
||||
# A demand without a pattern would get Net1's default pattern (up to 1.6x); fire flow is constant.
|
||||
wn.add_pattern("fire_const", [1.0])
|
||||
wn.get_node("22").add_demand(flow_gpm * GPM_TO_M3S, "fire_const", "fire")
|
||||
p = wntr_pressure(wn)
|
||||
assert abs(ours - float(p["22"].min())) < 0.01
|
||||
Reference in new issue
Block a user