Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py
T
eomsangdonandClaude Opus 5.5 be4f3d33ac feat(B07): 클립보드 DXF 글을 웹 CAD 도형으로 바꾸는 서버 길
오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글을 받는 POST /api/projects/dxf-clip 추가
(프로젝트 무관 · M02 양식도 씀 · 저장 안 함). 변환은 기존 도각 불러오기 엔진을 그대로 쓰고
dxf_text_to_entities 만 더함 — 엔티티 layerId 는 DXF 도면층 이름 그대로 · 쓰인 층 이름과
층 색을 함께 돌려줌. 시험 = 견본 DXF(선 · 폴리선 · 원 · 글 · 도면층 둘) 3건.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:47 +09:00

306 lines
12 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 io
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
from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode
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
# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`.
# 별도 실행 파일로만 부른다(라이브러리로 품지 않는다) — 자세한 것은 그 폴더의 README.
_BUNDLED_DIR = Path(__file__).resolve().parent / "openwebcad" / "tools" / "libredwg"
def bundled_tool(name: str) -> str | None:
"""동봉한 변환기 경로. 없으면 None."""
path = _BUNDLED_DIR / name
return str(path) if path.is_file() else None
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 _plain(label: str) -> str:
"""옛 DXF 는 한글을 유니코드 escape 로 적는다 — 글자로 되돌린다."""
return decode_dxf_unicode(label) if has_dxf_unicode(label) else label
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": _plain(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 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
return _document_entities(ezdxf.readfile(str(path)))
def _layer_color(document: Any, name: str) -> str | None:
"""도면층 색(ACI) → 화면 색. 꺼진 층은 음수로 적히므로 절댓값."""
try:
aci = abs(int(document.layers.get(name).dxf.color))
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
except Exception:
return None
def dxf_text_to_entities(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}]).
엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다.
"""
document = ezdxf.read(io.StringIO(text))
entities = _document_entities(document)
names = sorted({str(entity["layerId"]) for entity in entities})
return entities, [{"name": name, "color": _layer_color(document, name)} for name in names]
def _document_entities(document: Any) -> list[dict[str, Any]]:
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 bundled_tool("dwg2dxf.exe") or 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