Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py
T
eomsangdonandClaude Opus 5 b0c2e09b16 feat(B07): 자리표 칸 크기 조절·그림 비율 유지·편집 중 실제 값 미리보기
- 텍스트 자리표에 칸 크기(boxWidth/boxHeight) 도입 — basePoint 를 칸 중심으로 두고 가로·세로 가운데 정렬, 선택 시 칸 테두리 표시, DXF 내보내기도 중앙 정렬로 반영
- 그림은 칸 안에서 비율을 유지하며 맞춤(letterbox) — 칸을 늘려도 로고·서명이 찌그러지지 않음
- 자리표 패널에 선택 항목 칸 크기(가로·세로 mm) 입력 추가
- 도각 편집 중 자리표에 실제 값 미리보기 — 서버가 표제란 값(공사명·회사·담당자·로고·서명)을 함께 내려주고 화면만 값으로 표시, 저장값은 {{키}} 토큰 유지
- title_block_fields 공개화 및 frame-template 응답에 fields 추가

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

171 lines
7.0 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
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 로만 내보낼 수 있습니다.")