"""B07 도면 내보내기 — 캐드 도면(JSON)을 DXF·DWG 파일로 바꾼다 (2026-09-06 사용자 지시). 불러오기(`..._Engine_Frame_Import`)의 반대 방향이다. 도형은 ezdxf 로 DXF 를 쓰고, DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불러 바꾼다 — 라이브러리로 끌어안으면 GPL 이 이 프로그램까지 번진다. 그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를 건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다. """ from __future__ import annotations import logging import math import os import shutil import subprocess import tempfile from pathlib import Path from typing import Any import ezdxf from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import bundled_tool logger = logging.getLogger(__name__) # 파일로 내려보내는 DXF 형식. DWG 로 갈 때는 LibreDWG 가 쓰는 R2004 로 맞춘다 — # 더 새 형식을 주면 한글이 깨진 채로 DWG 에 박힌다(2026-09-06 실측). _DXF_VERSION = "R2018" _DWG_SOURCE_VERSION = "R2004" _DEFAULT_TEXT_MM = 3.0 def _xy(point: Any) -> tuple[float, float] | None: if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): return float(point["x"]), float(point["y"]) return None def _layer_name(raw: Any) -> str: """DXF 도면층 이름 규칙에 맞춘다 — 빈 이름과 금지 문자를 걸러 낸다.""" name = str(raw or "0").strip() for bad in '<>/\\":;?*|=`': name = name.replace(bad, "_") return name[:255] or "0" def _add_entity( space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int] ) -> None: """도형 하나를 DXF 에 적는다. 자식이 있으면 자식까지 따라 내려간다.""" if not isinstance(entity, dict): return layer = _layer_name(entity.get("layerId")) if layer not in layers: space.doc.layers.add(layer) layers.add(layer) attribs = {"layer": layer} shape = entity.get("shapeData") or {} kind = entity.get("type") if kind == "Image": # 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다. skipped["Image"] = skipped.get("Image", 0) + 1 return start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) if start and end: space.add_line(start, end, dxfattribs=attribs) elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): options = shape.get("options") or {} height = float(options.get("fontSize") or _DEFAULT_TEXT_MM) direction = _xy(options.get("textDirection")) or (1.0, 0.0) rotation = math.degrees(math.atan2(direction[1], direction[0])) text = space.add_text( shape["label"], dxfattribs={**attribs, "height": height, "rotation": rotation}, ) text.set_placement(base) elif point := _xy(shape.get("point")): space.add_point(point, dxfattribs=attribs) elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): radius = float(shape["radius"]) start_angle = shape.get("startAngle") end_angle = shape.get("endAngle") if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)): space.add_arc( center, radius, math.degrees(float(start_angle)), math.degrees(float(end_angle)), dxfattribs=attribs, ) else: space.add_circle(center, radius, dxfattribs=attribs) else: vertices = [xy for vertex in shape.get("points") or [] if (xy := _xy(vertex))] if len(vertices) >= 2: space.add_lwpolyline(vertices, close=True, dxfattribs=attribs) for child in entity.get("children") or []: _add_entity(space, child, layers, skipped) def drawing_to_dxf( drawing: dict[str, Any], version: str = _DXF_VERSION ) -> tuple[bytes, dict[str, int]]: """캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다.""" entities = drawing.get("entities") if not isinstance(entities, list) or not entities: raise ValueError("내보낼 도형이 없습니다.") document = ezdxf.new(version) space = document.modelspace() layers: set[str] = {layer.dxf.name for layer in document.layers} skipped: dict[str, int] = {} for entity in entities: _add_entity(space, entity, layers, skipped) with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: path = Path(temporary) / "drawing.dxf" document.saveas(path) return path.read_bytes(), skipped def _dxf2dwg_path() -> str | None: """LibreDWG 의 DWG 쓰기 도구 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" configured = os.getenv("LIBREDWG_DXF2DWG_PATH", "").strip() if configured: return configured if Path(configured).is_file() else None return bundled_tool("dxf2dwg.exe") or shutil.which("dxf2dwg") def dxf_to_dwg(dxf_bytes: bytes) -> bytes: """DXF 를 DWG 로 바꾼다. 변환기가 없으면 「DXF 로 받으라」는 안내와 함께 실패.""" converter = _dxf2dwg_path() if not converter: raise ValueError("이 서버는 아직 DWG 로 내보내지 못합니다. DXF 로 내려받아 주십시오.") with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: work_dir = Path(temporary) source = work_dir / "drawing.dxf" target = work_dir / "drawing.dwg" source.write_bytes(dxf_bytes) 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", exc) raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") from exc if not target.is_file() or target.stat().st_size == 0: raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") return target.read_bytes() def export_drawing(drawing: dict[str, Any], file_format: str) -> tuple[bytes, dict[str, int]]: """도면을 요청한 형식(dxf·dwg) 파일 바이트로 낸다.""" if file_format == "dxf": return drawing_to_dxf(drawing) if file_format == "dwg": dxf_bytes, skipped = drawing_to_dxf(drawing, _DWG_SOURCE_VERSION) return dxf_to_dwg(dxf_bytes), skipped raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.")