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
This commit is contained in:
2026-09-28 13:44:47 +09:00
co-authored by Claude Opus 5.5
parent 91cc795102
commit be4f3d33ac
4 changed files with 3311 additions and 2 deletions
@@ -16,6 +16,7 @@ DXF 로 저장해 달라」는 안내로 떨어진다.
from __future__ import annotations
import io
import logging
import os
import shutil
@@ -194,7 +195,31 @@ def _expand(entity: Any) -> list[Any]:
def dxf_to_entities(path: Path) -> list[dict[str, Any]]:
"""DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
document = ezdxf.readfile(str(path))
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():
@@ -11,12 +11,17 @@ from pathlib import Path
from urllib.parse import quote
from uuid import UUID
import ezdxf
from fastapi import APIRouter, File, Response, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import (
dxf_text_to_entities,
import_frame_file,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_document,
frame_template_document,
@@ -145,6 +150,45 @@ async def import_frame_template(
)
class DxfClipRequest(BaseModel):
"""오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글."""
text: str
@router.post("/dxf-clip")
async def paste_dxf_clip(request: DxfClipRequest) -> JSONResponse:
"""클립보드 DXF 글 → 붙일 엔티티 · 도면층. 프로젝트 무관(M02 양식도 씀) · 저장 안 함."""
try:
if len(request.text) > _FRAME_IMPORT_MAX_BYTES:
raise ValueError("붙여넣을 도형이 너무 많습니다(20MB 넘음).")
entities, layers = await asyncio.to_thread(dxf_text_to_entities, request.text)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"entities": entities,
"layers": layers,
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except (UnicodeError, ezdxf.DXFError):
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "클립보드 DXF 를 읽지 못했습니다 — 깨진 글입니다.",
},
)
except Exception:
logger.exception("B07 DXF 붙여넣기 실패")
return JSONResponse(
status_code=500, content={"status": "error", "message": "DXF 를 붙여넣지 못했습니다."}
)
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
async def put_frame_template(
project_id: UUID, request: FrameTemplateSaveRequest
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
"""B07 DXF 붙여넣기 — 오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글 → 웹 CAD 엔티티 · 도면층."""
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import dxf_text_to_entities
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router
SAMPLE = Path("resources/tester/fixtures/b07_webclip_sample.dxf").read_text(encoding="utf-8")
app = FastAPI()
app.include_router(router)
client = TestClient(app)
def test_engine_keeps_layer_names_and_shapes():
entities, layers = dxf_text_to_entities(SAMPLE)
kinds = sorted(entity["type"] for entity in entities)
# 선 1 · 닫힌 폴리선 1 · 원(선분 묶음) 1 · 글 1
assert kinds == ["Line", "PolyLine", "PolyLine", "Text"]
assert {entity["layerId"] for entity in entities} == {"구조물", "치수글"}
assert [layer["name"] for layer in layers] == ["구조물", "치수글"]
assert layers[0]["color"] == "#ff0000" # ACI 1
text = next(entity for entity in entities if entity["type"] == "Text")
assert text["shapeData"]["label"] == "배수관 D600"
assert text["shapeData"]["basePoint"] == {"x": 5.0, "y": 50.0}
line = next(entity for entity in entities if entity["type"] == "Line")
assert line["shapeData"]["endPoint"] == {"x": 100.0, "y": 0.0} # 원좌표 그대로
def test_route_returns_entities():
response = client.post("/api/projects/dxf-clip", json={"text": SAMPLE})
assert response.status_code == 200
body = response.json()
assert body["entity_count"] == 4
assert [layer["name"] for layer in body["layers"]] == ["구조물", "치수글"]
def test_route_rejects_broken_text():
response = client.post("/api/projects/dxf-clip", json={"text": " 0\nSECTION\n 2\nENTITIES\n"})
assert response.status_code == 400
assert response.json()["status"] == "error"