Files
SPipe EngineeringandClaude Opus 5.5 e3f386addf 도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, 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>
2026-11-06 15:03:21 +09:00

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")