Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py
T
eomsangdonandClaude Opus 5 2b6e76cd1c feat(B07): 도면 DXF/DWG 내보내기 추가
- 내보내기 엔진 신설 — 캐드 도면(JSON)을 ezdxf 로 DXF 작성(선·폴리선·글자·점·원·호, 도면층 유지), DWG 는 LibreDWG dxf2dwg 를 별도 프로세스로 호출
- POST /{project_id}/drawing-export 신설, 한글 파일명은 RFC 5987 방식으로 전달, 담지 못한 그림 수는 X-Aislo-Skipped 헤더로 통지
- CAD 출력 리본에 「DXF 내보내기」·「DWG 내보내기」 추가 — 캐드가 부모에 요청하면 부모가 서버 파일을 받아 내려받기
- 도각·내보내기 엔드포인트를 B07_DesignDetail_Router_Frame 으로 분리 (700줄 제한)
- 그림(Image)은 DXF 외부 참조 방식이라 제외하고 개수를 안내

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:38:55 +09:00

160 lines
6.4 KiB
Python

"""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
logger = logging.getLogger(__name__)
# 내보내는 DXF 형식 — LibreDWG 가 읽어 DWG 로 바꿀 수 있는 범위에 맞춘다.
_DXF_VERSION = "R2018"
_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]) -> tuple[bytes, dict[str, int]]:
"""캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다."""
entities = drawing.get("entities")
if not isinstance(entities, list) or not entities:
raise ValueError("내보낼 도형이 없습니다.")
document = ezdxf.new(_DXF_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 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) 파일 바이트로 낸다."""
dxf_bytes, skipped = drawing_to_dxf(drawing)
if file_format == "dxf":
return dxf_bytes, skipped
if file_format == "dwg":
return dxf_to_dwg(dxf_bytes), skipped
raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.")