diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 5ee1eb54..51c9d19f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -175,7 +175,7 @@ export interface FrameTemplateResponse { status: string; project_id: string; drawing: CadDrawing; - /** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */ + /** 프로젝트 도각이 처음 복사한 도각과 다르면 true. */ customized: boolean; /** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */ fields?: Record; @@ -237,7 +237,7 @@ export async function exportDrawing( }; } -/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */ +/** 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다. */ export function resetFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "DELETE", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index e0e20440..07e264f8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -1,12 +1,13 @@ """B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다. -resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해, +resources/master_template/drawing/의 사전 변환 템플릿(A1 도각 등)을 로드해, 도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어 (b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 — 템플릿 쪽을 확대해 콘텐츠를 감싼다. A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란, -내부 작도 영역 (42, 47) ~ (812, 567). +내부 작도 영역 (42, 47) ~ (812, 567). 작도 영역은 양식 칸 `drawing_area` 가 정본이고 +칸이 없는 양식(옛 회사 도각)은 이 값으로 떨어진다. """ import binascii @@ -26,8 +27,19 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( FRAME_LAYER_ID, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines +from M02_MasterTemplete.M02_Template_Layers import ( + delete_template, + initial_dir, + project_dir, + reset_project, + system_dir, + template_path, + version_of, + write_template, +) -_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing" +# 도면 양식은 M02 양식 층(PLAN 10-5)의 `drawing` 종류다. +_KIND = "drawing" logger = logging.getLogger(__name__) @@ -37,29 +49,27 @@ _SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector") A1_TEMPLATE = "00_template_A1" COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표 -# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다. +# A1 내부 작도 영역(템플릿 좌표) — 양식에 `drawing_area` 칸이 없을 때만 쓴다. _A1_INNER = (42.0, 47.0, 812.0, 567.0) # 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를 # 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자). _CONTENT_MARGIN = 0.02 -# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각 -# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다 -# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능"). -COMPANY_TEMPLATE_SUBDIR = "templates" - -# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장· +# 읽는 차례 = 프로젝트 작업본(`{프로젝트}/templates/drawing/`) → 없으면 시스템 양식 +# (`resources/master_template/drawing/`, 읽기 전용). 설계자가 고친 도각은 작업본에 쌓인다. +# +# 이 요청이 읽을 프로젝트 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장· # 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다. # `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다. -_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None) +_project_root: ContextVar[Path | None] = ContextVar("b07_project_template_root", default=None) -def use_company_templates(company_dir: Path | None) -> None: - """이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각.""" - _company_dir.set(company_dir) +def use_project_templates(project_root: Path | None) -> None: + """이 요청이 읽을 프로젝트 폴더를 정한다. None이면 시스템 양식.""" + _project_root.set(project_root) -# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 — +# 이 요청이 도각 표제란에 채울 값. 프로젝트 폴더와 같은 이유로 문맥 변수다 — # 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다. _title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={}) @@ -78,9 +88,14 @@ def add_title_fields(extra: dict[str, str]) -> None: _title_fields.set({**_title_fields.get(), **(extra or {})}) -def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path: - """회사 도각 파일 경로(없을 수도 있다).""" - return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json" +def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path: + """프로젝트 작업본 도각 파일 경로(없을 수도 있다).""" + return template_path(project_dir(project_root), _KIND, name) + + +def system_template_path(name: str = A1_TEMPLATE) -> Path: + """시스템 도각 파일 경로.""" + return template_path(system_dir(), _KIND, name) @lru_cache(maxsize=16) @@ -90,18 +105,35 @@ def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]: def _load_template(name: str) -> dict[str, Any] | None: - """회사 도각이 있으면 그것, 없으면 프로그램 기본 도각.""" - company_dir = _company_dir.get() - if company_dir is not None: - override = company_template_path(company_dir, name) + """프로젝트 작업본이 있으면 그것, 없으면 시스템 양식(옛 프로젝트).""" + project_root = _project_root.get() + if project_root is not None: + override = project_template_path(project_root, name) if override.is_file(): return _read_template(str(override), override.stat().st_mtime_ns) - path = _TEMPLATE_DIR / f"{name}.json" + path = system_template_path(name) if not path.is_file(): return None return _read_template(str(path), path.stat().st_mtime_ns) +def drawing_area(name: str = A1_TEMPLATE) -> tuple[float, float, float, float]: + """양식의 내부 작도 영역 (x0, y0, x1, y1) — 콘텐츠가 이 영역 중앙에 놓인다. + + 양식 칸 `drawing_area` 를 읽는다. 없거나 어긋나면 A1 기본값(`_A1_INNER`). + """ + area = (_load_template(name) or {}).get("drawing_area") + if ( + isinstance(area, list) + and len(area) == 4 + and all(isinstance(value, (int, float)) for value in area) + and area[0] < area[2] + and area[1] < area[3] + ): + return (float(area[0]), float(area[1]), float(area[2]), float(area[3])) + return _A1_INNER + + def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]: """도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다.""" template = _load_template(name) @@ -157,33 +189,52 @@ def validate_template_entities(entities: list[dict[str, Any]]) -> None: _check(entity, f"#{index}") -def clear_company_template(company_dir: Path, name: str = A1_TEMPLATE) -> bool: - """회사 도각을 지워 프로그램 기본 도각으로 되돌린다. 지울 것이 없으면 False.""" - path = company_template_path(company_dir, name) - if not path.is_file(): +def is_project_template_customized(project_root: Path, name: str = A1_TEMPLATE) -> bool: + """작업본이 처음 복사한 도각(`_initial/`, 없으면 시스템)과 다른가.""" + current = version_of(project_template_path(project_root, name)) + if current is None: return False - path.unlink() - return True + initial = version_of(template_path(initial_dir(project_root), _KIND, name)) + return current != (initial or version_of(system_template_path(name))) -def save_company_template( - company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE +def reset_project_template(project_root: Path, name: str = A1_TEMPLATE) -> bool: + """[기본 도각으로] — 처음 복사한 도각(`_initial/`)을 작업본에 덮어쓴다. + + `_initial/` 이 없는 옛 프로젝트는 작업본을 지워 시스템 양식으로 떨어뜨린다. + 되돌릴 것이 없으면 False. + """ + if reset_project(project_root, kind=_KIND, name=name): + return True + return delete_template(project_dir(project_root), _KIND, name) + + +def save_project_template( + project_root: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE ) -> Path: - """편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다.""" + """편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다.""" validate_template_entities(entities) - path = company_template_path(company_dir, name) - path.parent.mkdir(parents=True, exist_ok=True) + # 작도 영역은 CAD 가 모르는 칸이라 편집본에 없다 — 고치기 전 양식의 값을 이어 받는다. + area = (_load_template_at(project_root, name) or {}).get("drawing_area") document = { "format": DRAWING_FORMAT, "source": "B07 도각 편집 화면", + **({"drawing_area": area} if area else {}), # 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다. "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities], "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], } - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(path) - return path + write_template(project_dir(project_root), _KIND, name, document) + return project_template_path(project_root, name) + + +def _load_template_at(project_root: Path, name: str) -> dict[str, Any] | None: + """요청 문맥과 상관없이 이 프로젝트가 읽는 도각.""" + token = _project_root.set(project_root) + try: + return _load_template(name) + finally: + _project_root.reset(token) def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None: @@ -293,15 +344,15 @@ def usable_bbox() -> tuple[float, float, float, float]: 여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 — 내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다. """ - ix0, iy0, ix1, iy1 = _A1_INNER + ix0, iy0, ix1, iy1 = drawing_area() width, height = usable_area() cx, cy = (ix0 + ix1) / 2.0, (iy0 + iy1) / 2.0 return (cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0) -def usable_area() -> tuple[float, float]: - """A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도.""" - ix0, iy0, ix1, iy1 = _A1_INNER +def usable_area(name: str = A1_TEMPLATE) -> tuple[float, float]: + """양식 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도.""" + ix0, iy0, ix1, iy1 = drawing_area(name) return ( (ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN), (iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN), @@ -444,8 +495,8 @@ def frame_entities( content_w = max(max_x - min_x, 1e-6) content_h = max(max_y - min_y, 1e-6) - ix0, iy0, ix1, iy1 = _A1_INNER - usable_w, usable_h = usable_area() + ix0, iy0, ix1, iy1 = drawing_area(template_name) + usable_w, usable_h = usable_area(template_name) scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0 # 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다 # (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다). diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 20768b82..592477a5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( - use_company_templates, + use_project_templates, use_title_fields, ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( @@ -292,9 +292,8 @@ async def get_design_drawing( """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" try: route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id) - # 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각). - # 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다. - use_company_templates(project_root.parent.parent) + # 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트). + use_project_templates(project_root) # 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다. use_title_fields(await title_block_fields(project_id)) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py index 6ad39fab..3de70066 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -18,12 +18,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ 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, + is_project_template_customized, + reset_project_template, + save_project_template, + use_project_templates, validate_template_entities, ) from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields @@ -42,28 +42,27 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) -async def _company_dir(project_id: UUID) -> Path: - """프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`.""" +async def _project_root(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 + return Path(resolve_stored_project_path(stored_path)).resolve() @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) + project_root = await _project_root(project_id) + use_project_templates(project_root) # 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는 # 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다). fields = await title_block_fields(project_id) return FrameTemplateResponse( project_id=str(project_id), drawing=frame_template_document(), - customized=company_template_path(company_dir).is_file(), + customized=is_project_template_customized(project_root), fields=fields, ) except FileNotFoundError as exc: @@ -123,7 +122,7 @@ async def import_frame_template( ) -> FrameTemplateImportResponse | JSONResponse: """외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다. - 아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + 아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다. """ try: data = await file.read() @@ -150,7 +149,7 @@ async def import_frame_template( async def put_frame_template( project_id: UUID, request: FrameTemplateSaveRequest ) -> FrameTemplateSaveResponse | JSONResponse: - """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. + """편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 그대로 둔다. 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). @@ -159,8 +158,8 @@ async def put_frame_template( 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) + project_root = await _project_root(project_id) + await asyncio.to_thread(save_project_template, project_root, entities) return FrameTemplateSaveResponse(project_id=str(project_id)) except (FileNotFoundError, ValueError) as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) @@ -174,15 +173,18 @@ async def put_frame_template( @router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: - """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). + """[기본 도각으로] — 프로젝트 `_initial/` 도각을 작업본에 덮어쓴다(재계산 아님). - 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. - 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. + 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. + 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 되돌린 도각으로 나온다. """ 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) + project_root = await _project_root(project_id) + await asyncio.to_thread(reset_project_template, project_root) + return FrameTemplateSaveResponse( + project_id=str(project_id), + customized=is_project_template_customized(project_root), + ) except FileNotFoundError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except Exception: diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index acfc367e..14dd2488 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -137,7 +137,7 @@ class FrameTemplateSaveRequest(BaseModel): class FrameTemplateSaveResponse(BaseModel): - """회사 도각 저장 결과.""" + """프로젝트 도각 저장 결과.""" status: str = "success" project_id: str diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 4fda6b0b..e40721b1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -38,14 +38,20 @@ import { exportDrawing, fetchDesignDrawing, fetchDesignDrawingList, + fetchFrameTemplate, + importFrameTemplate, invalidateDesignDrawing, + resetFrameTemplate, + saveFrameTemplate, type CadDrawing, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; -import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; +// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). +import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; +import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -62,33 +68,13 @@ interface DesignMeta { } /** CAD 저장 응답 (도면 + 편집된 수량표). */ -interface SaveResult { - drawing: CadDrawing; - quantityTable: QuantityTable | null; -} - -/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ -const B07_CAD_APP_URL = "/b07-cad/index.html"; +type SaveResult = CadSaveResult; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing"; -const CAD_READY_MESSAGE = "aislo:b08:drawing-ready"; -const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded"; -const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error"; -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"; -const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; - /* ----------------------------------------------------------------------------- * 화면 조립 * -------------------------------------------------------------------------- */ @@ -114,71 +100,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { : "도면 목록을 불러오지 못했습니다."; } - const cadHost = document.createElement("div"); - cadHost.className = "b07-cad-host"; - const frame = document.createElement("iframe"); - frame.className = "b07-cad-frame"; - frame.src = B07_CAD_APP_URL; - frame.title = L("B07_Design_Title"); - const license = document.createElement("a"); - license.className = "b07-cad-license"; - license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt"; - license.target = "_blank"; - license.rel = "noreferrer"; - license.textContent = "Drawing engine based on OpenWebCAD · MIT License"; - cadHost.append(frame, license); - - let cadReady = false; - let pendingLoad: - | { - drawing: CadDrawing; - meta: DesignMeta | null; - frameEdit: boolean; - frameFields: Record; - } - | undefined; - // ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다** - // (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` · - // `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다. - // ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴 - // ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴 - // 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면 - // 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다). - const CAD_READY_TIMEOUT_MS = 20000; - const CAD_LOAD_TIMEOUT_MS = 15000; - let loadWatchdog: number | undefined; - - const failCad = (detail: string): void => { - cadHost.dataset.loading = "false"; - cadHost.dataset.error = detail; - showToast(detail, "error"); - }; - - const stopLoadWatchdog = (): void => { - if (loadWatchdog === undefined) return; - window.clearTimeout(loadWatchdog); - loadWatchdog = undefined; - }; - - const startLoadWatchdog = (): void => { - stopLoadWatchdog(); - // 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다. - const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS; - loadWatchdog = window.setTimeout(() => { - loadWatchdog = undefined; - if (cadHost.dataset.loading !== "true") return; - failCad( - cadReady - ? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요." - : "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.", - ); - }, wait); - }; - - // iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다. - frame.addEventListener("error", () => { - stopLoadWatchdog(); - failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요."); + const cad = createCadHost({ + title: L("B07_Design_Title"), + // 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다 + // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 + // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. + onChanged: (dirty) => { + if (!frameEditor.isEditing()) cadDirty = dirty; + }, + onNavigate: (direction) => navigateDrawing(direction), + onExport: (fileFormat) => void exportDrawingFile(fileFormat), }); let currentDrawing: DesignDrawingItem | undefined; @@ -188,7 +119,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross"; let allDrawingsConfirmed = drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed); - let resolveSave: ((payload: SaveResult) => void) | undefined; let drawingListEl: HTMLElement | undefined; const infoPanelHost = document.createElement("div"); infoPanelHost.className = "b07-info-host"; @@ -262,23 +192,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hasNext: index < drawings.length - 1, }); - // frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다 - // (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). - const sendLoad = ( - drawing: CadDrawing, - meta: DesignMeta | null, - frameEdit = false, - frameFields: Record = {}, - ) => { - pendingLoad = { drawing, meta, frameEdit, frameFields }; - if (!cadReady) return; - frame.contentWindow?.postMessage( - { type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields }, - window.location.origin, - ); - pendingLoad = undefined; - }; - /** * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시). * @@ -349,9 +262,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; - cadHost.dataset.loading = "true"; - cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다 - startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다 + cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다 try { const response = await requestDrawing(drawing); currentDrawing = drawing; @@ -360,10 +271,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 applyConfirmButtonState(); updateInfoPanel(drawing, response); - sendLoad(response.drawing, buildMeta(drawing, response, index)); + cad.load(response.drawing, buildMeta(drawing, response, index)); } catch (error) { - stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다 - failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); + cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); if (currentDrawing) highlightActive(currentDrawing.id); } finally { loadInFlight = false; @@ -384,16 +294,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { void loadDrawing(drawings[target], target); }; - const requestCadDrawing = (): Promise => - new Promise((resolve, reject) => { - resolveSave = resolve; - frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin); - window.setTimeout(() => { - if (!resolveSave) return; - resolveSave = undefined; - reject(new Error("CAD 저장 응답 시간이 초과되었습니다.")); - }, 5000); - }); + const requestCadDrawing = (): Promise => cad.requestSave(); async function confirmCurrentDrawing(): Promise { if (!projectId || !currentDrawing) return; @@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } - const frameEditor = createFrameTemplateEditor({ - projectId: projectId as string, - sendLoad, + const frameEditor = createFrameTemplateEditor({ + api: { + fetch: () => fetchFrameTemplate(projectId as string), + save: (drawing) => saveFrameTemplate(projectId as string, drawing), + importFile: (file) => importFrameTemplate(projectId as string, file), + reset: () => resetFrameTemplate(projectId as string), + }, + sendLoad: (drawing, meta, frameEdit, frameFields) => + cad.load(drawing, meta, frameEdit, frameFields), requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); @@ -513,77 +420,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); - window.addEventListener("message", (event: MessageEvent) => { - if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; - const message = event.data as { - type?: string; - detail?: string; - drawing?: CadDrawing; - quantityTable?: QuantityTable | null; - direction?: "prev" | "next"; - fileFormat?: "dxf" | "dwg"; - dirty?: boolean; - kind?: string; - text?: string; - actionId?: string; - durationMs?: number; - }; - if (message.type === CAD_TOAST_MESSAGE) { - const kind = (["info", "success", "warning", "error"] as const).find( - (item) => item === message.kind, - ); - // autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다. - const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000); - const actionId = message.actionId; - showToast( - message.text ?? "", - kind ?? "info", - duration, - actionId - ? () => - frame.contentWindow?.postMessage( - { type: CAD_TOAST_ACTION_MESSAGE, actionId }, - window.location.origin, - ) - : undefined, - ); - } else if (message.type === CAD_READY_MESSAGE) { - cadReady = true; - if (pendingLoad) { - sendLoad( - pendingLoad.drawing, - pendingLoad.meta, - pendingLoad.frameEdit, - pendingLoad.frameFields, - ); - // 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다. - if (cadHost.dataset.loading === "true") startLoadWatchdog(); - } - } else if (message.type === CAD_LOADED_MESSAGE) { - stopLoadWatchdog(); - cadHost.dataset.loading = "false"; - } else if (message.type === CAD_ERROR_MESSAGE) { - stopLoadWatchdog(); - failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다."); - } else if (message.type === CAD_CHANGED_MESSAGE) { - // 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다 - // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 - // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. - 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; - resolve({ - drawing: message.drawing, - quantityTable: message.quantityTable ?? null, - }); - } - }); - const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); @@ -604,7 +440,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 4, leftPanel: drawingPanel, - mainContent: cadHost, + mainContent: cad.element, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -624,7 +460,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { root.replaceChildren(layout.root); // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. - // CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다. + // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. if (drawings.length > 0) { void loadDrawing(drawings[0], 0).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 994febee..3af80ed7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -157,76 +157,6 @@ width: 100%; } -/* CAD 뷰어 호스트 (상세 페이지 영역) */ -.b07-cad-host { - position: relative; - width: 100%; - height: 100%; - min-height: 420px; - overflow: hidden; - border-radius: var(--radius-cards); - background-color: var(--color-surface); -} - -/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서 - 화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다 - (2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */ -.b07-cad-host[data-loading="true"]::after { - content: "도면을 불러오는 중…"; - position: absolute; - z-index: 4; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - padding: 10px 18px; - border-radius: var(--radius-cards); - background-color: var(--color-surface); - box-shadow: 0 2px 10px rgb(0 0 0 / 18%); - color: var(--color-text); - font-size: 13px; -} - -.b07-cad-host[data-error]:not([data-error=""])::before { - content: attr(data-error); - position: absolute; - z-index: 5; - top: 12px; - left: 50%; - transform: translateX(-50%); - max-width: 70%; - padding: 8px 16px; - border: 1px solid var(--color-danger, #d33); - border-radius: var(--radius-cards); - background-color: var(--color-surface); - color: var(--color-danger, #d33); - font-size: 12px; -} - -/* B07 독립형 CAD 앱 임베드 */ -.b07-cad-frame { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border: 0; -} - -.b07-cad-license { - position: absolute; - z-index: 2; - right: 10px; - bottom: 7px; - color: color-mix(in srgb, var(--color-text-muted) 55%, transparent); - font-size: 9px; - line-height: 1; - text-decoration: none; -} - -.b07-cad-license:hover { - color: var(--color-text-muted); - text-decoration: underline; -} - /* 선택 횡단도의 지반정보/계획정보 (잠정치) */ .b07-info-host:empty { display: none; @@ -294,40 +224,6 @@ color: var(--color-text-muted); } -/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과 - 겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */ -.b07-frame-edit { - display: flex; - flex-direction: column; - gap: 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-edit[hidden] { - display: none; -} - -.b07-frame-edit__label { - font-size: 0.78rem; - line-height: 1.4; - color: var(--color-text); -} - -.b07-frame-edit__buttons { - display: flex; - gap: var(--spacing-8); -} - -.b07-frame-edit__buttons > button { - flex: 1 1 0; - min-width: 0; - padding: 4px 8px; - font-size: 0.78rem; -} - /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 9f6cd23c..3ffebc53 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -20,6 +20,7 @@ import { setDesignMeta, setEntities, setFrameEditMode, + setHostReadOnly, setLayers, } from '../state.ts'; import { toast } from 'react-toastify'; @@ -45,6 +46,10 @@ interface DrawingLoadMessage { frameEdit?: boolean; /** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */ frameFields?: Record; + /** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */ + recoveryScope?: string; + /** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */ + readOnly?: boolean; } interface DrawingSaveRequestMessage { @@ -187,6 +192,7 @@ export function registerAisloDrawingBridge() { // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); + setHostReadOnly(event.data.readOnly === true); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: @@ -194,7 +200,7 @@ export function registerAisloDrawingBridge() { // 상태를 이어 갈 이유도 없다. runCommandInput('SELECT'); // 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다. - setRecoveryScope(event.data.meta?.drawingId ?? null); + setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null); getScreenCanvasDrawController().zoomToFitScreen(); notifyParent(AISLO_DRAWING_LOADED_MESSAGE); } catch (error) { diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 1fcf3217..aa51cd94 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -190,6 +190,8 @@ let designMeta: DesignMeta | null = null; let frameEditMode = false; /** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ let frameFields: Record = {}; +/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */ +let hostReadOnly = false; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -267,7 +269,7 @@ export const isDrawingDirty = () => drawingDirty; * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 * 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다. */ -export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true; +export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly; // setters export const setCanvas = (newCanvas: HTMLCanvasElement) => { @@ -493,6 +495,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { triggerReactUpdate(StateVariable.designMeta); }; /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ +/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */ +export const setHostReadOnly = (readOnly: boolean) => { + hostReadOnly = readOnly; + notifyWindow(HtmlEvent.UPDATE_STATE); +}; export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { frameEditMode = enabled; frameFields = enabled ? fields : {}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css new file mode 100644 index 00000000..08a4bb11 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -0,0 +1,66 @@ +/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */ + +.m02-drawing { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: 100%; + height: 100%; + min-height: 0; +} + +.m02-drawing > .cad-host { + flex: 1 1 0; +} + +.m02-drawing__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16, 16px); +} + +.m02-drawing__title { + align-self: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text); +} + +.m02-drawing__area { + display: flex; + align-items: flex-end; + gap: var(--spacing-8); +} + +.m02-drawing__area .ui-field { + width: 5.5rem; +} + +.m02-drawing__fields { + display: flex; + flex: 1 1 20rem; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4, 4px); +} + +.m02-drawing__field { + padding: 2px 8px; + border: 1px solid var(--color-border); + border-radius: 1440px; + background-color: var(--color-surface); + color: var(--color-text); + font-size: 0.72rem; + cursor: pointer; +} + +.m02-drawing__field[data-source="도면"] { + color: var(--color-text-muted); +} + +.m02-drawing__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts new file mode 100644 index 00000000..5c7d42ea --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -0,0 +1,220 @@ +/* ============================================================================= + * M02_MasterTemplete_Drawing.ts + * M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3). + * + * 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})` + * → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다. + * `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다. + * + * 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` · + * `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다. + * ========================================================================== */ + +import "./M02_MasterTemplete_Drawing.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; + +/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */ +export interface DrawingTemplateDoc extends CadHostDrawing { + format?: number; + source?: string; + drawing_area?: [number, number, number, number]; + [key: string]: unknown; +} + +export interface DrawingTemplateOptions { + /** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */ + onSave?: (doc: DrawingTemplateDoc) => void | Promise; + /** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */ + readOnly?: boolean; + /** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */ + name?: string; +} + +export interface DrawingTemplateHandle { + getDoc: () => Promise; + destroy: () => void; +} + +/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */ +const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567]; +const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"]; + +interface DrawingField { + key: string; + source: string; + label: string; +} + +async function fetchDrawingFields(): Promise { + const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" }); + const payload = (await response.json()) as { fields?: DrawingField[]; message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload.fields ?? []; +} + +async function importDrawingFile( + file: File, +): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> { + const form = new FormData(); + form.append("file", file); + const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, { + method: "POST", + credentials: "include", + body: form, + }); + const payload = (await response.json()) as { + drawing: DrawingTemplateDoc; + entity_count: number; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +export function mountDrawingTemplate( + container: HTMLElement, + doc: DrawingTemplateDoc, + options: DrawingTemplateOptions = {}, +): DrawingTemplateHandle { + const readOnly = options.readOnly === true; + const recoveryScope = `m02:${options.name ?? "drawing"}`; + let base: DrawingTemplateDoc = doc; + + const root = document.createElement("div"); + root.className = "m02-drawing"; + const toolbar = document.createElement("div"); + toolbar.className = "m02-drawing__toolbar"; + + // 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. + const area = document.createElement("div"); + area.className = "m02-drawing__area"; + const areaTitle = document.createElement("span"); + areaTitle.className = "m02-drawing__title"; + areaTitle.textContent = "작도 영역"; + const start = doc.drawing_area ?? DEFAULT_AREA; + const areaInputs = AREA_LABELS.map((label, index) => { + const field = createInputField({ label, type: "number", value: String(start[index]) }); + field.input.disabled = readOnly; + return field; + }); + area.append(areaTitle, ...areaInputs.map((field) => field.root)); + + const readArea = (): [number, number, number, number] | null => { + const values = areaInputs.map((field) => Number(field.input.value)); + const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3]; + areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위")); + return valid ? (values as [number, number, number, number]) : null; + }; + + // 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다. + const fields = document.createElement("div"); + fields.className = "m02-drawing__fields"; + const fieldsTitle = document.createElement("span"); + fieldsTitle.className = "m02-drawing__title"; + fieldsTitle.textContent = "자리표"; + fields.append(fieldsTitle); + void fetchDrawingFields() + .then((list) => { + for (const field of list) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "m02-drawing__field"; + chip.dataset.source = field.source; + chip.textContent = `{{${field.key}}}`; + chip.title = `${field.label} (${field.source}) — 눌러 복사`; + chip.addEventListener("click", () => { + void navigator.clipboard + ?.writeText(`{{${field.key}}}`) + .then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success")); + }); + fields.append(chip); + } + }) + .catch((error) => + showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"), + ); + + const cad = createCadHost({ title: "도면 양식" }); + const load = (drawing: DrawingTemplateDoc): void => { + cad.beginLoading(); + cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly }); + }; + + const actions = document.createElement("div"); + actions.className = "m02-drawing__actions"; + if (!readOnly) { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".dxf,.dwg"; + fileInput.hidden = true; + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + importButton.disabled = true; + importDrawingFile(file) + .then((response) => { + // 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다. + load(response.drawing); + showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); + }) + .catch((error) => + showToast( + error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", + "error", + ), + ) + .finally(() => (importButton.disabled = false)); + }); + const importButton = createButton({ + label: "파일 불러오기", + variant: "ghost", + onClick: () => fileInput.click(), + }); + actions.append(importButton, fileInput); + } + + const getDoc = async (): Promise => { + const { drawing } = await cad.requestSave(); + const drawingArea = readArea(); + if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다."); + base = { ...base, ...drawing, drawing_area: drawingArea }; + return base; + }; + + if (options.onSave && !readOnly) { + const onSave = options.onSave; + const saveButton = createButton({ + label: "저장", + variant: "filled", + onClick: () => { + saveButton.disabled = true; + getDoc() + .then((next) => onSave(next)) + .catch((error) => + showToast( + error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", + "error", + ), + ) + .finally(() => (saveButton.disabled = false)); + }, + }); + actions.append(saveButton); + } + + toolbar.append(area, fields, actions); + root.append(toolbar, cad.element); + container.append(root); + load(doc); + + return { + getDoc, + destroy: () => { + cad.destroy(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py new file mode 100644 index 00000000..d1c20db7 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py @@ -0,0 +1,95 @@ +"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3). + +양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다. +여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다. +""" + +import asyncio +import logging +from uuid import UUID + +from fastapi import APIRouter, File, UploadFile +from fastapi.responses import JSONResponse + +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + frame_document, + validate_template_entities, +) +from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"]) + +# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다. +_IMPORT_MAX_BYTES = 20 * 1024 * 1024 + +# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다. +# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다. +DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = ( + ("공사명", "프로젝트", "프로젝트 이름"), + ("위치", "프로젝트", "사업 위치"), + ("시행청", "프로젝트", "발주 기관"), + ("연도기번", "프로젝트", "프로젝트 번호"), + ("사업량", "프로젝트", "사업량"), + ("설계일자", "프로젝트", "설계 일자"), + ("용역회사", "프로젝트", "회사 이름"), + ("설계자", "프로젝트", "설계자 이름"), + ("과업책임자", "프로젝트", "과업책임자 이름"), + ("분야별책임자", "프로젝트", "분야별책임자 이름"), + ("회사로고", "프로젝트", "회사 로고 그림"), + ("설계자서명", "프로젝트", "설계자 서명 그림"), + ("과업책임자서명", "프로젝트", "과업책임자 서명 그림"), + ("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"), + ("도면명", "도면", "도면 이름"), + ("도면번호", "도면", "도면 목록 순번"), + ("축척_A1", "도면", "A1 축척 분모"), + ("축척_A3", "도면", "A3 축척 분모"), +) + + +@router.post("/drawing-import") +async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse: + """외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다.""" + try: + data = await file.read() + if len(data) > _IMPORT_MAX_BYTES: + raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).") + entities = await asyncio.to_thread(import_frame_file, file.filename or "", data) + validate_template_entities(entities) + return JSONResponse( + { + "status": "success", + "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("M02 도각 불러오기 실패: %s", file.filename) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각 파일을 읽지 못했습니다."}, + ) + + +@router.get("/drawing-fields") +async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse: + """자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용).""" + try: + values = await title_block_fields(project_id) if project_id else {} + except Exception: + logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id) + values = {} + return JSONResponse( + { + "status": "success", + "fields": [ + {"key": key, "source": source, "label": label} + for key, source, label in DRAWING_FIELDS + ], + "values": values, + } + ) diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/master_template/drawing/00_template_A1.json similarity index 99% rename from resources/template_2dDrawing/00_template_A1.json rename to resources/master_template/drawing/00_template_A1.json index d0b83d57..63d94bf1 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/master_template/drawing/00_template_A1.json @@ -1,6 +1,7 @@ { "format": 6, "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", + "drawing_area": [42, 47, 812, 567], "entities": [ { "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", diff --git a/resources/template_2dDrawing/00_template_compass.json b/resources/master_template/drawing/00_template_compass.json similarity index 100% rename from resources/template_2dDrawing/00_template_compass.json rename to resources/master_template/drawing/00_template_compass.json diff --git a/resources/template_2dDrawing/00_template_cover.json b/resources/master_template/drawing/00_template_cover.json similarity index 100% rename from resources/template_2dDrawing/00_template_cover.json rename to resources/master_template/drawing/00_template_cover.json diff --git a/resources/template_2dDrawing/01_template_Longitudinal_Yaxis_scale.json b/resources/master_template/drawing/01_template_Longitudinal_Yaxis_scale.json similarity index 100% rename from resources/template_2dDrawing/01_template_Longitudinal_Yaxis_scale.json rename to resources/master_template/drawing/01_template_Longitudinal_Yaxis_scale.json diff --git a/resources/template_2dDrawing/01_template_Longitudinal_table.json b/resources/master_template/drawing/01_template_Longitudinal_table.json similarity index 100% rename from resources/template_2dDrawing/01_template_Longitudinal_table.json rename to resources/master_template/drawing/01_template_Longitudinal_table.json diff --git a/resources/tester/test_cover_template.py b/resources/tester/test_cover_template.py index 3d0e2e4f..3d03e6fc 100644 --- a/resources/tester/test_cover_template.py +++ b/resources/tester/test_cover_template.py @@ -12,7 +12,7 @@ import pytest from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, FRAME_LAYER_ID from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing -TEMPLATE = Path("resources/template_2dDrawing/00_template_cover.json") +TEMPLATE = Path("resources/master_template/drawing/00_template_cover.json") # A1 도각 템플릿과 같은 재단 표식 원점 — 두 도면이 같은 종이 좌표계를 써야 한다. PAPER = (-5.05, -5.04, 834.95, 588.96) @@ -62,9 +62,7 @@ def test_도면은_잠금_도각_레이어로_나온다(): assert {e["layerId"] for e in drawing["entities"]} == {FRAME_LAYER_ID} layers = {layer["id"]: layer for layer in drawing["layers"]} assert layers[FRAME_LAYER_ID]["isLocked"] is True - assert any(not layer["isLocked"] for layer in drawing["layers"]), ( - "덧그릴 비잠금 도면층이 없다" - ) + assert any(not layer["isLocked"] for layer in drawing["layers"]), "덧그릴 비잠금 도면층이 없다" @pytest.mark.parametrize( diff --git a/resources/tester/test_m02_drawing_area.py b/resources/tester/test_m02_drawing_area.py new file mode 100644 index 00000000..6acaf41e --- /dev/null +++ b/resources/tester/test_m02_drawing_area.py @@ -0,0 +1,106 @@ +"""M02 도면 양식 — 작도 영역 칸 · B07 도각 층(프로젝트 작업본 → 시스템) (PLAN 10-3).""" + +import json + +from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine +from M02_MasterTemplete import M02_Template_Layers as layers + +A1 = engine.A1_TEMPLATE + + +def _project_template(project_root, document): + path = engine.project_template_path(project_root) + path.parent.mkdir(parents=True) + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def _entities(project_root): + engine.use_project_templates(project_root) + try: + return engine.template_entities() + finally: + engine.use_project_templates(None) + + +def test_system_a1_reads_drawing_area(): + engine.use_project_templates(None) + assert engine.drawing_area() == (42.0, 47.0, 812.0, 567.0) + assert len(engine.template_entities()) == 51 + + +def test_missing_area_falls_back_to_a1(tmp_path): + _project_template(tmp_path, {"format": 6, "entities": [], "layers": []}) + engine.use_project_templates(tmp_path) + try: + assert engine.drawing_area() == engine._A1_INNER + finally: + engine.use_project_templates(None) + + +def test_template_area_moves_content(tmp_path): + line = { + "id": "a", + "type": "Line", + "shapeData": {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 10, "y": 0}}, + } + _project_template( + tmp_path, + {"format": 6, "drawing_area": [0, 0, 400, 200], "entities": [line], "layers": []}, + ) + engine.use_project_templates(tmp_path) + try: + assert engine.drawing_area() == (0.0, 0.0, 400.0, 200.0) + assert engine.usable_area() == (400 * 0.96, 200 * 0.96) + # 콘텐츠 중심(500, 500) 이 작도 영역 중심(200, 100) 에 온다 — 이동량 (300, 400). + placed = engine.frame_entities("t", (490.0, 490.0, 510.0, 510.0), fit=False) + assert placed[0]["shapeData"]["startPoint"] == {"x": 300.0, "y": 400.0} + finally: + engine.use_project_templates(None) + + +def test_bad_area_falls_back(tmp_path): + _project_template(tmp_path, {"drawing_area": [10, 10, 5, 5], "entities": []}) + engine.use_project_templates(tmp_path) + try: + assert engine.drawing_area() == engine._A1_INNER + finally: + engine.use_project_templates(None) + + +def test_old_project_reads_system(tmp_path): + # 작업본이 없는 옛 프로젝트 — 시스템 양식으로 떨어진다. + assert len(_entities(tmp_path)) == 51 + assert engine.is_project_template_customized(tmp_path) is False + + +def test_save_reset_on_seeded_project(tmp_path): + layers.seed_project(tmp_path) + assert engine.project_template_path(tmp_path).is_file() + assert engine.is_project_template_customized(tmp_path) is False + + # [완료] — 작업본에만 쓴다 · 작도 영역은 이어 받는다 · `_initial/` · 시스템은 그대로. + path = engine.save_project_template(tmp_path, []) + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["drawing_area"] == [42, 47, 812, 567] + assert _entities(tmp_path) == [] + assert engine.is_project_template_customized(tmp_path) is True + initial = layers.template_path(layers.initial_dir(tmp_path), "drawing", A1) + assert len(json.loads(initial.read_text(encoding="utf-8"))["entities"]) == 51 + assert ( + len(json.loads(engine.system_template_path().read_text(encoding="utf-8"))["entities"]) == 51 + ) + + # [기본 도각으로] — `_initial/` 을 작업본에 덮어쓴다. + assert engine.reset_project_template(tmp_path) is True + assert len(_entities(tmp_path)) == 51 + assert engine.is_project_template_customized(tmp_path) is False + + +def test_reset_on_old_project_drops_to_system(tmp_path): + engine.save_project_template(tmp_path, []) + assert _entities(tmp_path) == [] + assert engine.reset_project_template(tmp_path) is True + assert not engine.project_template_path(tmp_path).exists() + assert len(_entities(tmp_path)) == 51 + assert engine.reset_project_template(tmp_path) is False diff --git a/resources/tester/test_m02_router_drawing.py b/resources/tester/test_m02_router_drawing.py new file mode 100644 index 00000000..da03fd1d --- /dev/null +++ b/resources/tester/test_m02_router_drawing.py @@ -0,0 +1,58 @@ +"""M02 도면 양식 서버 길 — 자리표 키 목록 · 도각 파일 불러오기 (PLAN 10-3).""" + +import io +import json +import re +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import DRAWING_FIELDS, router + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +def test_fields_cover_system_placeholders(): + # 시스템 도면 양식에 박힌 자리표는 전부 키 목록에 있다 — 없으면 화면이 모르는 칸이 생긴다. + keys = {key for key, _, _ in DRAWING_FIELDS} + used = set() + for path in Path("resources/master_template/drawing").glob("00_*.json"): + text = json.dumps(json.loads(path.read_text(encoding="utf-8")), ensure_ascii=False) + used |= {match.strip() for match in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", text)} + assert used and used <= keys, used - keys + + +def test_fields_without_project(): + response = client.get("/api/m02/drawing-fields") + assert response.status_code == 200 + body = response.json() + assert [item["key"] for item in body["fields"]] == [key for key, _, _ in DRAWING_FIELDS] + assert body["values"] == {} + + +def test_import_rejects_other_files(): + response = client.post( + "/api/m02/drawing-import", files={"file": ("a.txt", b"hello", "text/plain")} + ) + assert response.status_code == 400 + assert "DXF" in response.json()["message"] + + +def test_import_dxf(): + import ezdxf + + document = ezdxf.new() + document.modelspace().add_line((0, 0), (100, 50)) + stream = io.StringIO() + document.write(stream) + response = client.post( + "/api/m02/drawing-import", + files={"file": ("frame.dxf", stream.getvalue().encode("utf-8"), "application/dxf")}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["entity_count"] == 1 + assert body["drawing"]["entities"][0]["type"] == "Line" diff --git a/ui_template/cad_host/cad_host.css b/ui_template/cad_host/cad_host.css new file mode 100644 index 00000000..a7900805 --- /dev/null +++ b/ui_template/cad_host/cad_host.css @@ -0,0 +1,104 @@ +/* ui_template/cad_host — 웹캐드 iframe 칸 · 도각 편집 띠 (B07 · M02 공용). */ + +/* CAD 뷰어 호스트 (상세 페이지 영역) */ +.cad-host { + position: relative; + width: 100%; + height: 100%; + min-height: 420px; + overflow: hidden; + border-radius: var(--radius-cards); + background-color: var(--color-surface); +} + +/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서 + 화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다 + (2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */ +.cad-host[data-loading="true"]::after { + content: "도면을 불러오는 중…"; + position: absolute; + z-index: 4; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 10px 18px; + border-radius: var(--radius-cards); + background-color: var(--color-surface); + box-shadow: 0 2px 10px rgb(0 0 0 / 18%); + color: var(--color-text); + font-size: 13px; +} + +.cad-host[data-error]:not([data-error=""])::before { + content: attr(data-error); + position: absolute; + z-index: 5; + top: 12px; + left: 50%; + transform: translateX(-50%); + max-width: 70%; + padding: 8px 16px; + border: 1px solid var(--color-danger, #d33); + border-radius: var(--radius-cards); + background-color: var(--color-surface); + color: var(--color-danger, #d33); + font-size: 12px; +} + +/* B07 독립형 CAD 앱 임베드 */ +.cad-host__frame { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +.cad-host__license { + position: absolute; + z-index: 2; + right: 10px; + bottom: 7px; + color: color-mix(in srgb, var(--color-text-muted) 55%, transparent); + font-size: 9px; + line-height: 1; + text-decoration: none; +} + +.cad-host__license:hover { + color: var(--color-text-muted); + text-decoration: underline; +} +/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과 + 겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */ +.cad-frame-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background-color: var(--color-surface); +} + +.cad-frame-edit[hidden] { + display: none; +} + +.cad-frame-edit__label { + font-size: 0.78rem; + line-height: 1.4; + color: var(--color-text); +} + +.cad-frame-edit__buttons { + display: flex; + gap: var(--spacing-8); +} + +.cad-frame-edit__buttons > button { + flex: 1 1 0; + min-width: 0; + padding: 4px 8px; + font-size: 0.78rem; +} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts new file mode 100644 index 00000000..9651c3e8 --- /dev/null +++ b/ui_template/cad_host/cad_host.ts @@ -0,0 +1,258 @@ +/* ============================================================================= + * ui_template/cad_host/cad_host.ts + * 웹캐드(openwebcad) iframe 부모 쪽 한 벌 — B07 상세 설계 · M02 도면 양식이 함께 쓴다. + * + * iframe 띄우기 · ready 대기 · 시간초과 · 도면 싣기 · 저장 요청 · 토스트 중계를 맡는다. + * 페이지마다 다른 일(도면 넘기기 · 내보내기 · 미저장 표시)은 콜백으로 받는다. + * CAD 앱 쪽 짝은 `B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts`. + * ========================================================================== */ + +import "./cad_host.css"; +import { showToast } from "@ui/ui_template_elements"; + +/** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ +const CAD_APP_URL = "/b07-cad/index.html"; + +const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing"; +const CAD_READY_MESSAGE = "aislo:b08:drawing-ready"; +const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded"; +const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error"; +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"; +const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; + +/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */ +export interface CadHostDrawing { + entities: Record[]; + layers?: Record[]; +} + +/** CAD 저장 응답 (도면 + 수량표 — 수량표는 B07 횡단도만). */ +export interface CadSaveResult { + drawing: D; + quantityTable: Q | null; +} + +export interface CadHostOptions { + /** iframe 제목(접근성). */ + title: string; + /** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */ + onChanged?: (dirty: boolean) => void; + /** CAD 리본의 이전·다음 도면 단추. */ + onNavigate?: (direction: "prev" | "next") => void; + /** CAD 리본의 DXF·DWG 내보내기 단추. */ + onExport?: (fileFormat: "dxf" | "dwg") => void; +} + +/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */ +export interface CadLoadExtra { + /** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */ + recoveryScope?: string; + /** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */ + readOnly?: boolean; +} + +export interface CadHost { + /** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */ + element: HTMLElement; + /** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */ + beginLoading: () => void; + /** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */ + fail: (detail: string) => void; + /** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다. + * frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다 + * (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). */ + load: ( + drawing: D, + meta: M | null, + frameEdit?: boolean, + frameFields?: Record, + extra?: CadLoadExtra, + ) => void; + /** CAD 의 지금 편집본을 받는다. */ + requestSave: () => Promise>; + /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ + destroy: () => void; +} + +export function createCadHost( + options: CadHostOptions, +): CadHost { + const element = document.createElement("div"); + element.className = "cad-host"; + const frame = document.createElement("iframe"); + frame.className = "cad-host__frame"; + frame.src = CAD_APP_URL; + frame.title = options.title; + const license = document.createElement("a"); + license.className = "cad-host__license"; + license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt"; + license.target = "_blank"; + license.rel = "noreferrer"; + license.textContent = "Drawing engine based on OpenWebCAD · MIT License"; + element.append(frame, license); + + let cadReady = false; + let pendingLoad: + | { + drawing: D; + meta: M | null; + frameEdit: boolean; + frameFields: Record; + extra: CadLoadExtra; + } + | undefined; + let resolveSave: ((payload: CadSaveResult) => void) | undefined; + // ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다** + // (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` · + // `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다. + // ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴 + // ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴 + // 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면 + // 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다). + const CAD_READY_TIMEOUT_MS = 20000; + const CAD_LOAD_TIMEOUT_MS = 15000; + let loadWatchdog: number | undefined; + + const showFailure = (detail: string): void => { + element.dataset.loading = "false"; + element.dataset.error = detail; + showToast(detail, "error"); + }; + + const stopLoadWatchdog = (): void => { + if (loadWatchdog === undefined) return; + window.clearTimeout(loadWatchdog); + loadWatchdog = undefined; + }; + + const startLoadWatchdog = (): void => { + stopLoadWatchdog(); + // 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다. + const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS; + loadWatchdog = window.setTimeout(() => { + loadWatchdog = undefined; + if (element.dataset.loading !== "true") return; + showFailure( + cadReady + ? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요." + : "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.", + ); + }, wait); + }; + + // iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다. + frame.addEventListener("error", () => { + stopLoadWatchdog(); + showFailure("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요."); + }); + + const post = (message: Record): void => { + frame.contentWindow?.postMessage(message, window.location.origin); + }; + + const load: CadHost["load"] = ( + drawing, + meta, + frameEdit = false, + frameFields = {}, + extra = {}, + ) => { + pendingLoad = { drawing, meta, frameEdit, frameFields, extra }; + if (!cadReady) return; + post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra }); + pendingLoad = undefined; + }; + + const requestSave = (): Promise> => + new Promise((resolve, reject) => { + resolveSave = resolve; + post({ type: CAD_SAVE_REQUEST_MESSAGE }); + window.setTimeout(() => { + if (!resolveSave) return; + resolveSave = undefined; + reject(new Error("CAD 저장 응답 시간이 초과되었습니다.")); + }, 5000); + }); + + const onMessage = (event: MessageEvent): void => { + if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; + const message = event.data as { + type?: string; + detail?: string; + drawing?: D; + quantityTable?: Q | null; + direction?: "prev" | "next"; + fileFormat?: "dxf" | "dwg"; + dirty?: boolean; + kind?: string; + text?: string; + actionId?: string; + durationMs?: number; + }; + if (message.type === CAD_TOAST_MESSAGE) { + const kind = (["info", "success", "warning", "error"] as const).find( + (item) => item === message.kind, + ); + // autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다. + const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000); + const actionId = message.actionId; + showToast( + message.text ?? "", + kind ?? "info", + duration, + actionId ? () => post({ type: CAD_TOAST_ACTION_MESSAGE, actionId }) : undefined, + ); + } else if (message.type === CAD_READY_MESSAGE) { + cadReady = true; + if (pendingLoad) { + const { drawing, meta, frameEdit, frameFields, extra } = pendingLoad; + load(drawing, meta, frameEdit, frameFields, extra); + // 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다. + if (element.dataset.loading === "true") startLoadWatchdog(); + } + } else if (message.type === CAD_LOADED_MESSAGE) { + stopLoadWatchdog(); + element.dataset.loading = "false"; + } else if (message.type === CAD_ERROR_MESSAGE) { + stopLoadWatchdog(); + showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다."); + } else if (message.type === CAD_CHANGED_MESSAGE) { + options.onChanged?.(message.dirty !== false); + } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { + options.onNavigate?.(message.direction); + } else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) { + options.onExport?.(message.fileFormat); + } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { + const resolve = resolveSave; + resolveSave = undefined; + resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null }); + } + }; + window.addEventListener("message", onMessage); + + return { + element, + beginLoading: () => { + element.dataset.loading = "true"; + element.dataset.error = ""; // 앞선 실패 표시를 지운다 + startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다 + }, + fail: (detail) => { + stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다 + showFailure(detail); + }, + load, + requestSave, + destroy: () => { + stopLoadWatchdog(); + window.removeEventListener("message", onMessage); + }, + }; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/ui_template/cad_host/cad_host_frame_edit.ts similarity index 70% rename from B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts rename to ui_template/cad_host/cad_host_frame_edit.ts index a27b4cb1..6bc2597a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/ui_template/cad_host/cad_host_frame_edit.ts @@ -1,21 +1,27 @@ /** - * B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다. + * 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다. + * B07 에서 떼어 공용으로 둔다 — 도각을 읽고 쓰는 길(`api`)만 페이지가 넘긴다. * - * 정본(`resources/template_2dDrawing/00_template_A1.json`)은 프로그램 기본 도각이라 - * 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고, - * 이후 그리는 도면이 그것을 쓴다 (2026-09-01 사용자 확정). + * 시스템 양식(`resources/master_template/drawing/00_template_A1.json`)은 건드리지 않는다. + * 고친 도각은 **프로젝트 도각**(작업본 `{프로젝트}/templates/drawing/`)으로 저장되고, + * 이후 그리는 도면이 그것을 쓴다 (PLAN 10-5). * * 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다시 그려진다. */ import { createButton, showToast } from "@ui/ui_template_elements"; -import { - type CadDrawing, - fetchFrameTemplate, - importFrameTemplate, - resetFrameTemplate, - saveFrameTemplate, -} from "./B07_DesignDetail_Api_Fetch"; +import type { CadHostDrawing } from "./cad_host"; + +/** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */ +export interface FrameTemplateApi { + /** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */ + fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record }>; + save: (drawing: D) => Promise; + /** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */ + importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>; + /** 고친 도각을 지우고 기본 도각으로 되돌린다. */ + reset: () => Promise; +} export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ @@ -26,18 +32,18 @@ export interface FrameTemplateEditor { isEditing: () => boolean; } -interface Options { - projectId: string; +interface Options { + api: FrameTemplateApi; /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). * frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */ sendLoad: ( - drawing: CadDrawing, + drawing: D, meta: null, frameEdit?: boolean, frameFields?: Record, ) => void; /** CAD에서 현재 편집본을 받아온다. */ - requestCadDrawing: () => Promise; + requestCadDrawing: () => Promise; /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ restoreDrawing: () => void; /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ @@ -46,16 +52,18 @@ interface Options { currentDrawingInfo: () => { label: string; number: string } | null; } -export function createFrameTemplateEditor(options: Options): FrameTemplateEditor { +export function createFrameTemplateEditor( + options: Options, +): FrameTemplateEditor { let editing = false; // 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다. let frameFields: Record = {}; const banner = document.createElement("div"); - banner.className = "b07-frame-edit"; + banner.className = "cad-frame-edit"; banner.hidden = true; const label = document.createElement("span"); - label.className = "b07-frame-edit__label"; + label.className = "cad-frame-edit__label"; banner.append(label); const finishButton = createButton({ @@ -64,7 +72,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor onClick: () => void finish(), }); /** - * 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설). + * 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다 (2026-09-01 신설). * 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. */ const resetButton = createButton({ @@ -75,7 +83,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor /** * 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가 * 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직 - * 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + * 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다. */ const fileInput = document.createElement("input"); fileInput.type = "file"; @@ -95,7 +103,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor onClick: () => leave(), }); const bannerButtons = document.createElement("div"); - bannerButtons.className = "b07-frame-edit__buttons"; + bannerButtons.className = "cad-frame-edit__buttons"; bannerButtons.append(finishButton, importButton, resetButton, cancelButton); banner.append(bannerButtons, fileInput); @@ -118,7 +126,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor if (!file) return; importButton.disabled = true; try { - const response = await importFrameTemplate(options.projectId, file); + const response = await options.api.importFile(file); options.sendLoad(response.drawing, null, true, frameFields); label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`; showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); @@ -134,7 +142,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor async function enter(): Promise { try { - const response = await fetchFrameTemplate(options.projectId); + const response = await options.api.fetch(); // 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다. const info = options.currentDrawingInfo(); frameFields = { @@ -145,8 +153,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor button.disabled = true; banner.hidden = false; label.textContent = response.customized - ? "도각 편집 중 — 회사 도각을 고치고 있습니다." - : "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다."; + ? "도각 편집 중 — 이 프로젝트에서 고친 도각입니다." + : "도각 편집 중 — 고치면 이 프로젝트 도각으로 저장됩니다."; options.sendLoad(response.drawing, null, true, frameFields); } catch (error) { showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error"); @@ -156,16 +164,16 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor async function resetToDefault(): Promise { if ( !window.confirm( - "회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" + - "확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?", + "이 프로젝트 도각을 처음 복사한 도각으로 되돌립니다.\n" + + "확정하지 않은 도면부터 되돌린 도각으로 나옵니다. 계속할까요?", ) ) return; resetButton.disabled = true; try { - await resetFrameTemplate(options.projectId); + await options.api.reset(); options.onSaved(); - showToast("기본 도각으로 되돌렸습니다.", "success"); + showToast("처음 복사한 도각으로 되돌렸습니다.", "success"); leave(); } catch (error) { showToast( @@ -182,7 +190,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor finishButton.disabled = true; try { const drawing = await options.requestCadDrawing(); - await saveFrameTemplate(options.projectId, drawing); + await options.api.save(drawing); options.onSaved(); showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success"); leave();