From 784d6df801e74603c65ed62e70b898401ccc9cb4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 12:41:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EB=8F=84=EA=B0=81=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=8C=8C=EC=9D=BC=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0(DXF/DWG)=EC=99=80=20=EC=9E=90=EB=A6=AC=ED=91=9C=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EB=8F=84=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DXF -> 도각 JSON 변환 엔진 신설 (ezdxf) — 선·폴리선·원/호/타원/스플라인 평탄화·글자·점 변환, 블록·치수는 분해, 해치 등 미지원 요소는 제외 - DWG 는 ODA File Converter(.env ODA_CONVERTER_PATH) 경유 변환, 미설치 시 DXF 저장 안내로 폴백 - POST /{project_id}/frame-template/import 신설 — 파일을 편집 화면용 도면으로 반환(저장은 기존 [완료] 경로 유지), 20MB·2만 도형 상한 - 도각 편집 띠에 「파일 불러오기」 추가, 자리표 팔레트 신설 (글자 14종·그림 4종을 도면 중앙에 배치 후 이동) - CAD 텍스트 선택 시 회색 점선 외곽선 표시 (출력물에는 미포함) Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 57 +++-- .../B07_DesignDetail_Engine_Frame_Import.py | 230 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 10 +- B07_DesignDetail/B07_DesignDetail_Router.py | 39 ++- B07_DesignDetail/B07_DesignDetail_Schema.py | 9 + .../B07_DesignDetail_UI_FrameEdit.ts | 54 +++- .../B07_DesignDetail_UI_FramePlaceholders.ts | 196 +++++++++++++++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 2 +- .../B07_DesignDetail_UI_Panels.ts | 44 +--- .../B07_DesignDetail_UI_Style.css | 40 +++ .../openwebcad/src/entities/TextEntity.ts | 29 ++- 11 files changed, 635 insertions(+), 75 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_FramePlaceholders.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index e842eae7..f06569d3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -77,10 +77,7 @@ export interface CrossDesignInfo { cross_slope_pct?: number; paved?: boolean; ditch: DitchSpec; - road_edges?: Record< - "left" | "right", - { offset_m: number; elevation_m: number } - >; + road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -120,10 +117,7 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson( - path: string, - init: RequestInit = {}, -): Promise { +async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -134,17 +128,14 @@ async function requestJson( signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; - if (!response.ok) - throw new Error(payload.message ?? `HTTP ${response.status}`); + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timeoutId); } } -export function fetchDesignDrawingList( - projectId: string, -): Promise { +export function fetchDesignDrawingList(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -152,9 +143,7 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson( - `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, - ); + return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); } export function confirmDesignDrawing( @@ -172,10 +161,7 @@ export function confirmDesignDrawing( ); } -export function invalidateDesignDrawing( - projectId: string, - drawingId: string, -): Promise { +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, @@ -191,22 +177,39 @@ export interface FrameTemplateResponse { customized: boolean; } -export function fetchFrameTemplate( - projectId: string, -): Promise { +export function fetchFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate( - projectId: string, - drawing: CadDrawing, -): Promise { +export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), }); } +/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */ +export async function importFrameTemplate( + projectId: string, + file: File, +): Promise<{ drawing: CadDrawing; entity_count: number }> { + const form = new FormData(); + form.append("file", file); + // 파일 전송이라 requestJson(JSON 헤더·짧은 시한)을 쓰지 않는다. + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/frame-template/import`, { + method: "POST", + credentials: "include", + body: form, + }); + const payload = (await response.json()) as { + drawing: CadDrawing; + entity_count: number; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + /** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */ export function resetFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py new file mode 100644 index 00000000..ccdd1843 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -0,0 +1,230 @@ +"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다. + +고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다. +`.env` 의 `ODA_CONVERTER_PATH` 에 무료 변환기(ODA File Converter) 경로를 넣어 두면 DWG 를 +DXF 로 바꿔 읽고, 없으면 「DXF 로 저장해 올려 달라」는 안내로 떨어진다 (2026-09-06 사용자 확정). + +프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를 +직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다). +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tempfile +from math import cos, radians, sin +from pathlib import Path +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +import ezdxf +from ezdxf import colors as ezdxf_colors + +logger = logging.getLogger(__name__) + +_IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import") +_DEFAULT_COLOR = "#f5f7fa" +# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. +_FLATTEN_MM = 0.2 +_MAX_ENTITIES = 20000 + + +def _entity_id(index: int) -> str: + return str(uuid5(_IMPORT_NS, str(index))) + + +def _color(entity: Any) -> str: + """DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다.""" + try: + aci = int(entity.dxf.color) + if aci in (0, 256): # BYBLOCK · BYLAYER + return _DEFAULT_COLOR + red, green, blue = ezdxf_colors.aci2rgb(aci) + return f"#{red:02x}{green:02x}{blue:02x}" + except Exception: + return _DEFAULT_COLOR + + +def _point(x: float, y: float) -> dict[str, float]: + return {"x": float(x), "y": float(y)} + + +def _base(index: int, entity: Any, kind: str) -> dict[str, Any]: + return { + "id": _entity_id(index), + "type": kind, + "lineColor": _color(entity), + "lineWidth": 1, + "layerId": str(getattr(entity.dxf, "layer", "0")), + } + + +def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]: + return { + **_base(index, entity, "Line"), + "shapeData": { + "startPoint": _point(start[0], start[1]), + "endPoint": _point(end[0], end[1]), + }, + } + + +def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None: + """점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다.""" + vertices = [(float(p[0]), float(p[1])) for p in points] + if closed and len(vertices) > 2: + vertices.append(vertices[0]) + if len(vertices) < 2: + return None + children = [ + { + **_base(index, entity, "Line"), + "id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")), + "shapeData": { + "startPoint": _point(*vertices[seq]), + "endPoint": _point(*vertices[seq + 1]), + }, + } + for seq in range(len(vertices) - 1) + ] + return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children} + + +def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: + rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) + return { + **_base(index, entity, "Text"), + "shapeData": { + "label": label, + "basePoint": _point(insert[0], insert[1]), + "options": { + "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), + "textAlign": "left", + "textColor": _color(entity), + "fontSize": float(height) or 3.0, + "fontFamily": "sans-serif", + }, + }, + } + + +def _flatten(entity: Any) -> list[Any] | None: + """원·호·타원·스플라인을 선분 점열로 편다. 못 펴면 None.""" + try: + return list(entity.flattening(_FLATTEN_MM)) + except Exception: + return None + + +def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: + kind = entity.dxftype() + if kind == "LINE": + return [_line(index, entity, entity.dxf.start, entity.dxf.end)] + if kind == "LWPOLYLINE": + shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) + return [shape] if shape else [] + if kind == "POLYLINE": + points = [vertex.dxf.location for vertex in entity.vertices] + shape = _polyline(index, entity, points, bool(entity.is_closed)) + return [shape] if shape else [] + if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): + points = _flatten(entity) + if not points: + return [] + shape = _polyline(index, entity, points, kind in ("CIRCLE", "ELLIPSE")) + return [shape] if shape else [] + if kind == "POINT": + location = entity.dxf.location + return [ + { + **_base(index, entity, "Point"), + "shapeData": {"point": _point(location[0], location[1])}, + } + ] + if kind == "TEXT": + label = str(entity.dxf.text or "").strip() + if not label: + return [] + return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] + if kind == "MTEXT": + label = str(entity.plain_text() or "").strip() + if not label: + return [] + height = float(entity.dxf.char_height or 3.0) + return [_text(index, entity, label, entity.dxf.insert, height)] + return [] + + +def _expand(entity: Any) -> list[Any]: + """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" + if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): + try: + return list(entity.virtual_entities()) + except Exception: + logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) + return [] + return [entity] + + +def dxf_to_entities(path: Path) -> list[dict[str, Any]]: + """DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다.""" + document = ezdxf.readfile(str(path)) + entities: list[dict[str, Any]] = [] + index = 0 + for source in document.modelspace(): + for item in _expand(source): + entities.extend(_convert_entity(index, item)) + index += 1 + if len(entities) > _MAX_ENTITIES: + raise ValueError( + f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)." + " 도각만 남겨 다시 저장해 주십시오." + ) + if not entities: + raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.") + return entities + + +def _dwg_to_dxf(source: Path, work_dir: Path) -> Path: + """ODA File Converter 로 DWG 를 DXF 로 바꾼다. 변환기가 없으면 안내와 함께 실패.""" + converter = os.getenv("ODA_CONVERTER_PATH", "").strip() + if not converter or not Path(converter).is_file(): + raise ValueError( + "DWG 는 그대로 읽지 못합니다. 캐드에서 DXF 로 저장해 올려 주십시오." + " (서버에 ODA File Converter 를 두면 DWG 도 바로 읽습니다)" + ) + in_dir = work_dir / "in" + out_dir = work_dir / "out" + in_dir.mkdir(parents=True, exist_ok=True) + out_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(source, in_dir / source.name) + # ODA 인자: 입력폴더 출력폴더 출력버전 출력형식 재귀 감사 + subprocess.run( + [converter, str(in_dir), str(out_dir), "ACAD2018", "DXF", "0", "1"], + check=True, + timeout=180, + capture_output=True, + ) + converted = sorted(out_dir.glob("*.dxf")) + if not converted: + raise ValueError("DWG 를 DXF 로 바꾸지 못했습니다. 캐드에서 DXF 로 저장해 올려 주십시오.") + return converted[0] + + +def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: + """올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다.""" + suffix = Path(filename).suffix.lower() + if suffix not in (".dxf", ".dwg"): + raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.") + with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary: + work_dir = Path(temporary) + source = work_dir / f"frame{suffix}" + source.write_bytes(data) + target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source + try: + return dxf_to_entities(target) + except ezdxf.DXFError as exc: + raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 6708d945..e0e20440 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -20,13 +20,12 @@ from pathlib import Path from typing import Any from uuid import NAMESPACE_URL, uuid5 -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines - from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, DRAWING_FORMAT, FRAME_LAYER_ID, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing" @@ -114,9 +113,14 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: 1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다. """ + return frame_document(template_entities(name)) + + +def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]: + """엔티티 목록을 도각 편집 화면이 그대로 싣는 도면 한 장으로 감싼다 (실치수 1:1).""" return { "format": DRAWING_FORMAT, - "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in template_entities(name)], + "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities], "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f4bb7d50..eb7f04c1 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 +from fastapi import APIRouter, File, UploadFile from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -26,13 +26,16 @@ 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, @@ -59,6 +62,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingInvalidateResponse, DesignDrawingListResponse, DesignDrawingResponse, + FrameTemplateImportResponse, FrameTemplateResponse, FrameTemplateSaveRequest, FrameTemplateSaveResponse, @@ -585,6 +589,39 @@ async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONRe ) +# 도각 파일 상한 — 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 diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 53abb805..f722a0a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -104,6 +104,15 @@ class FrameTemplateResponse(BaseModel): customized: bool = False +class FrameTemplateImportResponse(BaseModel): + """외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 바꾼 결과.""" + + status: str = "success" + project_id: str + drawing: dict[str, Any] + entity_count: int + + class FrameTemplateSaveRequest(BaseModel): """도각 편집 화면이 [완료]에서 넘기는 도각.""" diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 7a195985..7cd15aff 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -12,15 +12,19 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { type CadDrawing, fetchFrameTemplate, + importFrameTemplate, resetFrameTemplate, saveFrameTemplate, } from "./B07_DesignDetail_Api_Fetch"; +import { createPlaceholderPalette } from "./B07_DesignDetail_UI_FramePlaceholders"; export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ button: HTMLButtonElement; /** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */ banner: HTMLElement; + /** 자리표 목록 — 편집 중에만 보인다. */ + tokens: HTMLElement; /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ isEditing: () => boolean; } @@ -61,6 +65,23 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor variant: "ghost", onClick: () => void resetToDefault(), }); + /** + * 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가 + * 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직 + * 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + */ + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".dxf,.dwg"; + fileInput.hidden = true; + fileInput.addEventListener("change", () => void importFile()); + + const importButton = createButton({ + label: "파일 불러오기", + variant: "ghost", + onClick: () => fileInput.click(), + }); + const cancelButton = createButton({ label: "취소", variant: "ghost", @@ -68,8 +89,13 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor }); const bannerButtons = document.createElement("div"); bannerButtons.className = "b07-frame-edit__buttons"; - bannerButtons.append(finishButton, resetButton, cancelButton); - banner.append(bannerButtons); + bannerButtons.append(finishButton, importButton, resetButton, cancelButton); + banner.append(bannerButtons, fileInput); + + const palette = createPlaceholderPalette({ + requestCadDrawing: () => options.requestCadDrawing(), + sendLoad: (drawing, meta) => options.sendLoad(drawing, meta), + }); const button = createButton({ label: "도각 편집", @@ -80,16 +106,38 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor const leave = (): void => { editing = false; banner.hidden = true; + palette.setVisible(false); button.disabled = false; options.restoreDrawing(); }; + async function importFile(): Promise { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + importButton.disabled = true; + try { + const response = await importFrameTemplate(options.projectId, file); + options.sendLoad(response.drawing, null); + label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`; + showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", + "error", + ); + } finally { + importButton.disabled = false; + } + } + async function enter(): Promise { try { const response = await fetchFrameTemplate(options.projectId); editing = true; button.disabled = true; banner.hidden = false; + palette.setVisible(true); label.textContent = response.customized ? "도각 편집 중 — 회사 도각을 고치고 있습니다." : "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다."; @@ -139,5 +187,5 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor } } - return { button, banner, isEditing: () => editing }; + return { button, banner, tokens: palette.root, isEditing: () => editing }; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FramePlaceholders.ts b/B07_DesignDetail/B07_DesignDetail_UI_FramePlaceholders.ts new file mode 100644 index 00000000..cd5b41f3 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_FramePlaceholders.ts @@ -0,0 +1,196 @@ +/** + * B07 도각 자리표 배치 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정). + * + * 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 출력 때 기존 치환 + * 엔진(`_fill_placeholders`)이 그대로 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다. + * + * 놓는 방식 — 단추를 누르면 도면 한가운데에 자리표가 서고, 그 뒤 캐드의 이동·크기 도구로 + * 자리를 잡는다. 캐드 안쪽 코드는 건드리지 않는다(도면을 통째로 다시 싣는 방식). + */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import type { CadDrawing } from "./B07_DesignDetail_Api_Fetch"; + +/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */ +const TEXT_TOKENS: readonly string[] = [ + "도면명", + "도면번호", + "공사명", + "위치", + "시행청", + "용역회사", + "연도기번", + "사업량", + "과업책임자", + "분야별책임자", + "설계자", + "설계일자", + "축척_A1", + "축척_A3", +]; + +/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 그림째 빠진다. */ +const IMAGE_TOKENS: readonly string[] = [ + "회사로고", + "과업책임자서명", + "분야별책임자서명", + "설계자서명", +]; + +const TEXT_SIZE_MM = 5; +const IMAGE_WIDTH_MM = 32; +const IMAGE_HEIGHT_MM = 16; +/** 겹쳐 놓지 않도록 하나 놓을 때마다 이만큼 내려 찍는다. */ +const STACK_STEP_MM = 8; + +interface Options { + /** CAD에서 현재 편집본을 받아온다. */ + requestCadDrawing: () => Promise; + /** CAD에 도면을 다시 싣는다. */ + sendLoad: (drawing: CadDrawing, meta: null) => void; +} + +interface Point { + x: number; + y: number; +} + +/** 엔티티 목록의 한가운데 — 자리표를 처음 놓는 자리. 좌표가 없으면 원점. */ +function centerOf(entities: Record[]): Point { + const xs: number[] = []; + const ys: number[] = []; + const visit = (entity: Record): void => { + const shape = (entity.shapeData ?? {}) as Record; + for (const key of ["startPoint", "endPoint", "basePoint", "point"]) { + const value = shape[key] as Point | undefined; + if (value && typeof value.x === "number" && typeof value.y === "number") { + xs.push(value.x); + ys.push(value.y); + } + } + for (const vertex of (shape.points as Point[] | undefined) ?? []) { + if (vertex && typeof vertex.x === "number") { + xs.push(vertex.x); + ys.push(vertex.y); + } + } + for (const child of (entity.children as Record[] | undefined) ?? []) { + visit(child); + } + }; + for (const entity of entities) visit(entity); + if (xs.length === 0) return { x: 0, y: 0 }; + const mid = (values: number[]): number => (Math.min(...values) + Math.max(...values)) / 2; + return { x: mid(xs), y: mid(ys) }; +} + +function layerIdOf(drawing: CadDrawing): string { + return drawing.layers[0]?.id ?? "0"; +} + +function textEntity(token: string, at: Point, layerId: string): Record { + return { + id: crypto.randomUUID(), + type: "Text", + lineColor: "#f5f7fa", + lineWidth: 1, + layerId, + shapeData: { + label: `{{${token}}}`, + basePoint: { x: at.x, y: at.y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: "center", + textColor: "#f5f7fa", + fontSize: TEXT_SIZE_MM, + fontFamily: "sans-serif", + }, + }, + }; +} + +function imageEntity(token: string, at: Point, layerId: string): Record { + const halfWidth = IMAGE_WIDTH_MM / 2; + const halfHeight = IMAGE_HEIGHT_MM / 2; + return { + id: crypto.randomUUID(), + type: "Image", + lineColor: "#f5f7fa", + lineWidth: 1, + layerId, + shapeData: { + points: [ + { x: at.x - halfWidth, y: at.y - halfHeight }, + { x: at.x + halfWidth, y: at.y - halfHeight }, + { x: at.x + halfWidth, y: at.y + halfHeight }, + { x: at.x - halfWidth, y: at.y + halfHeight }, + ], + imageData: `{{${token}}}`, + }, + }; +} + +export interface PlaceholderPalette { + /** 도각 편집 띠 아래에 붙는 자리표 목록 (평소엔 숨김). */ + root: HTMLElement; + setVisible: (visible: boolean) => void; +} + +export function createPlaceholderPalette(options: Options): PlaceholderPalette { + const root = document.createElement("div"); + root.className = "b07-frame-tokens"; + root.hidden = true; + + const hint = document.createElement("span"); + hint.className = "b07-frame-tokens__hint"; + hint.textContent = "자리표 놓기 — 누르면 도면 가운데에 서고, 끌어서 자리를 잡음"; + root.append(hint); + + let placed = 0; + + const place = async (token: string, kind: "text" | "image"): Promise => { + try { + const drawing = await options.requestCadDrawing(); + const center = centerOf(drawing.entities); + const at = { x: center.x, y: center.y - placed * STACK_STEP_MM }; + const layerId = layerIdOf(drawing); + const entity = + kind === "text" ? textEntity(token, at, layerId) : imageEntity(token, at, layerId); + options.sendLoad({ ...drawing, entities: [...drawing.entities, entity] }, null); + placed += 1; + showToast(`「${token}」 자리표를 놓았습니다. 끌어서 자리를 잡으십시오.`, "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "자리표를 놓지 못했습니다.", "error"); + } + }; + + const group = (label: string, tokens: readonly string[], kind: "text" | "image"): void => { + const box = document.createElement("div"); + box.className = "b07-frame-tokens__group"; + const title = document.createElement("span"); + title.className = "b07-frame-tokens__title"; + title.textContent = label; + box.append(title); + for (const token of tokens) { + box.append( + createButton({ + label: token, + variant: "ghost", + onClick: () => void place(token, kind), + }), + ); + } + root.append(box); + }; + + group("글자", TEXT_TOKENS, "text"); + group("그림", IMAGE_TOKENS, "image"); + + return { + root, + setVisible: (visible: boolean) => { + root.hidden = !visible; + if (!visible) placed = 0; + }, + }; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 1615d984..34517c7e 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -481,7 +481,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const confirmButtonRow = document.createElement("div"); confirmButtonRow.className = "b07-drawing-actions__row"; confirmButtonRow.append(frameEditor.button, confirmButton); - confirmActions.append(frameEditor.banner, confirmButtonRow); + confirmActions.append(frameEditor.banner, frameEditor.tokens, confirmButtonRow); drawingPanel.append(infoPanelHost, confirmActions); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 8de0f675..05a5d1fd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -8,10 +8,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import type { - CrossDesignInfo, - DesignDrawingItem, -} from "./B07_DesignDetail_Api_Fetch"; +import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -69,10 +66,7 @@ export function buildDrawingSidePanel( return panel; } - const drawingButton = ( - drawing: DesignDrawingItem, - label: string, - ): HTMLButtonElement => { + const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; @@ -91,9 +85,7 @@ export function buildDrawingSidePanel( ? drawings.filter((item) => item.kind === group.kind) : group.idPrefix ? drawings.filter( - (item) => - item.id === group.idPrefix || - item.id.startsWith(`${group.idPrefix}_`), + (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), ) : drawings.filter((item) => item.id === group.blankId); // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. @@ -117,8 +109,7 @@ export function buildDrawingSidePanel( const button = drawingButton(drawing, group.label); // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") - button.title = "준비 중 — 도각만 표시합니다"; + if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } @@ -140,10 +131,7 @@ export function buildDrawingSidePanel( return panel; } -const GROUND_TYPE_LABEL: Record< - CrossDesignInfo["ground_type"], - keyof typeof ui_locales -> = { +const GROUND_TYPE_LABEL: Record = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", @@ -165,8 +153,7 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; - if (!ditch || ditch.type === "none" || design.ditch_enabled === false) - return "없음"; + if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; if (ditch.type === "l_type") return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; @@ -202,23 +189,19 @@ export function buildDesignInfoPanel( const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); - const scopeLabel = - scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); + const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); stationName.textContent = `${scopeLabel} ${title}`; const confirmed = design?.status === "confirmed"; const badge = document.createElement("span"); badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; - badge.textContent = confirmed - ? L("B07_Info_Confirmed") - : L("B07_Info_Provisional"); + badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; - empty.textContent = - scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); + empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); panel.append(empty); return panel; } @@ -233,9 +216,7 @@ export function buildDesignInfoPanel( infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow( L("B07_Info_DitchSide"), - design.ditch_side === "left" - ? L("B06_Design_Ditch_Left") - : L("B06_Design_Ditch_Right"), + design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), ), ); @@ -245,10 +226,7 @@ export function buildDesignInfoPanel( planTitle.textContent = L("B07_Info_Plan_Title"); plan.append( planTitle, - infoRow( - L("B07_Info_DesignElevation"), - `${design.design_elevation_m.toFixed(2)}m`, - ), + infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 1dcfce89..1736bdd1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -321,3 +321,43 @@ padding: 4px 8px; font-size: 0.78rem; } + +/* 도각 자리표 목록 — 편집 중에만 보인다 (2026-09-06). 사이드바가 좁아 단추를 감싼다. */ +.b07-frame-tokens { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + margin-top: var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background-color: var(--color-surface); +} + +.b07-frame-tokens[hidden] { + display: none; +} + +.b07-frame-tokens__hint { + font-size: 0.72rem; + line-height: 1.4; + color: var(--color-text-secondary); +} + +.b07-frame-tokens__group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.b07-frame-tokens__title { + width: 100%; + font-size: 0.72rem; + color: var(--color-text-secondary); +} + +.b07-frame-tokens__group > button { + padding: 2px 6px; + font-size: 0.72rem; +} diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 312209a2..2a033abe 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -8,6 +8,9 @@ import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../stat import { type Entity, EntityName, type JsonEntity } from './Entity'; import type { LineEntity } from './LineEntity.ts'; +/** 글자를 집었을 때 두르는 외곽선 색 — 도면 선과 헷갈리지 않게 회색. */ +const TEXT_SELECTION_OUTLINE_COLOR = '#9aa0a6'; + export interface TextOptions { textDirection: Vector; textAlign: 'left' | 'center' | 'right'; @@ -49,14 +52,26 @@ export class TextEntity implements Entity { parentHighlighted?: boolean, parentSelected?: boolean ): void { - drawController.setLineStyles( - parentHighlighted ?? isEntityHighlighted(this), - parentSelected ?? isEntitySelected(this), - this.lineColor, - this.lineWidth, - this.lineDash - ); + const highlighted = parentHighlighted ?? isEntityHighlighted(this); + const selected = parentSelected ?? isEntitySelected(this); + drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash); drawController.drawText(this.label, this.basePoint, this.options); + // 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이 + // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 + // 없어 이 선이 실리지 않는다. + if (highlighted || selected) { + const box = this.getBoundingBox(); + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]); + for (let index = 0; index < corners.length; index++) { + drawController.drawLine(corners[index], corners[(index + 1) % corners.length]); + } + } } public move(x: number, y: number) {