- 데모 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>
135 lines
6.4 KiB
Python
135 lines
6.4 KiB
Python
"""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")
|