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