"""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 ezdxf.enums import TextEntityAlignment 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}, ) # 자리표는 칸 한가운데에 선다 — 내보낸 파일에서도 같은 자리에 오게 가운데 맞춤. if options.get("boxWidth") and options.get("boxHeight"): text.set_placement(base, align=TextEntityAlignment.MIDDLE_CENTER) else: 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 로만 내보낼 수 있습니다.")