Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.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

264 lines
10 KiB
Python

"""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
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
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 _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": 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 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