- 내보내기 엔진 신설 — 캐드 도면(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>
264 lines
10 KiB
Python
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
|