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:
2026-09-06 14:38:55 +09:00
co-authored by Claude Opus 5
parent ae719f22b2
commit 2b6e76cd1c
11 changed files with 448 additions and 117 deletions
@@ -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 를 골라 저장한 뒤 올려 주십시오."
)
+1 -113
View File
@@ -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": "기본 도각으로 되돌리지 못했습니다."},
)
+10 -1
View File
@@ -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'],
},
],
},
+2
View File
@@ -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)