diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index f06569d3..37d36b96 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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 { return requestJson(`/projects/${projectId}/frame-template`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py new file mode 100644 index 00000000..fbbc3e7c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -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 로만 내보낼 수 있습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py index 252d27e9..2498229b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -205,8 +205,7 @@ _DWG_VERSIONS: dict[str, str] = { "AC1032": "2018", } _SAVE_AS_GUIDE = ( - "캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤" - " 올려 주십시오." + "캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오." ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index eb7f04c1..e8896077 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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": "기본 도각으로 되돌리지 못했습니다."}, - ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py new file mode 100644 index 00000000..41f3e25e --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -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": "기본 도각으로 되돌리지 못했습니다."}, + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index f722a0a3..6cbb3ce1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -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): """도각 편집 화면이 [완료]에서 넘기는 도각.""" diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 7db46664..1f07d90c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 { } } + /** + * 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시). + * 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다. + */ + async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise { + 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 { 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 { 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; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index a1bc7986..c401ee26 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -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 내보내기', diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index d7b3b99f..d9395713 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -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 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts index 487f2955..0ca203cd 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -198,7 +198,7 @@ export const RIBBON_TABS: RibbonTab[] = [ { label: '내보내기', big: ['EXPORT'], - commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'], + commands: ['EXPORTDXF', 'EXPORTDWG', 'EXPORTSVG', 'EXPORTPNG', 'QSAVE'], }, ], }, diff --git a/main.py b/main.py index 0c9dce8a..7e6c2f9d 100644 --- a/main.py +++ b/main.py @@ -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)