diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 07c20a22..defbab99 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -4,7 +4,9 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface DesignDrawingItem { id: string; - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed"; + // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. + kind: + "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; label: string; chainage_m: number | null; confirmed: boolean; @@ -81,7 +83,9 @@ export interface DesignDrawingResponse { project_id: string; route_id: number; id: string; - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed"; + // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. + kind: + "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; label: string; drawing: CadDrawing; confirmed: boolean; @@ -184,3 +188,10 @@ export function saveFrameTemplate( body: JSON.stringify({ drawing }), }); } + +/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */ +export function resetFrameTemplate(projectId: string): Promise { + return requestJson(`/projects/${projectId}/frame-template`, { + method: "DELETE", + }); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py index 76fe67cb..3c54ffb6 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py @@ -25,11 +25,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _layer, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + _A1_INNER, _fill_placeholders, _load_template, + frame_entities, ) COVER_TEMPLATE = "00_template_cover" +# 빈 도면(준비 중)이 그림을 얹을 자유 도면층 — 도각만으로는 그릴 자리가 없다. +BLANK_LAYER_ID = "b08-blank-draft" # 표지는 엔티티가 전부 도각(잠금)이라 그대로 두면 덧그릴 자리가 없다 — 잠금 아닌 # 도면층을 하나 실어 사용자가 적을 자리를 준다(2026-09-01). NOTE_LAYER_ID = "b08-cover-note" @@ -60,3 +64,20 @@ def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) - _layer(FRAME_LAYER_ID, "도각", locked=True), ], } + + +def build_blank_drawing(drawing_id: str, label: str) -> dict[str, Any]: + """아직 내용을 만들지 않은 도면 — **도각만 실어** 연다 (2026-09-01 사용자 지시). + + 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. 도각이라도 열어 + 두면 사용자가 직접 그려 넣을 수도 있다. 도면명은 도각 표제란에 채운다. + """ + return { + "format": DRAWING_FORMAT, + # fit=False — 담을 콘텐츠가 없으니 A1 실치수 그대로 둔다. + "entities": frame_entities(drawing_id, _A1_INNER, fit=False, fields={"도면명": label}), + "layers": [ + _layer(BLANK_LAYER_ID, "작도"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 76c1830b..9dd44217 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -34,8 +34,9 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( usable_area, ) -# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않는 형식. -CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})") +# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m). +# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다. +CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. _BLOCK_GAP_MM = 8.0 @@ -147,11 +148,15 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]] group = blocks[start : start + count] number = len(sheets) + 1 + chainages = [chainage for chainage, _w, _h in group] sheets.append( { - "id": f"cross_s{number:02d}", + # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가 + # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 + # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). + "id": f"cross_s{chainages[0]:05d}m", "number": number, - "chainages": [chainage for chainage, _w, _h in group], + "chainages": chainages, "rows": len(row_heights), "slots": _slots(col_widths, row_heights, count), } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 63bb956e..ff8711a1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -94,10 +94,52 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: } +def validate_template_entities(entities: list[dict[str, Any]]) -> None: + """도면 조립이 실제로 쓰는 좌표가 숫자인지 본다. 어긋나면 ValueError. + + 검사 없이 받으면 좌표가 빠진 도형 하나로 **이후 모든 도면 요청이 500**이 되고 + 되돌릴 길이 없다(2026-09-01 지적). 여기서 막으면 편집 화면에 400으로 돌아간다. + `_transform_entity`·`entities_bbox`가 읽는 키만 본다 — 그 밖은 그대로 통과시킨다. + """ + + def _check(entity: Any, where: str) -> None: + if not isinstance(entity, dict): + raise ValueError(f"도각 도형이 올바르지 않습니다 ({where}).") + shape = entity.get("shapeData") + if isinstance(shape, dict): + for key in ("startPoint", "endPoint", "basePoint", "point", "center"): + point = shape.get(key) + if point is None: + continue + if not isinstance(point, dict) or not all( + isinstance(point.get(axis), (int, float)) for axis in ("x", "y") + ): + raise ValueError(f"도각 도형의 {key} 좌표가 숫자가 아닙니다 ({where}).") + if "radius" in shape and not isinstance(shape["radius"], (int, float)): + raise ValueError(f"도각 도형의 반지름이 숫자가 아닙니다 ({where}).") + children = entity.get("children") + if isinstance(children, list): + for index, child in enumerate(children): + _check(child, f"{where}:{index}") + + for index, entity in enumerate(entities): + _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(): + return False + path.unlink() + return True + + def save_company_template( company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE ) -> Path: """편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다.""" + validate_template_entities(entities) path = company_template_path(company_dir, name) path.parent.mkdir(parents=True, exist_ok=True) document = { diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 1012abeb..6104dd44 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -25,6 +25,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + clear_company_template, company_template_path, frame_template_document, save_company_template, @@ -426,3 +427,24 @@ async def put_frame_template( status_code=500, content={"status": "error", "message": "도각을 저장하지 못했습니다."}, ) + + +@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: + """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). + + 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. + 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. + """ + try: + company_dir = await _company_dir(project_id) + removed = await asyncio.to_thread(clear_company_template, company_dir) + return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."}, + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ac689b28..d536da10 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -22,7 +22,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( + build_blank_drawing, + build_cover_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -55,6 +58,19 @@ MASS_HAUL_ID = "mass_haul" WATERSHED_ID = "watershed" COVER_ID = "cover" +# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). +# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. +BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( + ("blank_plan_terrain", "계획평면도(지형)"), + ("blank_plan_route", "계획평면도(노선배치도)"), + ("blank_plan_layout", "계획평면도(배치도)"), + ("blank_plan_lidar", "계획평면도(라이다)"), + ("blank_cross_standard", "표준 횡단면도"), + ("blank_standard", "표준도"), + ("blank_landuse", "용지도"), +) +BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) + def _read_json(path: Path) -> dict[str, Any]: payload = json.loads(path.read_text(encoding="utf-8")) @@ -361,7 +377,14 @@ def _drawing_list( ) for chunk in longitudinal_chunks(longitudinal) ] - for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs): + # 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/` + # 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적). + try: + sheets = _cross_sheet_plan(project_root, longitudinal_path, designs) + except (FileNotFoundError, ValueError, OSError) as exc: + logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) + sheets = [] + for sheet in sheets: chainages = sheet["chainages"] first = station_by_chainage.get(chainages[0], {}) span = f"{chainages[0]}m" @@ -390,6 +413,10 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) + # 아직 내용을 만들지 않은 도면도 **빈 도각으로 열린다**(2026-09-01 사용자 지시). + # 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. + for drawing_id, label in BLANK_DRAWINGS: + drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label)) return drawings @@ -577,6 +604,11 @@ def _read_drawing( stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table + if drawing_id in BLANK_LABELS: + # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다. + label = BLANK_LABELS[drawing_id] + return "blank", label, build_blank_drawing(drawing_id, label), False, None + if drawing_id == COVER_ID: # 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다. return "cover", "표지", build_cover_drawing(drawing_id), False, None diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 9c6b305d..f88fe71f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -9,7 +9,8 @@ class DesignDrawingItem(BaseModel): """B06 확정 산출물에서 노출하는 도면 메타데이터.""" id: str - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"] + # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). + kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] label: str chainage_m: float | None = None confirmed: bool = False @@ -31,7 +32,8 @@ class DesignDrawingResponse(BaseModel): project_id: str route_id: int id: str - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"] + # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). + kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] label: str drawing: dict[str, Any] confirmed: bool = False diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 1f51c304..7e1bdd3d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { type CadDrawing, fetchFrameTemplate, + resetFrameTemplate, saveFrameTemplate, } from "./B07_DesignDetail_Api_Fetch"; @@ -53,12 +54,21 @@ export function createFrameTemplateEditor( variant: "filled", onClick: () => void finish(), }); + /** + * 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설). + * 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. + */ + const resetButton = createButton({ + label: "기본 도각으로", + variant: "ghost", + onClick: () => void resetToDefault(), + }); const cancelButton = createButton({ label: "취소", variant: "ghost", onClick: () => leave(), }); - banner.append(finishButton, cancelButton); + banner.append(finishButton, resetButton, cancelButton); const button = createButton({ label: "도각 편집", @@ -91,6 +101,32 @@ export function createFrameTemplateEditor( } } + async function resetToDefault(): Promise { + if ( + !window.confirm( + "회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" + + "확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?", + ) + ) + return; + resetButton.disabled = true; + try { + await resetFrameTemplate(options.projectId); + options.onSaved(); + showToast("기본 도각으로 되돌렸습니다.", "success"); + leave(); + } catch (error) { + showToast( + error instanceof Error + ? error.message + : "기본 도각으로 되돌리지 못했습니다.", + "error", + ); + } finally { + resetButton.disabled = false; + } + } + async function finish(): Promise { if (!editing) return; finishButton.disabled = true; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index b78ae815..32fd5f38 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -44,9 +44,12 @@ import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { - kind: "cover" | "cross" | "longitudinal" | "mass_haul" | "watershed"; + kind: DesignDrawingItem["kind"]; + /** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */ + drawingId: string; title: string; info: string; + /** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */ confirmed: boolean; quantityTable: QuantityTable | null; hasPrev: boolean; @@ -80,23 +83,29 @@ const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; -/** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */ +/** + * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). + * + * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 + * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. + */ const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"]; + blankId?: string; }[] = [ { label: "표지", kind: "cover" }, - { label: "계획평면도(지형)" }, - { label: "계획평면도(노선배치도)" }, - { label: "계획평면도(배치도)" }, - { label: "계획평면도(라이다)" }, + { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, + { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, + { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, + { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도" }, + { label: "표준 횡단면도", blankId: "blank_cross_standard" }, { label: "횡단면도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "표준도" }, - { label: "용지도" }, + { label: "표준도", blankId: "blank_standard" }, + { label: "용지도", blankId: "blank_landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */ @@ -144,24 +153,30 @@ function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { const items = group.kind ? drawings.filter((item) => item.kind === group.kind) - : []; - // 한 장짜리(와 아직 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. + : drawings.filter((item) => item.id === group.blankId); + // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. if (items.length <= 1) { const [drawing] = items; - const button = drawing - ? drawingButton(drawing, group.label) - : document.createElement("button"); + // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. if (!drawing) { - button.type = "button"; - button.className = "b07-drawing-button"; - button.disabled = true; - button.title = "준비 중"; + const placeholder = document.createElement("button"); + placeholder.type = "button"; + placeholder.className = "b07-drawing-button"; + placeholder.disabled = true; + placeholder.title = "준비 중"; + placeholder.dataset.pending = "true"; const name = document.createElement("span"); name.className = "b07-drawing-button__name"; name.textContent = group.label; - button.append(name); + placeholder.append(name); + panel.append(placeholder); + continue; } - button.dataset.pending = String(!drawing); + const button = drawingButton(drawing, group.label); + // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. + button.dataset.pending = String(drawing.kind === "blank"); + if (drawing.kind === "blank") + button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } @@ -356,13 +371,35 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); }; + /** + * CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나 + * 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다 + * (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다. + */ + let cadDirty = false; + + /** + * 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정). + * 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다. + */ const confirmButton = createButton({ label: "현재 도면 확정", variant: "filled", - onClick: () => void confirmCurrentDrawing(), + onClick: () => { + if (currentConfirmed) void reopenCurrentDrawing(); + else void confirmCurrentDrawing(); + }, }); confirmButton.disabled = true; + const applyConfirmButtonState = (): void => { + confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정"; + confirmButton.title = currentConfirmed + ? "확정을 풀고 이 도면을 고칩니다." + : "이 도면을 확정하고 저장합니다."; + confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank"; + }; + const findButton = (drawingId: string) => drawingListEl?.querySelector( `.b07-drawing-button[data-drawing-id="${drawingId}"]`, @@ -382,6 +419,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { index: number, ): DesignMeta => ({ kind: drawing.kind, + drawingId: drawing.id, title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: response.confirmed, @@ -451,18 +489,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } }; + /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ + const mayDiscardEdits = (): boolean => { + if (!cadDirty) return true; + return window.confirm( + "저장하지 않은 편집이 있습니다.\n" + + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + + "남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.", + ); + }; + + /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ + let loadInFlight = false; + const loadDrawing = async (drawing: DesignDrawingItem, index: number) => { - if (!projectId) return; + if (!projectId || loadInFlight) return; + loadInFlight = true; highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; cadHost.dataset.loading = "true"; + cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다 try { const response = await requestDrawing(drawing); currentDrawing = drawing; currentIndex = index; currentConfirmed = response.confirmed; - confirmButton.disabled = response.confirmed; + cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 + applyConfirmButtonState(); updateInfoPanel(drawing, response); sendLoad(response.drawing, buildMeta(drawing, response, index)); } catch (error) { @@ -471,12 +525,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; + showToast(cadHost.dataset.error, "error"); + if (currentDrawing) highlightActive(currentDrawing.id); } finally { + loadInFlight = false; if (button) button.dataset.loading = "false"; } }; const selectDrawing = (drawing: DesignDrawingItem) => { + if (drawing === currentDrawing || !mayDiscardEdits()) return; void loadDrawing(drawing, drawings.indexOf(drawing)); }; @@ -484,6 +542,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (currentIndex < 0) return; const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; if (target < 0 || target >= drawings.length) return; + if (!mayDiscardEdits()) return; void loadDrawing(drawings[target], target); }; @@ -514,11 +573,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); currentConfirmed = true; currentDrawing.confirmed = true; - confirmButton.disabled = true; + cadDirty = false; // 저장했다 + applyConfirmButtonState(); drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다. const button = findButton(currentDrawing.id); if (button) button.dataset.confirmed = "true"; + // 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다. + // **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직 + // 편집이 열린 어긋난 순간이 생긴다. + await loadDrawing(currentDrawing, currentIndex); // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. if (currentDrawing.kind === "cross") { infoPanelHost.replaceChildren( @@ -542,29 +606,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } - const invalidateCurrentDrawing = async () => { - if (!projectId || !currentDrawing) return; - const wasConfirmed = currentConfirmed; - drawingCache.delete(currentDrawing.id); - currentConfirmed = false; - allDrawingsConfirmed = false; - currentDrawing.confirmed = false; - confirmButton.disabled = false; - const button = findButton(currentDrawing.id); - if (button) button.dataset.confirmed = "false"; - if (wasConfirmed) { - try { - await invalidateDesignDrawing(projectId, currentDrawing.id); - } catch (error) { - showToast( - error instanceof Error - ? error.message - : "도면 확정 상태를 되돌리지 못했습니다.", - "error", - ); - } + /** + * [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다 + * (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나 + * 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. + */ + async function reopenCurrentDrawing(): Promise { + if (!projectId || !currentDrawing || !currentConfirmed) return; + showLoadingOverlay(); + try { + await invalidateDesignDrawing(projectId, currentDrawing.id); + drawingCache.delete(currentDrawing.id); + currentConfirmed = false; + allDrawingsConfirmed = false; + currentDrawing.confirmed = false; + const button = findButton(currentDrawing.id); + if (button) button.dataset.confirmed = "false"; + applyConfirmButtonState(); + // 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다. + await loadDrawing(currentDrawing, currentIndex); + showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info"); + } catch (error) { + showToast( + error instanceof Error + ? error.message + : "도면 확정 상태를 되돌리지 못했습니다.", + "error", + ); + } finally { + hideLoadingOverlay(); } - }; + } const frameEditor = createFrameTemplateEditor({ projectId: projectId as string, @@ -589,6 +661,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { drawing?: CadDrawing; quantityTable?: QuantityTable | null; direction?: "prev" | "next"; + dirty?: boolean; kind?: string; text?: string; actionId?: string; @@ -622,9 +695,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } else if (message.type === CAD_ERROR_MESSAGE) { cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다."; + cadHost.dataset.loading = "false"; + showToast(cadHost.dataset.error, "error"); } else if (message.type === CAD_CHANGED_MESSAGE) { - // 도각을 고치는 중에 온 변경 알림은 도면 편집이 아니다 — 확정을 풀면 안 된다. - if (!frameEditor.isEditing()) void invalidateCurrentDrawing(); + // 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다 + // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 + // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. + if (!frameEditor.isEditing()) cadDirty = message.dirty !== false; } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { navigateDrawing(message.direction); } else if ( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index cb6646f7..8ba7b7f2 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -63,8 +63,13 @@ font-weight: 600; } -/* 아직 만들지 않는 도면 — 자리만 잡아 둔 버튼. */ +/* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되 + 못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */ .b07-drawing-button[data-pending="true"] { + opacity: 0.72; +} + +.b07-drawing-button[data-pending="true"]:disabled { opacity: 0.5; cursor: not-allowed; } @@ -144,6 +149,40 @@ 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; diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 4d73ce79..6d9438ae 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -8,7 +8,9 @@ /* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */ --cad-command-height: 86px; --cad-status-height: 28px; - --cad-panel-width: 248px; + /* 도면층 한 행에 아이콘 6개와 이름이 함께 들어가는 폭. 248px에서는 이름 몫이 + 21px밖에 남지 않아 글자가 뭉개졌다(2026-09-01 실측). */ + --cad-panel-width: 300px; font-family: var(--font-body); color: var(--color-text-body); background: var(--color-bg); @@ -296,7 +298,8 @@ body > canvas[data-id="canvas"] { width: 44px; padding: 1px; background: var(--cad-chrome-sunken); - border: 1px solid var(--cad-line); + /* 흰색을 골라도 스와치 자리가 보이게 테두리를 진하게 준다 (2026-09-01) */ + border: 1px solid var(--cad-text-muted); border-radius: 3px; cursor: pointer; } @@ -442,12 +445,29 @@ body > canvas[data-id="canvas"] { max-height: calc(100vh - 250px); padding: 9px; overflow-y: auto; + overflow-x: hidden; } .cad-layer-manager button { min-height: 36px; padding-top: 6px; padding-bottom: 6px; } +/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이 + 행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */ +.cad-layer-manager .layer > button > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.cad-layer-manager .layer > button > div { + flex-shrink: 0; +} +/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */ +.cad-layer-manager .layer > button [data-size="small"] { + padding-left: 2px; + padding-right: 2px; + min-width: 0; +} /* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */ .cad-layer-manager button[data-type="regular"] { background: var(--cad-chrome-sunken); diff --git a/B07_DesignDetail/openwebcad/src/App.types.ts b/B07_DesignDetail/openwebcad/src/App.types.ts index e294c116..124c4f0a 100644 --- a/B07_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_DesignDetail/openwebcad/src/App.types.ts @@ -40,11 +40,14 @@ export enum HtmlEvent { /** 부모(B08 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */ export interface DesignMeta { - kind: 'cross' | 'longitudinal' | 'mass_haul' | 'watershed'; + kind: 'cover' | 'cross' | 'longitudinal' | 'mass_haul' | 'watershed' | 'blank'; + /** 도면 식별자 — 자동백업을 도면별로 나누는 열쇠. */ + drawingId: string; /** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */ title: string; /** 측점 부가 정보 (예: "STA.0+050.000") */ info: string; + /** 확정본은 읽기 전용 — 그리기·수정·값 편집이 막힌다. 부모의 [수정]으로 푼다. */ confirmed: boolean; /** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */ quantityTable: Record | null; diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index 9b976b78..1409ee81 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -56,6 +56,45 @@ for (const command of ALL_COMMANDS) { } } +/** + * 확정한 도면에서도 쓸 수 있는 명령 — **보는 일만 하는 것들**이다 + * (2026-09-01 사용자 확정: 확정하면 수정이 안 되고, 풀려면 [수정]을 누른다). + * + * 허용을 명시하는 쪽으로 짠다. 새 편집 명령이 늘어도 자동으로 막히고, 반대로 짜면 + * 새 명령마다 여기 적어야 해 언젠가 빠뜨린다. + */ +const VIEW_ONLY_COMMAND_IDS: ReadonlySet = new Set([ + // 보기 — 확대·이동·다시그리기·선택 + ...VIEW_COMMANDS.map((command) => command.id), + // 조회 — 거리·면적·각도처럼 재기만 하는 것 + ...INQUIRY_COMMANDS.map((command) => command.id), + // 도면층 보이기·잠금 — 객체를 건드리지 않는 것만 (LAYMCH·LAYCUR·LAYMRG·LAYDEL 제외) + 'LAYER', + 'LAYCURSET', + 'LAYOFF', + 'LAYON', + 'LAYFRZ', + 'LAYTHW', + 'LAYLCK', + 'LAYULK', + 'LAYISO', + 'LAYUNISO', + 'LAYERP', + 'LAYERSTATE', + 'LAYERSTATERESTORE', + 'LAYWALK', + // 파일 — 내보내기와 클립보드 복사는 도면을 바꾸지 않는다 + 'EXPORT', + 'EXPORTSVG', + 'EXPORTPNG', + 'COPYCLIP', + 'COPYBASE', +]); + +/** 확정한 도면에서 이 명령을 실행해도 되는가. */ +export const isViewOnlyCommand = (command: CadCommand): boolean => + VIEW_ONLY_COMMAND_IDS.has(command.id); + export const getAllCommands = (): CadCommand[] => ALL_COMMANDS; export const getCommandById = (id: string): CadCommand | undefined => COMMANDS_BY_ID.get(id); diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 64b21f42..3b0a4a86 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -2,9 +2,9 @@ import { toast } from 'react-toastify'; import { Actor } from 'xstate'; import { HtmlEvent } from '../App.types'; -import { getSelectedEntities, setActiveToolActor } from '../state'; +import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state'; import type { CadCommand } from './command.types'; -import { getCommandById, resolveCommandInput } from './registry'; +import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry'; const COMMAND_HISTORY_LIMIT = 200; @@ -24,6 +24,13 @@ function log(line: string) { /** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */ export function runCommand(command: CadCommand): string { + // 확정한 도면은 읽기 전용이다 — 보는 명령만 통과시킨다. 리본·명령행·단축키가 모두 + // 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정). + if (isDrawingReadOnly() && !isViewOnlyCommand(command)) { + toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`); + log(`${command.id}: 확정 도면 — 실행하지 않음`); + return ''; + } if (command.needsSelection && getSelectedEntities().length === 0) { toast.info(`${command.label}: 객체를 먼저 선택하십시오.`); log(`${command.id}: 선택 없음`); diff --git a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx index e050df89..0191975f 100644 --- a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx +++ b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx @@ -44,13 +44,15 @@ export const LayerManager: FC = ({ for (const entity of selectedEntities) { entity.layerId = layerId; } - console.info(`Assigned ${selectedEntities.length} entities to layer`); + // 객체를 옮기는 것도 도면 편집이다 — 되돌리기·미저장 표시에 실어야 한다. + setEntities([...getEntities()], true); }; const handleDeleteLayer = (evt: MouseEvent, layerId: string): void => { evt.stopPropagation(); const entitiesNotOnLayer = getEntities().filter((entity) => entity.layerId !== layerId); - setEntities(entitiesNotOnLayer); + // 도면층을 지우면 그 위의 객체가 통째로 사라진다 — 되돌리기에 실어야 Ctrl+Z로 돌아온다. + setEntities(entitiesNotOnLayer, true); setLayers(getLayers().filter((layer) => layer.id !== layerId)); if (getActiveLayerId() === layerId) { setActiveLayerId(getLayers()[0].id); diff --git a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx index 5e303d01..b680a9b0 100644 --- a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx +++ b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx @@ -36,16 +36,31 @@ export const dashToLineType = (dash: number[] | undefined): string => LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid'; /** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */ -function applyToSelection(mutate: (entity: Entity) => void): boolean { +function applyToSelection(mutate: (entity: Entity) => void, trackInUndoStack = true): boolean { const selected = getSelectedEntities(); if (!selected.length) return false; for (const entity of selected) { mutate(entity); } - setEntities([...getEntities()], true); + setEntities([...getEntities()], trackInUndoStack); return true; } +/** + * 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면 + * 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 끄는 동안에는 화면만 칠하고, + * 멎은 뒤 한 번만 되돌리기·통지에 싣는다. + */ +const COLOR_COMMIT_DELAY_MS = 350; +let colorCommitTimer: ReturnType | undefined; +function paintNowCommitLater(mutate: (entity: Entity) => void): void { + applyToSelection(mutate, false); // 화면만 — 되돌리기·미저장 표시는 아직 + clearTimeout(colorCommitTimer); + colorCommitTimer = setTimeout(() => { + applyToSelection(() => {}, true); // 멎었다 — 이번 한 번만 실어 보낸다 + }, COLOR_COMMIT_DELAY_MS); +} + export const PropertiesWidget: FC = () => { const lineColor = getActiveLineColor(); const lineWidth = getActiveLineWidth(); @@ -59,9 +74,10 @@ export const PropertiesWidget: FC = () => { type="color" value={lineColor} onChange={(event) => { - setActiveLineColor(event.target.value); - applyToSelection((entity) => { - entity.lineColor = event.target.value; + const color = event.target.value; + setActiveLineColor(color); + paintNowCommitLater((entity) => { + entity.lineColor = color; }); }} /> @@ -145,13 +161,16 @@ export const LayersWidget: FC = () => { export const TextStyleWidget: FC = () => { const textStyle = getActiveTextStyle(); - const handle = (patch: Partial) => { + const handle = (patch: Partial, live = false) => { setActiveTextStyle(patch); - applyToSelection((entity) => { + const mutate = (entity: Entity) => { if (entity.getType() === EntityName.Text) { (entity as TextEntity).setTextOptions(patch); } - }); + }; + // 색만 드래그 중 연발한다 — 나머지 항목은 한 번 고르면 끝이라 바로 싣는다. + if (live) paintNowCommitLater(mutate); + else applyToSelection(mutate); }; return ( @@ -203,7 +222,7 @@ export const TextStyleWidget: FC = () => { handle({ textColor: event.target.value })} + onChange={(event) => handle({ textColor: event.target.value }, true)} /> diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 75eb81cd..653ce154 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -24,10 +24,10 @@ export const Toolbar: FC = () => { const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null; useEffect(() => { - document.documentElement.style.setProperty( - '--cad-panel-width', - panelCollapsed ? '0px' : '248px' - ); + // 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아 + // 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨). + if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px'); + else document.documentElement.style.removeProperty('--cad-panel-width'); document.documentElement.style.setProperty( '--cad-command-height', commandLineVisible ? '86px' : '0px' diff --git a/B07_DesignDetail/openwebcad/src/helpers/autosave.ts b/B07_DesignDetail/openwebcad/src/helpers/autosave.ts index 0475c919..43990218 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/autosave.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/autosave.ts @@ -6,7 +6,14 @@ import { toast } from 'react-toastify'; import { HtmlEvent } from '../App.types'; import type { Entity } from '../entities/Entity'; -import { getEntities, resetUndoBaseline, setActiveLayerId, setEntities, setLayers } from '../state'; +import { + getEntities, + isDrawingReadOnly, + resetUndoBaseline, + setActiveLayerId, + setEntities, + setLayers, +} from '../state'; import { exportEntitiesAndLayersToJsonString } from './import-export-handlers/export-entities-to-json'; import { getEntitiesAndLayersFromJsonString } from './import-export-handlers/import-entities-from-json'; @@ -22,9 +29,37 @@ let debounceTimer: ReturnType | undefined; /** 마지막으로 백업한 객체 배열. setEntities가 매번 새 배열을 만들어 참조로 비교된다 */ let lastBackedUp: Entity[] | null = null; +/** + * 지금 백업이 어느 도면 것인가. 칸을 하나만 쓰면 도면을 넘겨볼 때마다 서로 덮어 + * 마지막에 본 것만 남고, 되살리면 **지금 열린 다른 도면 위에 붙는다** + * (2026-09-01 지적). B07 임베드에서는 도면을 실을 때마다 이 값이 바뀐다. + */ +let recoveryScope: string | null = null; +/** 서버 도면을 싣는 동안에는 백업하지 않는다 — 적재가 사용자 백업을 덮어 쓴다. */ +let suspended = false; + +function recoveryKey(): string { + return recoveryScope ? `${RECOVERY_KEY}__${recoveryScope}` : RECOVERY_KEY; +} + +/** + * 백업 칸을 이 도면 것으로 옮긴다 (B07 브리지가 도면을 실을 때 부른다). + * 옮기는 동안은 백업을 멈춘다 — 방금 실은 서버 도면이 사용자 백업을 덮지 않게. + */ +export function setRecoveryScope(drawingId: string | null): void { + recoveryScope = drawingId; + lastBackedUp = null; + suspended = true; + clearTimeout(debounceTimer); + // 적재가 부르는 상태 갱신이 모두 지나간 뒤 다시 연다. + setTimeout(() => { + suspended = false; + }, AUTOSAVE_DEBOUNCE_MS); +} + function readRecovery(): RecoveryFile | null { try { - const raw = localStorage.getItem(RECOVERY_KEY); + const raw = localStorage.getItem(recoveryKey()); return raw ? (JSON.parse(raw) as RecoveryFile) : null; } catch { return null; @@ -33,13 +68,13 @@ function readRecovery(): RecoveryFile | null { async function writeBackup(): Promise { const entities = getEntities(); - if (entities === lastBackedUp || entities.length === 0) return; + if (suspended || entities === lastBackedUp || entities.length === 0) return; try { const file: RecoveryFile = { savedAt: Date.now(), json: await exportEntitiesAndLayersToJsonString(), }; - localStorage.setItem(RECOVERY_KEY, JSON.stringify(file)); + localStorage.setItem(recoveryKey(), JSON.stringify(file)); lastBackedUp = entities; } catch { // 저장 공간이 없거나 막혀 있으면 조용히 넘어간다 — 편집을 막을 일은 아니다 @@ -48,13 +83,17 @@ async function writeBackup(): Promise { export function clearRecovery(): void { try { - localStorage.removeItem(RECOVERY_KEY); + localStorage.removeItem(recoveryKey()); } catch { // 지우지 못해도 다음 백업이 덮어쓴다 } } export async function restoreRecovery(): Promise { + if (isDrawingReadOnly()) { + toast.info('확정한 도면입니다. 되살리려면 [수정]을 먼저 누르세요.'); + return; + } const file = readRecovery(); if (!file) { toast.info('복구할 백업이 없습니다.'); diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 32bd16c2..4fa3f95e 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -2,7 +2,16 @@ * 화면 검증용 상태 조회 창구 — 브라우저 콘솔·자동화 스크립트가 * window.__aisloCad로 현재 객체·도면층·선택을 읽는다. 앱 동작에는 관여하지 않는다. */ -import { getEntities, getLayers, getSelectedEntityIds, getSnapPoint } from '../state'; +import { + getDesignMeta, + getEntities, + getHighlightedEntityIds, + getLayers, + getSelectedEntityIds, + getSnapPoint, + isDrawingDirty, + isDrawingReadOnly, +} from '../state'; import { isEntityHidden } from './visibility'; export function registerCadDebugHook(): void { @@ -30,5 +39,11 @@ export function registerCadDebugHook(): void { selection: () => getSelectedEntityIds(), // 마우스 위치의 객체 스냅점 — 잠금 도면층(도각)이 스냅에 끼는지 검증할 때 읽는다. snap: () => getSnapPoint(), + // 마우스가 스친 객체 — 잠금층(도각·등고선)이 밝아지는지 수치로 본다. + highlighted: () => getHighlightedEntityIds(), + // 확정 도면의 편집 잠금과 미저장 표시 — 화면 검증이 상태를 직접 읽는다. + readOnly: () => isDrawingReadOnly(), + dirty: () => isDrawingDirty(), + meta: () => getDesignMeta(), }; } diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 272aa8b5..e5d135b1 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -5,17 +5,23 @@ import type { JsonDrawingFileSerialized } from '../helpers/import-export-handler import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts'; import { + clearDrawingDirty, getCanvas, getDesignMeta, getEntities, getLayers, getScreenCanvasDrawController, + isDrawingDirty, + isDrawingReadOnly, resetUndoBaseline, setActiveLayerId, setDesignMeta, setEntities, setLayers, } from '../state.ts'; +import { toast } from 'react-toastify'; +import { runCommandInput } from '../commands/run-command.ts'; +import { setRecoveryScope } from '../helpers/autosave.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -52,19 +58,25 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') { notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction }); } -/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */ -export function notifyDrawingChangedByTable() { - notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); -} +/** + * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 + * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 + * 화면과 저장값이 어긋났다. + */ +const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table'; /** * 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다. - * CAD 테이블(측점 테이블·수량 산출표) 값 셀 수정 수단 — 잠금 레이어는 제외. + * CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외. */ function registerTextDoubleClickEdit() { const canvas = getCanvas(); if (!canvas) return; canvas.addEventListener('dblclick', (event: MouseEvent) => { + if (isDrawingReadOnly()) { + toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.'); + return; + } const drawController = getScreenCanvasDrawController(); const bounds = canvas.getBoundingClientRect(); const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); @@ -76,18 +88,24 @@ function registerTextDoubleClickEdit() { ); let closest: TextEntity | null = null; let closestDistance = Number.MAX_SAFE_INTEGER; + let closestOnQuantityTable = false; for (const entity of getEntities()) { if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; const distanceInfo = entity.distanceTo(worldPoint); if (distanceInfo && distanceInfo[0] < closestDistance) { closestDistance = distanceInfo[0]; closest = entity; + closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID; } } if (!closest) return; const fontSize = closest.getTextOptions().fontSize; const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); if (closestDistance > tolerance) return; + if (closestOnQuantityTable) { + toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.'); + return; + } const nextLabel = window.prompt('값 수정', closest.getLabel()); if (nextLabel === null || nextLabel === closest.getLabel()) return; closest.setLabel(nextLabel); @@ -108,8 +126,9 @@ export function registerAisloDrawingBridge() { const drawing = JSON.parse( await exportEntitiesAndLayersToJsonString() ) as JsonDrawingFileSerialized; - // 편집된 수량표 값을 도면과 함께 부모로 돌려준다. + // 수량표는 앞 단계 산출값을 그대로 돌려준다 — B07에서는 고치지 않는다. const quantityTable = getDesignMeta()?.quantityTable ?? null; + clearDrawingDirty(); // 부모가 받아 저장한다 — 미저장 경고 대상이 아니다 notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable }); } catch (error) { const detail = error instanceof Error ? error.message : 'Unable to serialize drawing'; @@ -125,9 +144,17 @@ export function registerAisloDrawingBridge() { setLayers(drawing.layers); setActiveLayerId(drawing.layers[0].id); // 실은 도면이 되돌리기의 바닥 — 첫 Ctrl+Z가 백지로 가지 않게 한다. + // (미저장 표시도 여기서 내려간다 — 방금 서버 도면을 실었으니 고친 것이 없다.) resetUndoBaseline(); // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); + // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** + // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: + // 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중 + // 상태를 이어 갈 이유도 없다. + runCommandInput('SELECT'); + // 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다. + setRecoveryScope(event.data.meta?.drawingId ?? null); getScreenCanvasDrawController().zoomToFitScreen(); notifyParent(AISLO_DRAWING_LOADED_MESSAGE); } catch (error) { @@ -135,8 +162,10 @@ export function registerAisloDrawingBridge() { notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail }); } }); + // 부모는 이 통지로 **미저장 여부만** 안다. 확정을 푸는 것은 [수정] 버튼 한 곳뿐이다 + // (2026-09-01 사용자 확정) — 예전에는 되돌리기·색 고르기까지 확정을 풀었다. window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => { - notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); + notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() }); }); registerTextDoubleClickEdit(); diff --git a/B07_DesignDetail/openwebcad/src/state.test.ts b/B07_DesignDetail/openwebcad/src/state.test.ts index 08caaf08..a45b4fe2 100644 --- a/B07_DesignDetail/openwebcad/src/state.test.ts +++ b/B07_DesignDetail/openwebcad/src/state.test.ts @@ -1,11 +1,18 @@ import { beforeAll, describe, expect, it } from 'vitest'; +import type { DesignMeta } from './App.types'; import type { Entity } from './entities/Entity'; import { + clearDrawingDirty, getActiveLayerId, getEntities, + getHighlightedEntityIds, + isDrawingDirty, + isDrawingReadOnly, resetUndoBaseline, setActiveLayerId, + setDesignMeta, setEntities, + setHighlightedEntityIds, setLayers, undo, } from './state'; @@ -17,6 +24,24 @@ const layer = (id: string, isLocked: boolean) => ({ isLocked, }); +/** 노드 환경에는 window가 없다 — 상태 알림을 받는 시늉만 시킨다. */ +const stubWindow = () => { + (globalThis as { window?: unknown }).window = { dispatchEvent: () => true }; +}; + +const entityOnLayer = (id: string, layerId: string) => ({ id, layerId }) as unknown as Entity; + +const meta = (confirmed: boolean): DesignMeta => ({ + kind: 'cross', + drawingId: 'cross_s00020m', + title: '1장', + info: '', + confirmed, + quantityTable: null, + hasPrev: false, + hasNext: false, +}); + describe('setActiveLayerId', () => { it('잠금 도면층(배경)은 현재 도면층이 되지 않고 잠금 아닌 첫 층으로 간다', () => { setLayers([layer('도각', true), layer('계획선', false)]); @@ -32,10 +57,7 @@ describe('setActiveLayerId', () => { }); describe('resetUndoBaseline', () => { - // 상태 변경 알림만 window로 나간다 — 노드 환경이라 받는 시늉만 시킨다. - beforeAll(() => { - (globalThis as { window?: unknown }).window = { dispatchEvent: () => true }; - }); + beforeAll(stubWindow); it('기준선을 세운 뒤의 되돌리기는 실은 도면까지만 물러난다', () => { const loaded = [{ id: 'a' } as unknown as Entity]; @@ -46,3 +68,55 @@ describe('resetUndoBaseline', () => { expect(getEntities()).toHaveLength(1); }); }); + +describe('setHighlightedEntityIds', () => { + it('잠금 도면층(도각·등고선) 객체는 마우스가 가도 밝아지지 않는다', () => { + setLayers([layer('도각', true), layer('계획선', false)]); + setEntities([entityOnLayer('배경', '도각'), entityOnLayer('내선', '계획선')], false); + setHighlightedEntityIds(['배경', '내선']); + expect(getHighlightedEntityIds()).toEqual(['내선']); + }); + + it('잠금층만 가리키면 아무것도 밝아지지 않는다', () => { + setLayers([layer('도각', true)]); + setEntities([entityOnLayer('배경', '도각')], false); + setHighlightedEntityIds(['배경']); + expect(getHighlightedEntityIds()).toEqual([]); + }); +}); + +describe('미저장 표시(drawingDirty)', () => { + beforeAll(stubWindow); + + it('편집하면 서고, 도면을 새로 실으면(기준선 재설정) 내려간다', () => { + setEntities([entityOnLayer('a', 'L')], false); + resetUndoBaseline(); + expect(isDrawingDirty()).toBe(false); + setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true); + expect(isDrawingDirty()).toBe(true); + resetUndoBaseline(); + expect(isDrawingDirty()).toBe(false); + }); + + it('되돌리기도 미저장이다 — 저장본과 달라질 수 있다', () => { + setEntities([entityOnLayer('a', 'L')], false); + resetUndoBaseline(); + setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true); + clearDrawingDirty(); + undo(); + expect(isDrawingDirty()).toBe(true); + }); +}); + +describe('isDrawingReadOnly', () => { + beforeAll(stubWindow); + + it('확정한 도면은 읽기 전용, 확정을 풀면 편집이 열린다', () => { + setDesignMeta(meta(true)); + expect(isDrawingReadOnly()).toBe(true); + setDesignMeta(meta(false)); + expect(isDrawingReadOnly()).toBe(false); + setDesignMeta(null); + expect(isDrawingReadOnly()).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index a37414b4..9b2393b3 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -126,8 +126,11 @@ let lastDrawTimestamp: DOMHighResTimeStamp = 0; /** * Active line color (7-char hex so can consume it directly) + * + * 종이 배경(흰색)에 그리므로 검정이 기본이다. 원본 OpenWebCAD의 흰색을 그대로 두면 + * 그은 선도 색 스와치도 흰 바탕에 묻혀 보이지 않는다(2026-09-01 실측). */ -let activeLineColor = '#ffffff'; +let activeLineColor = '#000000'; /** * Active line width @@ -145,7 +148,7 @@ let activeLineDash: number[] | undefined = undefined; let activeTextStyle = { fontFamily: 'Noto Sans KR', fontSize: 16, - textColor: '#ffffff', + textColor: '#000000', // 흰 종이 배경 — activeLineColor와 같은 이유로 검정이 기본이다 bold: false, italic: false, }; @@ -184,6 +187,13 @@ let snapTrackingEnabled = true; */ let designMeta: DesignMeta | null = null; +/** + * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — + * 없으면 사용자가 그은 선이 경고 없이 사라진다(2026-09-01 실측). + * 도면 적재와 저장 응답에서 내려간다. + */ +let drawingDirty = false; + // getters export const getCanvas = () => canvas; export const getActiveToolActor = () => activeToolActor; @@ -246,6 +256,12 @@ export const getSnapEnabled = () => snapEnabled; export const getGridEnabled = () => gridEnabled; export const getSnapTrackingEnabled = () => snapTrackingEnabled; export const getDesignMeta = (): DesignMeta | null => designMeta; +export const isDrawingDirty = () => drawingDirty; +/** + * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 + * 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다. + */ +export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true; // setters export const setCanvas = (newCanvas: HTMLCanvasElement) => { @@ -304,10 +320,21 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => entities = newEntities; bumpSceneVersion(); if (trackInUndoStack) { + drawingDirty = true; window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } }; +/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */ +export const clearDrawingDirty = () => { + drawingDirty = false; +}; export const setHighlightedEntityIds = (newEntityIds: string[]) => { + // 잠금 도면층(도각·등고선·계류·원지반)은 배경이다 — 마우스가 스쳐도 밝아지지 않는다 + // (2026-09-01 사용자 지시). 거르는 자리를 여기 한 곳에 둬 호출부가 늘어도 새지 않게 한다. + const locked = new Set( + entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id) + ); + if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id)); highlightedEntityIds = newEntityIds; highlightedEntityIdSet = new Set(newEntityIds); }; @@ -453,12 +480,9 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; triggerReactUpdate(StateVariable.designMeta); }; -/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */ -export const setDesignQuantityTable = (table: Record) => { - if (!designMeta) return; - designMeta = { ...designMeta, quantityTable: table, confirmed: false }; - triggerReactUpdate(StateVariable.designMeta); -}; +// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정). +// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은 +// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다. // Computed setters export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => { @@ -523,6 +547,7 @@ function updateStates(undoState: UndoState) { export function resetUndoBaseline() { undoStack.clear(StateVariable.entities); undoStack.push({ variable: StateVariable.entities, value: entities }); + drawingDirty = false; } export function undo() { @@ -530,6 +555,7 @@ export function undo() { if (!undoState) return; updateStates(undoState); + drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } @@ -538,6 +564,7 @@ export function redo() { if (!redoState) return; updateStates(redoState); + drawingDirty = true; window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); }