"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다. 고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다. **LibreDWG 의 `dwg2dxf`** 로 바꿔 읽는다 (2026-09-06 사용자 확정). ODA File Converter 는 비회원 무료 사용이 **비상업 용도로 제한**돼 이 프로그램에는 쓰지 않는다. LibreDWG 는 별도 실행 파일로만 부른다 — 라이브러리로 끌어안으면 GPL 이 이 프로그램까지 번진다. 별도 프로세스 호출은 그 의무가 생기지 않는다. 읽는 범위는 **R2018(AC1032) 까지**다. 그보다 새 형식이나 변환 실패는 「R2018 이하 또는 DXF 로 저장해 달라」는 안내로 떨어진다. 프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를 직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다). """ from __future__ import annotations import logging import os import shutil import subprocess import tempfile from math import cos, radians, sin from pathlib import Path from typing import Any from uuid import NAMESPACE_URL, uuid5 import ezdxf from ezdxf import colors as ezdxf_colors from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode logger = logging.getLogger(__name__) _IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import") _DEFAULT_COLOR = "#f5f7fa" # 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. _FLATTEN_MM = 0.2 _MAX_ENTITIES = 20000 # 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`. # 별도 실행 파일로만 부른다(라이브러리로 품지 않는다) — 자세한 것은 그 폴더의 README. _BUNDLED_DIR = Path(__file__).resolve().parent / "openwebcad" / "tools" / "libredwg" def bundled_tool(name: str) -> str | None: """동봉한 변환기 경로. 없으면 None.""" path = _BUNDLED_DIR / name return str(path) if path.is_file() else None def _entity_id(index: int) -> str: return str(uuid5(_IMPORT_NS, str(index))) def _color(entity: Any) -> str: """DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다.""" try: aci = int(entity.dxf.color) if aci in (0, 256): # BYBLOCK · BYLAYER return _DEFAULT_COLOR red, green, blue = ezdxf_colors.aci2rgb(aci) return f"#{red:02x}{green:02x}{blue:02x}" except Exception: return _DEFAULT_COLOR def _point(x: float, y: float) -> dict[str, float]: return {"x": float(x), "y": float(y)} def _base(index: int, entity: Any, kind: str) -> dict[str, Any]: return { "id": _entity_id(index), "type": kind, "lineColor": _color(entity), "lineWidth": 1, "layerId": str(getattr(entity.dxf, "layer", "0")), } def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]: return { **_base(index, entity, "Line"), "shapeData": { "startPoint": _point(start[0], start[1]), "endPoint": _point(end[0], end[1]), }, } def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None: """점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다.""" vertices = [(float(p[0]), float(p[1])) for p in points] if closed and len(vertices) > 2: vertices.append(vertices[0]) if len(vertices) < 2: return None children = [ { **_base(index, entity, "Line"), "id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")), "shapeData": { "startPoint": _point(*vertices[seq]), "endPoint": _point(*vertices[seq + 1]), }, } for seq in range(len(vertices) - 1) ] return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children} def _plain(label: str) -> str: """옛 DXF 는 한글을 유니코드 escape 로 적는다 — 글자로 되돌린다.""" return decode_dxf_unicode(label) if has_dxf_unicode(label) else label def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) return { **_base(index, entity, "Text"), "shapeData": { "label": _plain(label), "basePoint": _point(insert[0], insert[1]), "options": { "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), "textAlign": "left", "textColor": _color(entity), "fontSize": float(height) or 3.0, "fontFamily": "sans-serif", }, }, } def _flatten(entity: Any) -> list[Any] | None: """원·호·타원·스플라인을 선분 점열로 편다. 못 펴면 None.""" try: return list(entity.flattening(_FLATTEN_MM)) except Exception: return None def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: kind = entity.dxftype() if kind == "LINE": return [_line(index, entity, entity.dxf.start, entity.dxf.end)] if kind == "LWPOLYLINE": shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) return [shape] if shape else [] if kind == "POLYLINE": points = [vertex.dxf.location for vertex in entity.vertices] shape = _polyline(index, entity, points, bool(entity.is_closed)) return [shape] if shape else [] if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): points = _flatten(entity) if not points: return [] shape = _polyline(index, entity, points, kind in ("CIRCLE", "ELLIPSE")) return [shape] if shape else [] if kind == "POINT": location = entity.dxf.location return [ { **_base(index, entity, "Point"), "shapeData": {"point": _point(location[0], location[1])}, } ] if kind == "TEXT": label = str(entity.dxf.text or "").strip() if not label: return [] return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] if kind == "MTEXT": label = str(entity.plain_text() or "").strip() if not label: return [] height = float(entity.dxf.char_height or 3.0) return [_text(index, entity, label, entity.dxf.insert, height)] return [] def _expand(entity: Any) -> list[Any]: """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): try: return list(entity.virtual_entities()) except Exception: logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) return [] return [entity] def dxf_to_entities(path: Path) -> list[dict[str, Any]]: """DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다.""" document = ezdxf.readfile(str(path)) entities: list[dict[str, Any]] = [] index = 0 for source in document.modelspace(): for item in _expand(source): entities.extend(_convert_entity(index, item)) index += 1 if len(entities) > _MAX_ENTITIES: raise ValueError( f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)." " 도각만 남겨 다시 저장해 주십시오." ) if not entities: raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.") return entities # DWG 머리글의 형식 표시(앞 6바이트) — 읽을 수 있는 것과 사람이 읽을 이름. _DWG_VERSIONS: dict[str, str] = { "AC1014": "R14", "AC1015": "2000", "AC1018": "2004", "AC1021": "2007", "AC1024": "2010", "AC1027": "2013", "AC1032": "2018", } _SAVE_AS_GUIDE = ( "캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오." ) def dwg_version(data: bytes) -> str | None: """DWG 머리글에서 형식 이름을 읽는다. 우리가 아는 형식이 아니면 None.""" return _DWG_VERSIONS.get(data[:6].decode("ascii", "ignore")) def _dwg2dxf_path() -> str | None: """LibreDWG 변환기(dwg2dxf) 자리 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" configured = os.getenv("LIBREDWG_DWG2DXF_PATH", "").strip() if configured: return configured if Path(configured).is_file() else None return bundled_tool("dwg2dxf.exe") or shutil.which("dwg2dxf") def _dwg_to_dxf(source: Path, work_dir: Path) -> Path: """LibreDWG 로 DWG 를 DXF 로 바꾼다. 못 읽는 형식·변환기 없음은 안내와 함께 실패.""" with source.open("rb") as handle: version = dwg_version(handle.read(6)) if version is None: raise ValueError(f"이 DWG 는 R2018 이후이거나 알 수 없는 형식입니다. {_SAVE_AS_GUIDE}") converter = _dwg2dxf_path() if not converter: raise ValueError(f"이 서버는 아직 DWG 를 바로 읽지 못합니다. {_SAVE_AS_GUIDE}") target = work_dir / "converted.dxf" try: subprocess.run( [converter, "-o", str(target), str(source)], check=True, timeout=180, capture_output=True, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: logger.info("B07 도각 DWG 변환 실패(%s): %s", version, exc) raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") from exc if not target.is_file() or target.stat().st_size == 0: raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") return target def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: """올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다.""" suffix = Path(filename).suffix.lower() if suffix not in (".dxf", ".dwg"): raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.") with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary: work_dir = Path(temporary) source = work_dir / f"frame{suffix}" source.write_bytes(data) target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source try: return dxf_to_entities(target) except ezdxf.DXFError as exc: raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc