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>
This commit is contained in:
@@ -210,6 +210,29 @@ export async function importFrameTemplate(
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** 지금 도면을 DXF·DWG 파일로 받는다 (2026-09-06 사용자 지시) — 파일은 서버가 만든다. */
|
||||
export async function exportDrawing(
|
||||
projectId: string,
|
||||
drawing: CadDrawing,
|
||||
fileFormat: "dxf" | "dwg",
|
||||
name: string,
|
||||
): Promise<{ blob: Blob; skipped: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drawing-export`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ drawing, file_format: fileFormat, name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = (await response.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(detail.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
skipped: Number(response.headers.get("X-Aislo-Skipped") ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
|
||||
export function resetFrameTemplate(projectId: string): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 내보내는 DXF 형식 — LibreDWG 가 읽어 DWG 로 바꿀 수 있는 범위에 맞춘다.
|
||||
_DXF_VERSION = "R2018"
|
||||
_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},
|
||||
)
|
||||
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]) -> tuple[bytes, dict[str, int]]:
|
||||
"""캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다."""
|
||||
entities = drawing.get("entities")
|
||||
if not isinstance(entities, list) or not entities:
|
||||
raise ValueError("내보낼 도형이 없습니다.")
|
||||
document = ezdxf.new(_DXF_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 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) 파일 바이트로 낸다."""
|
||||
dxf_bytes, skipped = drawing_to_dxf(drawing)
|
||||
if file_format == "dxf":
|
||||
return dxf_bytes, skipped
|
||||
if file_format == "dwg":
|
||||
return dxf_to_dwg(dxf_bytes), skipped
|
||||
raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.")
|
||||
@@ -205,8 +205,7 @@ _DWG_VERSIONS: dict[str, str] = {
|
||||
"AC1032": "2018",
|
||||
}
|
||||
_SAVE_AS_GUIDE = (
|
||||
"캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤"
|
||||
" 올려 주십시오."
|
||||
"캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
@@ -26,16 +26,9 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
extract_quantity_table,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
clear_company_template,
|
||||
company_template_path,
|
||||
frame_document,
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
use_company_templates,
|
||||
use_title_fields,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
@@ -62,10 +55,6 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingInvalidateResponse,
|
||||
DesignDrawingListResponse,
|
||||
DesignDrawingResponse,
|
||||
FrameTemplateImportResponse,
|
||||
FrameTemplateResponse,
|
||||
FrameTemplateSaveRequest,
|
||||
FrameTemplateSaveResponse,
|
||||
)
|
||||
from common_util.common_util_drainage_context import load_drainage_context
|
||||
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
|
||||
@@ -566,104 +555,3 @@ async def invalidate_design_drawing(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
|
||||
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
|
||||
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
use_company_templates(company_dir)
|
||||
return FrameTemplateResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_template_document(),
|
||||
customized=company_template_path(company_dir).is_file(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
|
||||
_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse)
|
||||
async def import_frame_template(
|
||||
project_id: UUID, file: UploadFile = File(...)
|
||||
) -> FrameTemplateImportResponse | JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
|
||||
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
"""
|
||||
try:
|
||||
data = await file.read()
|
||||
if len(data) > _FRAME_IMPORT_MAX_BYTES:
|
||||
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
|
||||
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
|
||||
validate_template_entities(entities)
|
||||
return FrameTemplateImportResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_document(entities),
|
||||
entity_count=len(entities),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 불러오기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def put_frame_template(
|
||||
project_id: UUID, request: FrameTemplateSaveRequest
|
||||
) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
||||
|
||||
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
||||
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
||||
"""
|
||||
try:
|
||||
entities = request.drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
raise ValueError("도각 엔티티가 없습니다.")
|
||||
company_dir = await _company_dir(project_id)
|
||||
await asyncio.to_thread(save_company_template, company_dir, entities)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id))
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
||||
|
||||
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
||||
"""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
removed = await asyncio.to_thread(clear_company_template, company_dir)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""B07 도각·내보내기 라우터 (B07_DesignDetail_Router 에서 분리, 700줄 제한).
|
||||
|
||||
도각을 읽고 고치고 되돌리는 길, 외부 도각 파일(DXF·DWG) 불러오기, 그리고 캐드 도면을
|
||||
DXF·DWG 파일로 내보내는 길을 한곳에 둔다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, Response, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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_Template import (
|
||||
clear_company_template,
|
||||
company_template_path,
|
||||
frame_document,
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
use_company_templates,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DrawingExportRequest,
|
||||
FrameTemplateImportResponse,
|
||||
FrameTemplateResponse,
|
||||
FrameTemplateSaveRequest,
|
||||
FrameTemplateSaveResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
async def _company_dir(project_id: UUID) -> Path:
|
||||
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
return root.parent.parent
|
||||
|
||||
|
||||
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
|
||||
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
|
||||
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
use_company_templates(company_dir)
|
||||
return FrameTemplateResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_template_document(),
|
||||
customized=company_template_path(company_dir).is_file(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
|
||||
_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@router.post("/{project_id}/drawing-export")
|
||||
async def export_drawing_file(project_id: UUID, request: DrawingExportRequest) -> Response:
|
||||
"""캐드 화면의 도면을 DXF·DWG 파일로 내려보낸다 (2026-09-06 사용자 지시).
|
||||
|
||||
DWG 는 LibreDWG 가 서버에 있을 때만 나간다 — 없으면 「DXF 로 받으라」는 안내로 떨어진다.
|
||||
"""
|
||||
try:
|
||||
data, skipped = await asyncio.to_thread(
|
||||
export_drawing, request.drawing, request.file_format
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도면 내보내기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도면을 내보내지 못했습니다."},
|
||||
)
|
||||
name = re.sub(r"[^0-9A-Za-z가-힣_.-]", "_", request.name or "drawing")[:120] or "drawing"
|
||||
# 한글 파일 이름은 헤더에 그대로 못 싣는다(latin-1) — 옛 브라우저용 영문 이름과
|
||||
# UTF-8 이름을 함께 준다.
|
||||
ascii_name = re.sub(r"[^0-9A-Za-z_.-]", "_", name) or "drawing"
|
||||
encoded_name = quote(f"{name}.{request.file_format}")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="{ascii_name}.{request.file_format}"; '
|
||||
f"filename*=UTF-8''{encoded_name}"
|
||||
),
|
||||
# 그림처럼 못 담은 도형 수 — 화면이 안내 문구를 띄우는 데 쓴다.
|
||||
"X-Aislo-Skipped": str(sum(skipped.values())),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse)
|
||||
async def import_frame_template(
|
||||
project_id: UUID, file: UploadFile = File(...)
|
||||
) -> FrameTemplateImportResponse | JSONResponse:
|
||||
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
|
||||
|
||||
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
||||
"""
|
||||
try:
|
||||
data = await file.read()
|
||||
if len(data) > _FRAME_IMPORT_MAX_BYTES:
|
||||
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
|
||||
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
|
||||
validate_template_entities(entities)
|
||||
return FrameTemplateImportResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_document(entities),
|
||||
entity_count=len(entities),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 불러오기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def put_frame_template(
|
||||
project_id: UUID, request: FrameTemplateSaveRequest
|
||||
) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
||||
|
||||
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
||||
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
||||
"""
|
||||
try:
|
||||
entities = request.drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
raise ValueError("도각 엔티티가 없습니다.")
|
||||
company_dir = await _company_dir(project_id)
|
||||
await asyncio.to_thread(save_company_template, company_dir, entities)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id))
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
||||
|
||||
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
||||
"""
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
removed = await asyncio.to_thread(clear_company_template, company_dir)
|
||||
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."},
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DesignDrawingItem(BaseModel):
|
||||
@@ -113,6 +113,15 @@ class FrameTemplateImportResponse(BaseModel):
|
||||
entity_count: int
|
||||
|
||||
|
||||
class DrawingExportRequest(BaseModel):
|
||||
"""캐드 화면의 도면을 DXF·DWG 파일로 내보내는 요청 (2026-09-06 사용자 지시)."""
|
||||
|
||||
drawing: dict[str, Any]
|
||||
file_format: str = Field(default="dxf", pattern="^(dxf|dwg)$")
|
||||
# 내려받을 파일 이름(확장자 제외). 비우면 도면 id 를 쓴다.
|
||||
name: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class FrameTemplateSaveRequest(BaseModel):
|
||||
"""도각 편집 화면이 [완료]에서 넘기는 도각."""
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmDesignDrawing,
|
||||
exportDrawing,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
invalidateDesignDrawing,
|
||||
@@ -82,6 +83,7 @@ const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
||||
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
||||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
@@ -403,6 +405,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시).
|
||||
* 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다.
|
||||
*/
|
||||
async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const { drawing } = await requestCadDrawing();
|
||||
const name = currentDrawing?.label ?? "도면";
|
||||
const result = await exportDrawing(projectId as string, drawing, fileFormat, name);
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(result.blob);
|
||||
link.download = `${name}.${fileFormat}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
showToast(
|
||||
result.skipped > 0
|
||||
? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.`
|
||||
: `${fileFormat.toUpperCase()} 로 내보냈습니다.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "도면을 내보내지 못했습니다.", "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
const frameEditor = createFrameTemplateEditor({
|
||||
projectId: projectId as string,
|
||||
sendLoad,
|
||||
@@ -421,6 +451,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
drawing?: CadDrawing;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
fileFormat?: "dxf" | "dwg";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
@@ -462,6 +493,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
|
||||
void exportDrawingFile(message.fileFormat);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
|
||||
import { clearRecovery, restoreRecovery } from '../helpers/autosave';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { requestDrawingExport } from '../integration/aislo-drawing-bridge';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { redo, undo } from '../state';
|
||||
@@ -44,6 +45,26 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
return 'JSON 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORTDXF',
|
||||
label: 'DXF 내보내기',
|
||||
glyph: '📐',
|
||||
hint: '지금 도면을 DXF 파일로 내려받는다',
|
||||
run: () => {
|
||||
requestDrawingExport('dxf');
|
||||
return 'DXF 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORTDWG',
|
||||
label: 'DWG 내보내기',
|
||||
glyph: '📁',
|
||||
hint: '지금 도면을 DWG 파일로 내려받는다 (서버에 변환기가 있을 때)',
|
||||
run: () => {
|
||||
requestDrawingExport('dwg');
|
||||
return 'DWG 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORTSVG',
|
||||
label: 'SVG 내보내기',
|
||||
|
||||
@@ -32,6 +32,7 @@ export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed';
|
||||
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
|
||||
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
|
||||
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
|
||||
export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file';
|
||||
|
||||
interface DrawingLoadMessage {
|
||||
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
||||
@@ -61,6 +62,14 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
|
||||
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시).
|
||||
* 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다.
|
||||
*/
|
||||
export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') {
|
||||
notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat });
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
|
||||
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
|
||||
|
||||
@@ -198,7 +198,7 @@ export const RIBBON_TABS: RibbonTab[] = [
|
||||
{
|
||||
label: '내보내기',
|
||||
big: ['EXPORT'],
|
||||
commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'],
|
||||
commands: ['EXPORTDXF', 'EXPORTDWG', 'EXPORTSVG', 'EXPORTPNG', 'QSAVE'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ from B06_Section.B06_Section_Router_Confirm import (
|
||||
router as b06_section_confirm_router,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
||||
from common_util.common_util_auth import (
|
||||
require_company,
|
||||
@@ -405,6 +406,7 @@ app.include_router(b05_structures_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user