From 8ebb553259d7ead1f9650a0cc4832713888174e5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 14:23:39 +0900 Subject: [PATCH] auto: 2026-09-28 14:23 (ESD_LAPTOP) --- .../B07_DesignDetail_Engine_Frame_Import.py | 23 ++++-- .../B07_DesignDetail_Router_Frame.py | 32 +++++++- B07_DesignDetail/openwebcad/src/App.css | 22 +++++- .../openwebcad/src/components/DropHint.tsx | 25 ++++++ .../openwebcad/src/components/Toolbar.tsx | 4 +- .../openwebcad/src/components/WebclipHint.tsx | 23 ------ .../src/integration/dxf-clip-paste.ts | 79 ++++++++++++++----- .../openwebcad/src/integration/dxf-clip.ts | 6 +- resources/tester/cad/dxf-clip.test.ts | 2 +- resources/tester/test_b07_dxf_clip.py | 37 ++++++++- 10 files changed, 194 insertions(+), 59 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/components/DropHint.tsx delete mode 100644 B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py index ba736690..8472d92f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -209,11 +209,19 @@ def _layer_color(document: Any, name: str) -> str | None: def dxf_text_to_entities(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}]). + """클립보드 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}]).""" + return _entities_with_layers(ezdxf.read(io.StringIO(text))) - 엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다. - """ - document = ezdxf.read(io.StringIO(text)) + +def drawing_file_to_entities( + filename: str, data: bytes +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """끌어다 놓은 DXF·DWG 파일 → (엔티티 목록, 쓰인 도면층 [{name, color}]).""" + return _entities_with_layers(_read_drawing_file(filename, data)) + + +def _entities_with_layers(document: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다.""" 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] @@ -291,6 +299,11 @@ def _dwg_to_dxf(source: Path, work_dir: Path) -> Path: def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: """올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다.""" + return _document_entities(_read_drawing_file(filename, data)) + + +def _read_drawing_file(filename: str, data: bytes) -> Any: + """DXF·DWG 파일 → ezdxf 문서(메모리에 다 읽음 · 임시 폴더는 바로 지움).""" suffix = Path(filename).suffix.lower() if suffix not in (".dxf", ".dwg"): raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.") @@ -300,6 +313,6 @@ def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: source.write_bytes(data) target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source try: - return dxf_to_entities(target) + return ezdxf.readfile(str(target)) except ezdxf.DXFError as exc: raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py index f4686598..2633269b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -19,6 +19,7 @@ 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 ( + drawing_file_to_entities, dxf_text_to_entities, import_frame_file, ) @@ -151,7 +152,7 @@ async def import_frame_template( class DxfClipRequest(BaseModel): - """오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글.""" + """클립보드에 담긴 DXF 글.""" text: str @@ -189,6 +190,35 @@ async def paste_dxf_clip(request: DxfClipRequest) -> JSONResponse: ) +@router.post("/dxf-clip/file") +async def paste_drawing_file(file: UploadFile = File(...)) -> JSONResponse: + """끌어다 놓은 DXF·DWG 파일(오토캐드 WBLOCK·DXFOUT) → 붙일 엔티티 · 도면층 · 저장 안 함.""" + try: + data = await file.read() + if len(data) > _FRAME_IMPORT_MAX_BYTES: + raise ValueError("도면 파일이 너무 큽니다(20MB 넘음).") + entities, layers = await asyncio.to_thread( + drawing_file_to_entities, file.filename or "", data + ) + 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 Exception: + logger.exception("B07 도면 파일 붙여넣기 실패: %s", file.filename) + 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 diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 18b788aa..62b2b8d4 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -520,8 +520,8 @@ body > canvas[data-id="canvas"] { background: var(--cad-chrome-raised); box-shadow: var(--shadow-lg); } -/* 오토캐드 WEBCLIP 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */ -.cad-webclip-hint { +/* 오토캐드 파일 끌어다 놓기 안내 — 캔버스 오른쪽 위 흐린 글씨(빠른 특성 창이 뜨면 그 아래로 가려짐) */ +.cad-drop-hint { position: fixed; z-index: 2; top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 8px); @@ -540,13 +540,27 @@ body > canvas[data-id="canvas"] { opacity: 0.55; cursor: pointer; } -.cad-webclip-hint:hover { +.cad-drop-hint:hover { opacity: 1; } -.cad-webclip-hint__install { +.cad-drop-hint__steps { + display: flex; + flex-direction: column; + align-items: flex-end; color: var(--cad-text-dim); font-size: 10px; } +/* 파일을 끄는 동안 — 화면 가장자리 흐린 테두리(`dxf-clip-paste.ts`) */ +body.cad-file-dragging::after { + content: ''; + position: fixed; + z-index: 50; + inset: 6px; + border: 2px dashed var(--cad-accent); + border-radius: 6px; + opacity: 0.6; + pointer-events: none; +} .cad-view-controls button { width: 34px; diff --git a/B07_DesignDetail/openwebcad/src/components/DropHint.tsx b/B07_DesignDetail/openwebcad/src/components/DropHint.tsx new file mode 100644 index 00000000..8f489acc --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/DropHint.tsx @@ -0,0 +1,25 @@ +/** 화면 오른쪽 위 흐린 안내 — 오토캐드에서 저장한 파일을 끌어다 놓는 법 · 누르면 순서 (B07 · M02 양식 둘 다) */ +import { type FC, useState } from 'react'; +import { isDrawingReadOnly } from '../state'; + +export const DropHint: FC = () => { + const [open, setOpen] = useState(false); + if (isDrawingReadOnly()) return null; + return ( + + ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 921b682d..426d4bbf 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -16,7 +16,7 @@ import { isRibbonCollapsed } from './ui-state'; import { getActiveToolActor } from '../state'; import type { Tool } from '../tools'; import { ViewControls } from './ViewControls'; -import { WebclipHint } from './WebclipHint'; +import { DropHint } from './DropHint'; export const Toolbar: FC = () => { useCadRefresh(); @@ -57,7 +57,7 @@ export const Toolbar: FC = () => { onToggleCollapsed={() => setPanelCollapsed((value) => !value)} /> - + {commandLineVisible && } diff --git a/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx b/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx deleted file mode 100644 index 1f561823..00000000 --- a/B07_DesignDetail/openwebcad/src/components/WebclipHint.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** 화면 오른쪽 위 흐린 안내 — 오토캐드 WEBCLIP → 여기서 Ctrl+V · 누르면 설치 한 줄 (B07 · M02 양식 둘 다) */ -import { type FC, useState } from 'react'; -import { isDrawingReadOnly } from '../state'; - -export const WebclipHint: FC = () => { - const [open, setOpen] = useState(false); - if (isDrawingReadOnly()) return null; - return ( - - ); -}; diff --git a/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts b/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts index e2f96e8f..2be08efa 100644 --- a/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts +++ b/B07_DesignDetail/openwebcad/src/integration/dxf-clip-paste.ts @@ -1,8 +1,11 @@ /** - * Ctrl+V — 시스템 클립보드 글이 DXF(오토캐드 WEBCLIP)면 서버에서 도형으로 바꿔 현재 도면에 - * 원좌표 그대로 붙이고 고른 상태로 둔다 · DXF 가 아니면 앱 안 클립보드 붙여넣기(PASTECLIP). - * 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 — - * `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도). + * 오토캐드 도형 붙이기 — 두 길 모두 서버가 DXF 를 도형으로 바꿔 현재 도면에 원좌표 그대로 붙이고 + * 고른 상태로 둔다(되돌리기 한 번에 빠짐). + * - 끌어다 놓기: 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일, 또는 도면 파일 통째. + * 끄는 동안 `body.cad-file-dragging` 으로 흐린 테두리. + * - Ctrl+V: 시스템 클립보드 글이 DXF 면 같은 길 · 아니면 앱 안 붙여넣기(PASTECLIP). + * 키 처리(`input-controller.ts`)는 Ctrl+V 의 기본 동작을 막지 않아 브라우저가 `paste` 를 쏜다 — + * `paste` 이벤트의 글은 권한 없이 읽힘(iframe 안에서도). */ import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command'; @@ -21,12 +24,25 @@ import { type ClipLayer, isDxfText, prepareClip } from './dxf-clip'; const DXF_CLIP_URL = '/api/projects/dxf-clip'; export async function pasteDxfText(text: string): Promise { - const response = await fetch(DXF_CLIP_URL, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); + return pasteServerClip( + await fetch(DXF_CLIP_URL, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + ); +} + +export async function pasteDrawingFile(file: File): Promise { + const form = new FormData(); + form.append('file', file); + return pasteServerClip( + await fetch(`${DXF_CLIP_URL}/file`, { method: 'POST', credentials: 'include', body: form }) + ); +} + +async function pasteServerClip(response: Response): Promise { const payload = (await response.json().catch(() => ({}))) as { entities?: JsonEntity[]; layers?: ClipLayer[]; @@ -43,6 +59,20 @@ export async function pasteDxfText(text: string): Promise { return drawing.entities.length; } +function reportPaste(job: Promise): void { + job + .then((count) => toast.success(`오토캐드 도형 ${count}개를 붙여넣었습니다.`)) + .catch((error: unknown) => + toast.error(error instanceof Error ? error.message : '도형을 붙여넣지 못했습니다.') + ); +} + +function refuseReadOnly(): boolean { + if (!isDrawingReadOnly()) return false; + toast.info('볼 수만 있는 도면입니다 — 붙여넣을 수 없습니다.'); + return true; +} + export function registerDxfClipPaste(): void { document.addEventListener('paste', (event: ClipboardEvent) => { if ((event.target as HTMLElement | null)?.closest?.('input, textarea, select')) return; @@ -52,14 +82,25 @@ export function registerDxfClipPaste(): void { if (hasClipboardContent()) runCommandInput('PASTECLIP'); return; } - if (isDrawingReadOnly()) { - toast.info('볼 수만 있는 도면입니다 — 붙여넣을 수 없습니다.'); - return; - } - pasteDxfText(text) - .then((count) => toast.success(`오토캐드 도형 ${count}개를 붙여넣었습니다.`)) - .catch((error: unknown) => - toast.error(error instanceof Error ? error.message : 'DXF 를 붙여넣지 못했습니다.') - ); + if (!refuseReadOnly()) reportPaste(pasteDxfText(text)); + }); + + // 파일을 끌 때만 — 앱 안 끌기(글 · 패널)는 건드리지 않음. 기본 동작(파일 열기로 화면 이동)은 막음. + const dragging = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false; + const setDragging = (on: boolean) => document.body.classList.toggle('cad-file-dragging', on); + document.addEventListener('dragover', (event) => { + if (!dragging(event)) return; + event.preventDefault(); + setDragging(true); + }); + document.addEventListener('dragleave', (event) => { + if (!event.relatedTarget) setDragging(false); + }); + document.addEventListener('drop', (event) => { + if (!dragging(event)) return; + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer?.files[0]; + if (file && !refuseReadOnly()) reportPaste(pasteDrawingFile(file)); }); } diff --git a/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts b/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts index 8c94336a..3c914fba 100644 --- a/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts +++ b/B07_DesignDetail/openwebcad/src/integration/dxf-clip.ts @@ -1,7 +1,7 @@ /** - * 오토캐드 → 웹 CAD 붙여넣기(WEBCLIP) — 순수 부분. - * 오토캐드 쪽 `resources/tools/autocad/aislo_webclip.lsp` 가 고른 개체를 DXF 글로 클립보드에 넣고, - * 여기서 그 글을 알아보고 서버(`/api/projects/dxf-clip`)가 돌려준 JSON 을 현재 도면에 맞춘다. + * 오토캐드 → 웹 CAD 붙여넣기 — 순수 부분. + * 오토캐드 기본 명령(WBLOCK · DXFOUT)으로 저장한 파일을 끌어다 놓거나 DXF 글을 Ctrl+V 하면, + * 서버(`/api/projects/dxf-clip` · `/file`)가 돌려준 JSON 을 여기서 현재 도면에 맞춘다. * DOM · 상태를 안 만지는 것만 둔다(시험이 바로 부름) — 잇기는 `dxf-clip-paste.ts`. */ import type { Layer } from '../App.types.ts'; diff --git a/resources/tester/cad/dxf-clip.test.ts b/resources/tester/cad/dxf-clip.test.ts index d8959522..2af4d47b 100644 --- a/resources/tester/cad/dxf-clip.test.ts +++ b/resources/tester/cad/dxf-clip.test.ts @@ -1,5 +1,5 @@ /** - * 오토캐드 WEBCLIP 붙여넣기 순수 부분(`integration/dxf-clip.ts`) 검증. + * 오토캐드 도형 붙이기 순수 부분(`integration/dxf-clip.ts`) 검증. * 실행: node --experimental-strip-types resources/tester/cad/dxf-clip.test.ts * (타입만 가져오는 모듈이라 vitest · jsdom 없이 바로 돎) */ diff --git a/resources/tester/test_b07_dxf_clip.py b/resources/tester/test_b07_dxf_clip.py index 3ac6cafb..69f419dc 100644 --- a/resources/tester/test_b07_dxf_clip.py +++ b/resources/tester/test_b07_dxf_clip.py @@ -1,4 +1,4 @@ -"""B07 DXF 붙여넣기 — 오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글 → 웹 CAD 엔티티 · 도면층.""" +"""B07 DXF 붙여넣기 — 클립보드 DXF 글 · 끌어다 놓은 DXF·DWG 파일 → 웹 CAD 엔티티 · 도면층.""" from pathlib import Path @@ -42,3 +42,38 @@ 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" + + +# 끌어다 놓기 — 오토캐드 WBLOCK(DWG) · DXFOUT(DXF) 로 저장한 파일 +DWG = Path("resources/knowledge/original/경쟁사 프로그램/오솔길/OSK_sym/REpyo.dwg") + + +def test_file_route_reads_dxf(): + response = client.post( + "/api/projects/dxf-clip/file", + files={"file": ("클립.dxf", SAMPLE.encode("utf-8"), "application/octet-stream")}, + ) + assert response.status_code == 200 + body = response.json() + assert body["entity_count"] == 4 + assert [layer["name"] for layer in body["layers"]] == ["구조물", "치수글"] + + +def test_file_route_reads_dwg(): + response = client.post( + "/api/projects/dxf-clip/file", + files={"file": (DWG.name, DWG.read_bytes(), "application/octet-stream")}, + ) + assert response.status_code == 200 + assert response.json()["entity_count"] > 0 + + +def test_file_route_explains_unreadable(): + wrong = client.post("/api/projects/dxf-clip/file", files={"file": ("a.pdf", b"%PDF", "x")}) + assert wrong.status_code == 400 + assert "DXF 또는 DWG" in wrong.json()["message"] + newer = client.post( + "/api/projects/dxf-clip/file", files={"file": ("a.dwg", b"AC1035" + b"\0" * 64, "x")} + ) + assert newer.status_code == 400 + assert "AutoCAD 2018 DWG 또는 DXF" in newer.json()["message"]