From 29a17f33ce1afb291fb1f23720cc29b90ef7952d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 17:30:51 +0900 Subject: [PATCH 1/5] 260719_7 --- ui_template/ui_template_theme.css | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 88953c34..df119202 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -171,10 +171,10 @@ --element-gap: 8px; /* Scrollbar */ - --color-scrollbar-track: color-mix(in srgb, var(--color-text) 4%, transparent); - --color-scrollbar-thumb: color-mix(in srgb, var(--color-text) 16%, transparent); - --color-scrollbar-thumb-hover: color-mix(in srgb, var(--color-text) 24%, transparent); - --color-scrollbar-thumb-active: color-mix(in srgb, var(--color-text) 32%, transparent); + --color-scrollbar-track: transparent; + --color-scrollbar-thumb: color-mix(in srgb, var(--color-text) 12%, transparent); + --color-scrollbar-thumb-hover: color-mix(in srgb, var(--color-text) 20%, transparent); + --color-scrollbar-thumb-active: color-mix(in srgb, var(--color-text) 28%, transparent); /* 워크플로우 3단 레이아웃 (frontend.md §2) */ --wf-header-height: 56px; /* 상단: 타이틀 + 진행 단계 */ @@ -287,15 +287,16 @@ @supports selector(::-webkit-scrollbar) { * { scrollbar-color: auto; + scrollbar-width: auto; } *::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 8px; + height: 8px; } *::-webkit-scrollbar-track { - background-color: var(--color-scrollbar-track); + background-color: transparent; } *::-webkit-scrollbar-thumb { @@ -313,6 +314,12 @@ background-color: var(--color-scrollbar-thumb-active); } + *::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + *::-webkit-scrollbar-corner { background-color: transparent; } From ef0dab9bc30ff967c8b704cbfe624122a7e6cb08 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 18:26:00 +0900 Subject: [PATCH 2/5] 260719_8 --- .../B07_wf4_DesignDetail_Api_Fetch.ts | 94 ++ .../B07_wf4_DesignDetail_Router.py | 554 +++++++++++ .../B07_wf4_DesignDetail_Schema.py | 62 ++ .../B07_wf4_DesignDetail_UI_Page.ts | 265 +++++- .../B07_wf4_DesignDetail_UI_Style.css | 118 ++- .../openwebcad/src/App.consts.ts | 5 - B07_wf4_DesignDetail/openwebcad/src/App.css | 405 +++++++++ B07_wf4_DesignDetail/openwebcad/src/App.tsx | 20 +- .../openwebcad/src/App.types.ts | 3 +- .../openwebcad/src/components/Toolbar.tsx | 857 ++++++------------ .../screenCanvas.drawController.ts | 38 +- .../src/inputController/input-controller.ts | 57 +- .../src/integration/aislo-drawing-bridge.ts | 33 +- B07_wf4_DesignDetail/openwebcad/src/main.tsx | 29 +- B07_wf4_DesignDetail/openwebcad/src/state.ts | 26 + main.py | 2 + 16 files changed, 1872 insertions(+), 696 deletions(-) create mode 100644 B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts create mode 100644 B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py create mode 100644 B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts new file mode 100644 index 00000000..6eeff376 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts @@ -0,0 +1,94 @@ +/* B07 상세 설계 도면 목록·단건 API 클라이언트. */ + +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; + +export interface DesignDrawingItem { + id: string; + kind: "longitudinal" | "cross"; + label: string; + chainage_m: number | null; + confirmed: boolean; +} + +export interface CadDrawing { + entities: Record[]; + layers: { + id: string; + name: string; + isVisible: boolean; + isLocked: boolean; + }[]; +} + +export interface DesignDrawingListResponse { + status: string; + project_id: string; + route_id: number; + drawings: DesignDrawingItem[]; +} + +export interface DesignDrawingResponse { + status: string; + project_id: string; + route_id: number; + id: string; + kind: "longitudinal" | "cross"; + label: string; + drawing: CadDrawing; + confirmed: boolean; +} + +export interface DesignDrawingConfirmResponse { + status: string; + project_id: string; + id: string; + confirmed: boolean; + all_confirmed: boolean; +} + +async function requestJson(path: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + }); + const payload = (await response.json()) as T & { message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; + } finally { + window.clearTimeout(timeoutId); + } +} + +export function fetchDesignDrawingList(projectId: string): Promise { + return requestJson(`/projects/${projectId}/design-drawings`); +} + +export function fetchDesignDrawing( + projectId: string, + drawingId: string, +): Promise { + return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); +} + +export function confirmDesignDrawing( + projectId: string, + drawingId: string, + drawing: CadDrawing, +): Promise { + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`, + { method: "PUT", body: JSON.stringify({ drawing }) }, + ); +} + +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, + { method: "POST" }, + ); +} diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py new file mode 100644 index 00000000..71d3cf29 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -0,0 +1,554 @@ +"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터.""" + +import asyncio +import json +import logging +import re +from pathlib import Path +from typing import Any +from uuid import UUID, uuid5 + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + get_confirmed_route_context, + get_longitudinal_section, +) +from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import ( + DesignDrawingConfirmRequest, + DesignDrawingConfirmResponse, + DesignDrawingInvalidateResponse, + DesignDrawingItem, + DesignDrawingListResponse, + DesignDrawingResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import complete_stage, start_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + +_CROSS_ID = re.compile(r"^cross_(\d+)m$") +_GROUND_LAYER_ID = "b07-ground" +_STAGE_DIR = "B07_wf4_DesignDetail" + + +async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: + """확정된 B06 종단 레코드와 프로젝트 저장 경로를 반환한다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + route_context = await get_confirmed_route_context(connection, project_id) + if not route_context: + raise FileNotFoundError("확정된 경로가 없습니다.") + route_id = int(route_context["route_id"]) + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + if not longitudinal or longitudinal.get("status") != "CONFIRMED": + raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.") + stored_path = await get_project_storage_relative_path(connection, project_id) + + root = Path(resolve_stored_project_path(stored_path)).resolve() + longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve() + if root not in longitudinal_path.parents or not longitudinal_path.is_file(): + raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.") + return route_id, root, longitudinal_path + + +def _read_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") + return payload + + +def _cross_files(longitudinal_path: Path) -> list[Path]: + cross_dir = longitudinal_path.parent.parent / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") + return sorted(cross_dir.glob("cross_*.json")) + + +def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: + stations = longitudinal.get("stations", []) + if not isinstance(stations, list): + return {} + return { + round(float(station.get("chainage_m", 0))): station + for station in stations + if isinstance(station, dict) + } + + +def _design_root(project_root: Path) -> Path: + return project_root / _STAGE_DIR + + +def _read_manifest(project_root: Path) -> dict[str, Any]: + path = _design_root(project_root) / "manifest.json" + if not path.is_file(): + return {"drawings": {}} + payload = _read_json(path) + return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} + + +def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: + stage_root = _design_root(project_root) + stage_root.mkdir(parents=True, exist_ok=True) + path = stage_root / "manifest.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) + + +def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]: + longitudinal = _read_json(longitudinal_path) + station_by_chainage = _station_map(longitudinal) + manifest_drawings = _read_manifest(project_root)["drawings"] + drawings = [ + DesignDrawingItem( + id="longitudinal", + kind="longitudinal", + label="종단도 전체", + confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")), + ) + ] + for path in _cross_files(longitudinal_path): + match = _CROSS_ID.fullmatch(path.stem) + if not match: + continue + chainage = int(match.group(1)) + station = station_by_chainage.get(chainage, {}) + drawings.append( + DesignDrawingItem( + id=path.stem, + kind="cross", + label=str(station.get("label") or f"STA.{chainage // 1000}+{chainage % 1000:03d}"), + chainage_m=float(station.get("chainage_m", chainage)), + confirmed=bool(manifest_drawings.get(path.stem, {}).get("confirmed")), + ) + ) + return drawings + + +def _line_entity( + drawing_id: str, + index: int, + start: tuple[float, float], + end: tuple[float, float], + layer_id: str = _GROUND_LAYER_ID, + color: str = "#f5f7fa", +) -> dict[str, Any]: + entity_id = str(uuid5(UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96"), f"{drawing_id}:{index}")) + return { + "id": entity_id, + "type": "Line", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "startPoint": {"x": start[0], "y": start[1]}, + "endPoint": {"x": end[0], "y": end[1]}, + }, + } + + +def _text_entity( + drawing_id: str, + index: int, + label: str, + point: tuple[float, float], +) -> dict[str, Any]: + return { + "id": str( + uuid5( + UUID("8ce96e1d-17e8-457b-b46e-329456701225"), + f"{drawing_id}:{index}", + ) + ), + "type": "Text", + "lineColor": "#cbd5e1", + "lineWidth": 1, + "layerId": "b07-quantity-table", + "shapeData": { + "label": label, + "basePoint": {"x": point[0], "y": point[1]}, + "options": { + "textDirection": {"x": 1, "y": 0}, + "textAlign": "left", + "textColor": "#cbd5e1", + "fontSize": 0.32, + "fontFamily": "Noto Sans KR", + }, + }, + } + + +def _quantity_rows(source: dict[str, Any]) -> list[tuple[str, str, str]]: + ground = source.get("center_z") + planned = source.get("planned_elevation_m", source.get("design_elevation_m")) + cut = ( + max(float(ground) - float(planned), 0.0) + if isinstance(ground, (int, float)) and isinstance(planned, (int, float)) + else None + ) + fill = ( + max(float(planned) - float(ground), 0.0) + if isinstance(ground, (int, float)) and isinstance(planned, (int, float)) + else None + ) + quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} + + def value(number: Any) -> str: + return f"{float(number):.3f}" if isinstance(number, (int, float)) else "-" + + return [ + ("기본", "측점", str(source.get("label", "-"))), + ("기본", "지반고", value(ground)), + ("기본", "계획고", value(planned)), + ("기본", "절토고", value(cut)), + ("기본", "성토고", value(fill)), + ("흙깎기", "토사", value(quantities.get("cut_soil"))), + ("흙깎기", "연암", value(quantities.get("cut_soft_rock"))), + ("흙깎기", "보통암", value(quantities.get("cut_rock"))), + ("옆도랑파기", "토사", value(quantities.get("ditch_soil"))), + ("옆도랑파기", "연암", value(quantities.get("ditch_soft_rock"))), + ("옆도랑파기", "보통암", value(quantities.get("ditch_rock"))), + ("비탈보호공", "성토면", value(quantities.get("fill_slope_protection"))), + ("비탈보호공", "절토면", value(quantities.get("cut_slope_protection"))), + ("기타", "지장목제거", value(quantities.get("tree_removal"))), + ("기타", "흙쌓기", value(quantities.get("embankment"))), + ("기타", "제근", value(quantities.get("grubbing"))), + ("기타", "노면고르기", value(quantities.get("surface_grading"))), + ] + + +def _quantity_table_entities( + source: dict[str, Any], drawing_id: str, points: list[tuple[float, float]] +) -> list[dict[str, Any]]: + if not points: + return [] + rows = [("구분", "항목", "값"), *_quantity_rows(source)] + row_height = 0.75 + column_widths = (3.2, 3.2, 3.0) + left = max(point[0] for point in points) + 2.0 + top = max(point[1] for point in points) + right = left + sum(column_widths) + bottom = top - row_height * len(rows) + entities: list[dict[str, Any]] = [] + for row_index in range(len(rows) + 1): + y = top - row_index * row_height + entities.append( + _line_entity( + f"{drawing_id}:table-h", + row_index, + (left, y), + (right, y), + "b07-quantity-table", + "#64748b", + ) + ) + x_positions = [left] + for width in column_widths: + x_positions.append(x_positions[-1] + width) + for column_index, x in enumerate(x_positions): + entities.append( + _line_entity( + f"{drawing_id}:table-v", + column_index, + (x, top), + (x, bottom), + "b07-quantity-table", + "#64748b", + ) + ) + text_index = 0 + for row_index, row in enumerate(rows): + y = top - row_index * row_height - 0.5 + for column_index, label in enumerate(row): + entities.append( + _text_entity( + drawing_id, + text_index, + label, + (x_positions[column_index] + 0.12, y), + ) + ) + text_index += 1 + return entities + + +def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]: + """B06 샘플을 openwebcad PolyLine 직렬화 형식으로 변환한다.""" + x_key = "chainage_m" if kind == "longitudinal" else "offset_m" + points: list[tuple[float, float]] = [] + for sample in source.get("samples", []): + if not isinstance(sample, dict) or not sample.get("valid", False): + continue + x = sample.get(x_key) + y = sample.get("elevation_m", sample.get("z")) + if isinstance(x, (int, float)) and isinstance(y, (int, float)): + points.append((float(x), float(y))) + children = [ + _line_entity(drawing_id, index, points[index], points[index + 1]) + for index in range(len(points) - 1) + ] + entities: list[dict[str, Any]] = [] + if children: + entities.append( + { + "id": str( + uuid5( + UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8"), + drawing_id, + ) + ), + "type": "PolyLine", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": _GROUND_LAYER_ID, + "shapeData": None, + "children": children, + } + ) + if kind == "cross": + entities.extend(_quantity_table_entities(source, drawing_id, points)) + return { + "entities": entities, + "layers": [ + { + "id": _GROUND_LAYER_ID, + "name": "Existing Ground", + "isVisible": True, + "isLocked": False, + }, + { + "id": "b07-quantity-table", + "name": "Quantity Table", + "isVisible": True, + "isLocked": False, + }, + ], + } + + +def _read_drawing( + project_root: Path, longitudinal_path: Path, drawing_id: str +) -> tuple[str, str, dict[str, Any], bool]: + manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) + saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" + if manifest_entry.get("confirmed") and saved_path.is_file(): + kind = "longitudinal" if drawing_id == "longitudinal" else "cross" + label = str(manifest_entry.get("label") or drawing_id) + return kind, label, _read_json(saved_path), True + if drawing_id == "longitudinal": + source = _read_json(longitudinal_path) + return ( + "longitudinal", + "종단도 전체", + _cad_drawing(source, drawing_id, "longitudinal"), + False, + ) + + if not _CROSS_ID.fullmatch(drawing_id): + raise ValueError("올바르지 않은 도면 ID입니다.") + path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" + if not path.is_file(): + raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") + source = _read_json(path) + label = str(source.get("label") or drawing_id) + return "cross", label, _cad_drawing(source, drawing_id, "cross"), False + + +def _store_confirmed_drawing( + project_root: Path, + item: DesignDrawingItem, + drawing: dict[str, Any], + expected_ids: set[str], +) -> bool: + if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): + raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") + drawings_dir = _design_root(project_root) / "drawings" + drawings_dir.mkdir(parents=True, exist_ok=True) + path = drawings_dir / f"{item.id}.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(drawing, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) + + manifest = _read_manifest(project_root) + manifest["drawings"][item.id] = { + "kind": item.kind, + "label": item.label, + "confirmed": True, + "file": f"drawings/{item.id}.json", + } + _write_manifest(project_root, manifest) + confirmed_ids = { + item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed") + } + return expected_ids.issubset(confirmed_ids) + + +def _invalidate_drawing(project_root: Path, drawing_id: str) -> None: + manifest = _read_manifest(project_root) + entry = manifest["drawings"].get(drawing_id) + if entry: + entry["confirmed"] = False + _write_manifest(project_root, manifest) + + +@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse) +async def get_design_drawing_list( + project_id: UUID, +) -> DesignDrawingListResponse | JSONResponse: + """B07 좌측 패널용 도면 메타데이터만 캐시한다.""" + try: + route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + return DesignDrawingListResponse( + project_id=str(project_id), route_id=route_id, drawings=drawings + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도면 목록 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "상세 설계 도면 목록을 읽지 못했습니다."}, + ) + + +@router.get("/{project_id}/design-drawings/{drawing_id}", response_model=DesignDrawingResponse) +async def get_design_drawing( + project_id: UUID, drawing_id: str +) -> DesignDrawingResponse | JSONResponse: + """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" + try: + route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + kind, label, drawing, confirmed = await asyncio.to_thread( + _read_drawing, project_root, longitudinal_path, drawing_id + ) + return DesignDrawingResponse( + project_id=str(project_id), + route_id=route_id, + id=drawing_id, + kind=kind, + label=label, + drawing=drawing, + confirmed=confirmed, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B07 단건 도면 조회 실패: project_id=%s drawing_id=%s", project_id, drawing_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "상세 설계 도면을 읽지 못했습니다."}, + ) + + +@router.put( + "/{project_id}/design-drawings/{drawing_id}/confirm", + response_model=DesignDrawingConfirmResponse, +) +async def confirm_design_drawing( + project_id: UUID, drawing_id: str, request: DesignDrawingConfirmRequest +) -> DesignDrawingConfirmResponse | JSONResponse: + """현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.""" + try: + _, project_root, longitudinal_path = await _confirmed_source(project_id) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + item = next((candidate for candidate in items if candidate.id == drawing_id), None) + if not item: + raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.") + all_confirmed = await asyncio.to_thread( + _store_confirmed_drawing, + project_root, + item, + request.drawing, + {candidate.id for candidate in items}, + ) + + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as cursor: + if all_confirmed: + await complete_stage(cursor, str(project_id), 4) + else: + await start_stage(cursor, str(project_id), 4) + await connection.commit() + except Exception: + await connection.rollback() + raise + return DesignDrawingConfirmResponse( + project_id=str(project_id), + id=drawing_id, + confirmed=True, + all_confirmed=all_confirmed, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도면 확정 실패: project_id=%s drawing_id=%s", project_id, drawing_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "상세 설계 도면을 확정하지 못했습니다."}, + ) + + +@router.post( + "/{project_id}/design-drawings/{drawing_id}/invalidate", + response_model=DesignDrawingInvalidateResponse, +) +async def invalidate_design_drawing( + project_id: UUID, drawing_id: str +) -> DesignDrawingInvalidateResponse | JSONResponse: + """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" + try: + _, project_root, longitudinal_path = await _confirmed_source(project_id) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + if drawing_id not in {item.id for item in items}: + raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") + await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) + + pool = get_db_pool() + async with pool.acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as cursor: + await start_stage(cursor, str(project_id), 4) + await connection.commit() + except Exception: + await connection.rollback() + raise + return DesignDrawingInvalidateResponse( + project_id=str(project_id), + id=drawing_id, + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B07 도면 확정 해제 실패: project_id=%s drawing_id=%s", project_id, drawing_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, + ) diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py new file mode 100644 index 00000000..67fcb6d3 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py @@ -0,0 +1,62 @@ +"""B07 상세 설계 도면 목록·단건 응답 모델.""" + +from typing import Any, Literal + +from pydantic import BaseModel + + +class DesignDrawingItem(BaseModel): + """B06 확정 산출물에서 노출하는 도면 메타데이터.""" + + id: str + kind: Literal["longitudinal", "cross"] + label: str + chainage_m: float | None = None + confirmed: bool = False + + +class DesignDrawingListResponse(BaseModel): + """B07 진입 시 캐시할 경량 도면 목록.""" + + status: str = "success" + project_id: str + route_id: int + drawings: list[DesignDrawingItem] + + +class DesignDrawingResponse(BaseModel): + """openwebcad 브리지로 전달할 단일 CAD 도면.""" + + status: str = "success" + project_id: str + route_id: int + id: str + kind: Literal["longitudinal", "cross"] + label: str + drawing: dict[str, Any] + confirmed: bool = False + + +class DesignDrawingConfirmRequest(BaseModel): + """CAD 앱에서 직렬화한 현재 편집 도면.""" + + drawing: dict[str, Any] + + +class DesignDrawingConfirmResponse(BaseModel): + """도면별 확정 및 B07 전체 완료 상태.""" + + status: str = "success" + project_id: str + id: str + confirmed: bool + all_confirmed: bool + + +class DesignDrawingInvalidateResponse(BaseModel): + """확정 도면 변경에 따른 상태 롤백 결과.""" + + status: str = "success" + project_id: str + id: str + confirmed: bool = False diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index c8dd9ec1..67215bab 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -12,6 +12,12 @@ import "./B07_wf4_DesignDetail_UI_Style.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -21,6 +27,14 @@ import { WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; +import { + confirmDesignDrawing, + fetchDesignDrawing, + fetchDesignDrawingList, + invalidateDesignDrawing, + type CadDrawing, + type DesignDrawingItem, +} from "./B07_wf4_DesignDetail_Api_Fetch"; /** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ const B07_CAD_APP_URL = "/b07-cad/index.html"; @@ -30,17 +44,70 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 빈 사이드 패널 (전달 데이터 확정 후 구성 예정) */ -function buildEmptySidePanel(): HTMLDivElement { +const CAD_LOAD_MESSAGE = "aislo:b07:load-drawing"; +const CAD_READY_MESSAGE = "aislo:b07:drawing-ready"; +const CAD_LOADED_MESSAGE = "aislo:b07:drawing-loaded"; +const CAD_ERROR_MESSAGE = "aislo:b07:drawing-error"; +const CAD_CHANGED_MESSAGE = "aislo:b07:drawing-changed"; +const CAD_SAVE_REQUEST_MESSAGE = "aislo:b07:save-request"; +const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b07:save-response"; + +/** B06 확정 산출물 기반 도면 목록 패널. */ +function buildDrawingSidePanel( + drawings: DesignDrawingItem[], + onSelect: (drawing: DesignDrawingItem, button: HTMLButtonElement) => Promise, + errorMessage?: string, +): HTMLDivElement { const panel = document.createElement("div"); - panel.className = "b07-side-empty"; - const icon = document.createElement("div"); - icon.className = "b07-side-empty__icon"; - icon.textContent = "🏗️"; - const msg = document.createElement("p"); - msg.className = "b07-side-empty__msg"; - msg.textContent = L("B07_Cad_Side_Pending"); - panel.append(icon, msg); + panel.className = "b07-drawing-list"; + const heading = document.createElement("div"); + heading.className = "b07-drawing-list__heading"; + const title = document.createElement("strong"); + title.textContent = "설계 도면"; + const count = document.createElement("span"); + count.textContent = `${drawings.length}건`; + heading.append(title, count); + panel.append(heading); + + if (errorMessage || drawings.length === 0) { + const empty = document.createElement("p"); + empty.className = "b07-drawing-list__empty"; + empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; + panel.append(empty); + return panel; + } + + const groups: [string, DesignDrawingItem[]][] = [ + ["종단도", drawings.filter((item) => item.kind === "longitudinal")], + ["횡단도", drawings.filter((item) => item.kind === "cross")], + ]; + for (const [label, items] of groups) { + if (!items.length) continue; + const section = document.createElement("section"); + section.className = "b07-drawing-group"; + const sectionTitle = document.createElement("h3"); + sectionTitle.textContent = `${label} ${items.length}`; + section.append(sectionTitle); + for (const drawing of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b07-drawing-button"; + button.dataset.drawingId = drawing.id; + button.dataset.confirmed = String(drawing.confirmed); + const name = document.createElement("span"); + name.textContent = drawing.label; + const kind = document.createElement("small"); + kind.textContent = drawing.confirmed + ? "확정" + : drawing.kind === "longitudinal" + ? "PROFILE" + : "SECTION"; + button.append(name, kind); + button.addEventListener("click", () => void onSelect(drawing, button)); + section.append(button); + } + panel.append(section); + } return panel; } @@ -50,12 +117,20 @@ function buildEmptySidePanel(): HTMLDivElement { export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let workflowState: WorkflowState | undefined; + let drawings: DesignDrawingItem[] = []; + let drawingError: string | undefined; if (projectId) { - try { - workflowState = await fetchWorkflowState(projectId); - } catch { - /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ - } + const [workflowResult, drawingResult] = await Promise.allSettled([ + fetchWorkflowState(projectId), + fetchDesignDrawingList(projectId), + ]); + if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; + if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings; + else + drawingError = + drawingResult.reason instanceof Error + ? drawingResult.reason.message + : "도면 목록을 불러오지 못했습니다."; } const cadHost = document.createElement("div"); @@ -64,19 +139,173 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { frame.className = "b07-cad-frame"; frame.src = B07_CAD_APP_URL; frame.title = L("B07_Design_Title"); - cadHost.append(frame); + 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 pendingDrawing: CadDrawing | undefined; + let currentDrawing: DesignDrawingItem | undefined; + let currentButton: HTMLButtonElement | undefined; + let currentConfirmed = false; + let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed); + let resolveSave: ((drawing: CadDrawing) => void) | undefined; + const confirmButton = createButton({ + label: "현재 도면 확정", + variant: "filled", + onClick: () => void confirmCurrentDrawing(), + }); + confirmButton.disabled = true; + const sendDrawing = (drawing: CadDrawing) => { + pendingDrawing = drawing; + if (!cadReady) return; + frame.contentWindow?.postMessage({ type: CAD_LOAD_MESSAGE, drawing }, window.location.origin); + pendingDrawing = undefined; + }; + const selectDrawing = async (drawing: DesignDrawingItem, button: HTMLButtonElement) => { + if (!projectId) return; + const buttons = button + .closest(".b07-drawing-list") + ?.querySelectorAll(".b07-drawing-button"); + buttons?.forEach((item) => { + item.disabled = true; + item.dataset.active = String(item === button); + }); + button.dataset.loading = "true"; + cadHost.dataset.loading = "true"; + try { + const response = await fetchDesignDrawing(projectId, drawing.id); + currentDrawing = drawing; + currentButton = button; + currentConfirmed = response.confirmed; + confirmButton.disabled = response.confirmed; + sendDrawing(response.drawing); + } catch (error) { + cadHost.dataset.loading = "false"; + cadHost.dataset.error = + error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; + } finally { + button.dataset.loading = "false"; + buttons?.forEach((item) => { + item.disabled = false; + }); + } + }; + + 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); + }); + + async function confirmCurrentDrawing(): Promise { + if (!projectId || !currentDrawing) return; + showLoadingOverlay(); + try { + const drawing = await requestCadDrawing(); + const result = await confirmDesignDrawing(projectId, currentDrawing.id, drawing); + currentConfirmed = true; + currentDrawing.confirmed = true; + confirmButton.disabled = true; + if (currentButton) { + currentButton.dataset.confirmed = "true"; + const status = currentButton.querySelector("small"); + if (status) status.textContent = "확정"; + } + showToast("현재 도면을 확정하고 저장했습니다.", "success"); + if (result.all_confirmed) { + allDrawingsConfirmed = true; + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]); + } + } catch (error) { + showToast( + error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.", + "error", + ); + } finally { + hideLoadingOverlay(); + } + } + + const invalidateCurrentDrawing = async () => { + if (!projectId || !currentDrawing) return; + const wasConfirmed = currentConfirmed; + currentConfirmed = false; + allDrawingsConfirmed = false; + currentDrawing.confirmed = false; + confirmButton.disabled = false; + if (currentButton) { + currentButton.dataset.confirmed = "false"; + const status = currentButton.querySelector("small"); + if (status) + status.textContent = currentDrawing.kind === "longitudinal" ? "PROFILE" : "SECTION"; + } + if (wasConfirmed) { + try { + await invalidateDesignDrawing(projectId, currentDrawing.id); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.", + "error", + ); + } + } + }; + + 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; + }; + if (message.type === CAD_READY_MESSAGE) { + cadReady = true; + if (pendingDrawing) sendDrawing(pendingDrawing); + } else if (message.type === CAD_LOADED_MESSAGE) { + cadHost.dataset.loading = "false"; + } else if (message.type === CAD_ERROR_MESSAGE) { + cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다."; + } else if (message.type === CAD_CHANGED_MESSAGE) { + void invalidateCurrentDrawing(); + } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { + const resolve = resolveSave; + resolveSave = undefined; + resolve(message.drawing); + } + }); + + const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); + const confirmActions = document.createElement("div"); + confirmActions.className = "b07-drawing-actions"; + confirmActions.append(confirmButton); + drawingPanel.append(confirmActions); const layout = createWorkflowLayout({ title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, - leftPanel: buildEmptySidePanel(), + leftPanel: drawingPanel, mainContent: cadHost, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (!projectId) return; + if (stepIndex > 4 && !allDrawingsConfirmed) { + showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); + return; + } + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); layout.root.classList.add("b07-design-layout"); diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css index 51269061..4fc8c1bf 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css @@ -16,31 +16,97 @@ min-height: 0; } -/* 빈 사이드 패널 (B06 전달 데이터 확정 후 구성 예정) */ -.b07-side-empty { +/* B06 확정 산출물 도면 목록 */ +.b07-drawing-list { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--spacing-16); + gap: var(--spacing-12); height: 100%; - min-height: 200px; - padding: var(--spacing-24); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); + min-height: 0; + overflow-y: auto; +} + +.b07-drawing-list__heading { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.b07-drawing-list__heading span, +.b07-drawing-list__empty { color: var(--color-text-muted); - text-align: center; -} - -.b07-side-empty__icon { - font-size: 32px; - line-height: 1; -} - -.b07-side-empty__msg { font-size: var(--text-body-sm); } +.b07-drawing-group { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.b07-drawing-group h3 { + margin: var(--spacing-8) 0 var(--spacing-4); + color: var(--color-text-muted); + font-size: var(--text-caption); + font-weight: 600; +} + +.b07-drawing-button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 38px; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid transparent; + border-radius: var(--radius-buttons); + background: transparent; + color: var(--color-text); + cursor: pointer; + text-align: left; +} + +.b07-drawing-button:hover, +.b07-drawing-button[data-active="true"] { + border-color: var(--color-border); + background: var(--color-mist-violet); +} + +.b07-drawing-button[data-active="true"] { + color: var(--color-primary); +} + +.b07-drawing-button small { + color: var(--color-text-muted); + font-size: 9px; +} + +.b07-drawing-button[data-loading="true"] small { + visibility: hidden; +} + +.b07-drawing-button[data-confirmed="true"] { + border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border)); +} + +.b07-drawing-button[data-confirmed="true"] small { + color: var(--color-success); +} + +.b07-drawing-actions { + position: sticky; + bottom: 0; + margin-top: auto; + padding-top: var(--spacing-12); + background: var(--color-surface-raised); +} + +.b07-drawing-actions > button { + width: 100%; +} + /* CAD 뷰어 호스트 (상세 페이지 영역) */ .b07-cad-host { position: relative; @@ -60,3 +126,19 @@ 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; +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts index 259a0102..0ffd4cdb 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts @@ -1,8 +1,3 @@ -/** - * Width of the toolbar containing all the tools on the left side of the screen - */ -export const TOOLBAR_WIDTH = 320; - /** * Very small number that will be used to compare floating point numbers on equality * since javascript isn't always very accurate with floating point numbers diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.css b/B07_wf4_DesignDetail/openwebcad/src/App.css index f1d8c73c..2ffbc824 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.css +++ b/B07_wf4_DesignDetail/openwebcad/src/App.css @@ -1 +1,406 @@ @import "tailwindcss"; + +:root { + --cad-title-height: 42px; + --cad-ribbon-height: 82px; + --cad-command-height: 58px; + --cad-status-height: 28px; + --cad-panel-width: 248px; + font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif; + color: #dce6f2; + background: #11161d; +} + +* { + box-sizing: border-box; +} +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} +button, +input { + font: inherit; +} +button { + color: inherit; +} + +.cad-app { + position: fixed; + inset: 0; + z-index: 2; + pointer-events: none; +} +.controls { + pointer-events: auto; +} + +body > canvas[data-id="canvas"] { + position: fixed; + z-index: 1; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height)); + right: 0; + bottom: calc(var(--cad-command-height) + var(--cad-status-height)); + left: var(--cad-panel-width); + width: calc(100vw - var(--cad-panel-width)); + height: calc( + 100vh - + var(--cad-title-height) - + var(--cad-ribbon-height) - + var(--cad-command-height) - + var(--cad-status-height) + ); + background: #111; + cursor: none; +} + +.cad-titlebar { + position: fixed; + inset: 0 0 auto 0; + height: var(--cad-title-height); + display: flex; + align-items: center; + gap: 24px; + padding: 0 12px; + background: #19222d; + border-bottom: 1px solid #344252; + box-shadow: 0 1px 4px #0008; +} +.cad-brand { + display: flex; + align-items: baseline; + gap: 9px; + min-width: 220px; +} +.cad-brand strong { + color: #f7fbff; + font-size: 14px; +} +.cad-brand span, +.cad-file-state { + color: #91a2b5; + font-size: 11px; +} +.cad-file-state { + display: flex; + align-items: center; + gap: 7px; +} +.cad-file-state__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #49b675; + box-shadow: 0 0 0 2px #49b67522; +} +.cad-title-actions { + display: flex; + gap: 4px; + margin-left: auto; +} +.cad-title-actions button { + height: 28px; + min-width: 32px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + font-size: 12px; +} +.cad-title-actions button:hover { + border-color: #4d6074; + background: #273544; +} + +.cad-ribbon { + position: fixed; + top: var(--cad-title-height); + right: 0; + left: 0; + height: var(--cad-ribbon-height); + display: flex; + align-items: stretch; + padding: 5px 8px 3px; + overflow-x: auto; + background: #202b37; + border-bottom: 1px solid #3c4a59; +} +.cad-ribbon-group { + display: flex; + flex-direction: column; + min-width: max-content; + padding: 0 9px; + border-right: 1px solid #40505f; +} +.cad-ribbon-tools { + display: flex; + gap: 2px; + height: 57px; +} +.cad-ribbon-group__label { + margin-top: auto; + color: #8293a5; + font-size: 10px; + text-align: center; +} +.cad-tool { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 48px; + padding: 3px 6px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + font-size: 10px; +} +.cad-tool:hover:not(:disabled), +.cad-tool[data-active="true"] { + border-color: #4f87b8; + background: #2b455c; +} +.cad-tool[data-active="true"] { + box-shadow: inset 0 -2px #4ca6e8; +} +.cad-tool:disabled { + color: #657382; + cursor: not-allowed; +} +.cad-tool__glyph { + height: 27px; + color: #a9d7fb; + font-size: 22px; + line-height: 27px; +} +.cad-tool:disabled .cad-tool__glyph { + color: #657382; +} + +.cad-inspector { + position: fixed; + z-index: 3; + top: calc(var(--cad-title-height) + var(--cad-ribbon-height)); + bottom: calc(var(--cad-command-height) + var(--cad-status-height)); + left: 0; + width: var(--cad-panel-width); + background: #1b2530; + border-right: 1px solid #3d4c5c; + transition: width 120ms ease; +} +.cad-inspector[data-collapsed="true"] { + width: 0; +} +.cad-inspector__collapse { + position: absolute; + z-index: 4; + top: 10px; + left: 100%; + width: 20px; + height: 38px; + border: 1px solid #465769; + border-left: 0; + border-radius: 0 4px 4px 0; + background: #263442; + color: #a9bbcd; +} +.cad-inspector-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + height: 38px; + border-bottom: 1px solid #394857; +} +.cad-inspector-tabs button { + border: 0; + border-bottom: 2px solid transparent; + background: #17202a; + color: #94a7ba; + font-size: 12px; +} +.cad-inspector-tabs button[data-active="true"] { + border-bottom-color: #4da3df; + background: #223141; + color: white; +} +.cad-properties { + padding: 14px 12px; +} +.cad-properties h2 { + margin: 0 0 14px; + color: #f3f7fb; + font-size: 13px; + font-weight: 600; +} +.cad-properties dl { + margin: 0; + border-top: 1px solid #344352; +} +.cad-properties dl div { + display: grid; + grid-template-columns: 92px 1fr; + padding: 8px 0; + border-bottom: 1px solid #2d3a47; + font-size: 11px; +} +.cad-properties dt { + color: #8092a4; +} +.cad-properties dd { + margin: 0; + overflow: hidden; + color: #d7e1eb; + text-overflow: ellipsis; +} +.cad-layer-manager { + max-height: calc(100vh - 250px); + padding: 9px; + overflow-y: auto; +} +.cad-layer-manager button { + min-height: 36px; + padding-top: 6px; + padding-bottom: 6px; + background: #202c38; +} + +.cad-view-controls { + position: fixed; + z-index: 3; + right: 14px; + bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px); + display: flex; + align-items: center; + overflow: hidden; + border: 1px solid #465565; + border-radius: 4px; + background: #202b37e8; + box-shadow: 0 3px 12px #0008; +} +.cad-view-controls button { + width: 34px; + height: 32px; + border: 0; + border-right: 1px solid #3d4c5b; + background: transparent; + font-size: 17px; +} +.cad-view-controls button:hover { + background: #31516b; +} +.cad-view-controls span { + min-width: 48px; + color: #9fb0c1; + font-size: 10px; + text-align: center; +} + +.cad-command-area { + position: fixed; + z-index: 3; + right: 0; + bottom: var(--cad-status-height); + left: var(--cad-panel-width); + height: var(--cad-command-height); + padding: 5px 10px; + background: #151c24f2; + border-top: 1px solid #3b4b5a; +} +.cad-command-prompt { + display: flex; + gap: 12px; + height: 18px; + overflow: hidden; + font-size: 10px; + white-space: nowrap; +} +.cad-command-prompt span { + color: #6eaee0; +} +.cad-command-prompt strong { + overflow: hidden; + color: #aab8c6; + font-weight: 400; + text-overflow: ellipsis; +} +.cad-command-area form { + display: flex; + align-items: center; + gap: 7px; + height: 28px; +} +.cad-command-area label { + color: #e6eef6; + font-size: 11px; + font-weight: 600; +} +.cad-command-area input { + flex: 1; + height: 26px; + padding: 0 8px; + border: 1px solid #46586a; + border-radius: 2px; + outline: none; + background: #0e141a; + color: #eef5fc; + font: 11px Consolas, monospace; +} +.cad-command-area input:focus { + border-color: #4b9bd3; + box-shadow: 0 0 0 1px #4b9bd344; +} + +.cad-statusbar { + position: fixed; + z-index: 3; + right: 0; + bottom: 0; + left: 0; + height: var(--cad-status-height); + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + background: #263544; + border-top: 1px solid #415263; +} +.cad-statusbar button { + height: 21px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: #9caebf; + font-size: 10px; +} +.cad-statusbar button:hover { + background: #33485b; +} +.cad-statusbar button[data-active="true"] { + border-color: #4f9bd0; + background: #285579; + color: white; +} +.cad-statusbar__hint { + margin-left: auto; + color: #7f91a2; + font-size: 10px; +} + +@media (max-width: 800px) { + :root { + --cad-panel-width: 200px; + } + .cad-file-state, + .cad-statusbar__hint { + display: none; + } + .cad-brand { + min-width: auto; + } +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.tsx b/B07_wf4_DesignDetail/openwebcad/src/App.tsx index ed580f19..0d3f6865 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/App.tsx @@ -4,26 +4,8 @@ import { Toolbar } from './components/Toolbar.tsx'; function App() { return ( -
-
- Aislo 2D Drawing - B07 상세 설계 -
+
-
); diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.types.ts b/B07_wf4_DesignDetail/openwebcad/src/App.types.ts index ba9ea46c..a10a9c81 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/App.types.ts @@ -1,4 +1,4 @@ -import type {Arc, Circle, Point, Polygon, Segment} from '@flatten-js/core'; +import type { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core'; export type Shape = Polygon | Segment | Point | Circle | Arc; @@ -35,6 +35,7 @@ export enum MouseButton { export enum HtmlEvent { UPDATE_STATE = 'UPDATE_STATE', + DRAWING_CHANGED = 'DRAWING_CHANGED', } export interface StateMetaData { diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx index 174172d1..5f18a42a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -1,624 +1,321 @@ -import { type FC, type MouseEvent, useCallback, useEffect, useState } from 'react'; +import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { Actor } from 'xstate'; -import { COLOR_LIST } from '../App.consts'; import { HtmlEvent, type Layer } from '../App.types'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; -import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage.ts'; -import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; -import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; -import { importEntitiesFromJsonFile } from '../helpers/import-export-handlers/import-entities-from-json'; -import { importEntitiesFromSvgFile } from '../helpers/import-export-handlers/import-entities-from-svg.ts'; -import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file'; -import { times } from '../helpers/times'; +import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; import { getActiveLayerId, - getActiveLineColor, - getActiveLineWidth, getActiveToolActor, getAngleStep, + getGridEnabled, + getInputController, + getLastStateInstructions, getLayers, getScreenCanvasDrawController, + getSelectedEntities, + getSnapEnabled, redo, setActiveLayerId, - setActiveLineColor, - setActiveLineWidth, setActiveToolActor, setAngleStep, - setEntities, + setGridEnabled, setLayers, + setSnapEnabled, undo, } from '../state'; import { Tool } from '../tools'; -import { imageImportToolStateMachine } from '../tools/image-import-tool'; import { TOOL_STATE_MACHINES } from '../tools/tool.consts'; -import { ActorEvent } from '../tools/tool.types'; -import { Button } from './Button.tsx'; -import { DropdownButton } from './DropdownButton.tsx'; -import { Icon, IconName } from './Icon/Icon.tsx'; -import { LayerManager } from './LayerManager.tsx'; +import { LayerManager } from './LayerManager'; + +interface RibbonTool { + label: string; + shortcut?: string; + tool?: Tool; + glyph: string; + disabled?: boolean; +} + +const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [ + { + label: '그리기', + tools: [ + { label: '선', shortcut: 'L', tool: Tool.LINE, glyph: '╱' }, + { label: '폴리선', shortcut: 'PE', tool: Tool.PEDIT, glyph: '⌁' }, + { label: '원', shortcut: 'C', tool: Tool.CIRCLE, glyph: '○' }, + { label: '사각형', shortcut: 'R', tool: Tool.RECTANGLE, glyph: '□' }, + { label: '호', glyph: '◜', disabled: true }, + ], + }, + { + label: '수정', + tools: [ + { label: '선택', shortcut: 'S', tool: Tool.SELECT, glyph: '↖' }, + { label: '이동', tool: Tool.MOVE, glyph: '✥' }, + { label: '복사', tool: Tool.COPY, glyph: '▣' }, + { label: '회전', tool: Tool.ROTATE, glyph: '↻' }, + { label: '자르기', tool: Tool.ERASER, glyph: '⌫' }, + { label: '간격띄우기', glyph: '⇶', disabled: true }, + ], + }, + { + label: '주석', + tools: [ + { label: '치수', tool: Tool.MEASUREMENT, glyph: '↔' }, + { label: '문자', glyph: 'A', disabled: true }, + ], + }, +]; + +const COMMANDS = Object.values(Tool); export const Toolbar: FC = () => { - const [activeToolLocal, setActiveToolLocal] = useState(Tool.LINE); - const [angleStepLocal, setAngleStepLocal] = useState(45); - const [activeLineColorLocal, setActiveLineColorLocal] = useState('#FFF'); - const [activeLineWidthLocal, setActiveLineWidthLocal] = useState(1); - const [screenZoomLocal, setScreenZoomLocal] = useState(1); - const [layersLocal, setLayersLocal] = useState(getLayers()); - const [activeLayerIdLocal, setActiveLayerIdLocal] = useState(getLayers()[0].id); + const [activeTool, setActiveTool] = useState(Tool.LINE); + const [zoom, setZoom] = useState(1); + const [layers, setLayersLocal] = useState(getLayers()); + const [activeLayerId, setActiveLayerIdLocal] = useState(getActiveLayerId()); + const [selectedCount, setSelectedCount] = useState(0); + const [selectedType, setSelectedType] = useState('선택 없음'); + const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.'); + const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('properties'); + const [panelCollapsed, setPanelCollapsed] = useState(false); + const [snap, setSnap] = useState(getSnapEnabled()); + const [grid, setGrid] = useState(getGridEnabled()); + const [ortho, setOrtho] = useState(getAngleStep() === 90); + const [command, setCommand] = useState(''); + const [commandLog, setCommandLog] = useState('준비'); - const fetchStateUpdatesFromOutside = useCallback(() => { - setActiveToolLocal(getActiveToolActor()?.getSnapshot()?.context.type); - setAngleStepLocal(getAngleStep()); - setActiveLineColorLocal(getActiveLineColor()); - setActiveLineWidthLocal(getActiveLineWidth()); - setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale()); - setLayersLocal(getLayers()); + const refresh = useCallback(() => { + setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE); + setZoom(getScreenCanvasDrawController().getScreenScale()); + setLayersLocal([...getLayers()]); setActiveLayerIdLocal(getActiveLayerId()); - }, []); - - const handleWheel = useCallback((event: WheelEvent) => { - if (event.ctrlKey) { - event.preventDefault(); - } + const selected = getSelectedEntities(); + setSelectedCount(selected.length); + setSelectedType( + selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음' + ); + setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.'); + setSnap(getSnapEnabled()); + setGrid(getGridEnabled()); + setOrtho(getAngleStep() === 90); }, []); useEffect(() => { - window.addEventListener('wheel', handleWheel, { passive: false }); - window.addEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside); + window.addEventListener(HtmlEvent.UPDATE_STATE, refresh); + return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh); + }, [refresh]); - return () => { - window.removeEventListener('wheel', handleWheel); - window.removeEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside); - }; - }, [fetchStateUpdatesFromOutside, handleWheel]); + useEffect(() => { + document.documentElement.style.setProperty( + '--cad-panel-width', + panelCollapsed ? '0px' : '248px' + ); + window.dispatchEvent(new Event('resize')); + }, [panelCollapsed]); - const handleToolClick = useCallback((tool: Tool) => { - getActiveToolActor()?.stop(); - - const newToolActor = new Actor(TOOL_STATE_MACHINES[tool]); - setActiveToolActor(newToolActor, false); - setActiveToolLocal(tool); + const activateTool = useCallback((tool: Tool) => { + const actor = new Actor(TOOL_STATE_MACHINES[tool]); + setActiveToolActor(actor); + setActiveTool(tool); + setCommandLog(`${tool} 명령 실행`); }, []); - const handleAngleChanged = useCallback((angle: number) => { - setAngleStepLocal(angle); - setAngleStep(angle, false); - }, []); - - const noopClickHandler = (evt: MouseEvent) => { - evt.stopPropagation(); + const handleCommand = (event: FormEvent) => { + event.preventDefault(); + const value = command.trim(); + if (!value) return; + getInputController().submitText(value); + setCommandLog(`명령: ${value.toUpperCase()}`); + setCommand(''); }; - const handleSetLayers = (newLayers: Layer[]) => { - setLayersLocal(newLayers); - setLayers(newLayers); - }; - - const handleSetActiveLayerId = (newActiveLayerId: string) => { - setActiveLayerIdLocal(newActiveLayerId); - setActiveLayerId(newActiveLayerId); + const changeZoom = (factor: number) => { + const controller = getScreenCanvasDrawController(); + controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor)); + setZoom(controller.getScreenScale()); }; return ( -
- - + + + +
+ + +
} - label="Snap angles" - dataId="angle-guide-button" - > - {[5, 15, 30, 45, 90].map((angle: number) => ( - + {!panelCollapsed && ( + <> +
+ + +
+ {panelTab === 'properties' ? ( +
+

{selectedType}

+
+
+
선택 객체
+
{selectedCount}
+
+
+
현재 도구
+
{activeTool}
+
+
+
현재 도면층
+
{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}
+
+
+
+ ) : ( + { + setLayersLocal(next); + setLayers(next); + }} + setActiveLayerId={(id) => { + setActiveLayerIdLocal(id); + setActiveLayerId(id); + }} /> - } - style={{ width: 'calc(50% - 2px)' }} - onClick={(evt) => { - evt.stopPropagation(); - handleAngleChanged(angle); - }} - active={angle === angleStepLocal} - /> - ))} - - {screenZoomLocal.toFixed(1)}} - label="Zoom level" - dataId="zoom-level-button" - > - {[20, 50, 75, 100, 150, 200, 400].map((zoom: number) => ( - - - - + ⌂ + + + {Math.round(zoom * 100)}% + + - - + + + 휠: 줌 · 휠 드래그: 팬 · Esc: 취소 + + ); }; diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2d1d9291..e838d660 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -1,12 +1,17 @@ -import {Point, type Vector} from '@flatten-js/core'; -import {CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER} from '../App.consts'; -import {containRectangle} from '../helpers/contain-rect.ts'; -import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts'; -import {getBoundingBoxOfMultipleEntities} from '../helpers/get-bounding-box-of-multiple-entities.ts'; -import {mapNumberRange} from '../helpers/map-number-range.ts'; -import {StateVariable} from '../helpers/undo-stack.ts'; -import {getEntities, getScreenCanvasDrawController, triggerReactUpdate} from '../state.ts'; -import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController'; +import { Point, type Vector } from '@flatten-js/core'; +import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts'; +import { containRectangle } from '../helpers/contain-rect.ts'; +import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; +import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { mapNumberRange } from '../helpers/map-number-range.ts'; +import { StateVariable } from '../helpers/undo-stack.ts'; +import { + getEntities, + getGridEnabled, + getScreenCanvasDrawController, + triggerReactUpdate, +} from '../state.ts'; +import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; /** * Screen coordinate system: @@ -229,6 +234,21 @@ export class ScreenCanvasDrawController implements DrawController { this.context.fillStyle = CANVAS_BACKGROUND_COLOR; this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); + if (getGridEnabled()) { + this.context.strokeStyle = '#242b35'; + this.context.lineWidth = 1; + this.context.setLineDash([]); + this.context.beginPath(); + for (let x = 0.5; x < this.canvasSize.x; x += 24) { + this.context.moveTo(x, 0); + this.context.lineTo(x, this.canvasSize.y); + } + for (let y = 0.5; y < this.canvasSize.y; y += 24) { + this.context.moveTo(0, y); + this.context.lineTo(this.canvasSize.x, y); + } + this.context.stroke(); + } } /** diff --git a/B07_wf4_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_wf4_DesignDetail/openwebcad/src/inputController/input-controller.ts index 127eac09..f27ff62d 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -1,6 +1,6 @@ -import {Point} from '@flatten-js/core'; -import {compact, round} from 'es-toolkit'; -import {Actor} from 'xstate'; +import { Point } from '@flatten-js/core'; +import { compact, round } from 'es-toolkit'; +import { Actor } from 'xstate'; import { CANVAS_INPUT_FIELD_BACKGROUND_COLOR, CANVAS_INPUT_FIELD_HEIGHT, @@ -10,19 +10,19 @@ import { CANVAS_INPUT_FIELD_WIDTH, HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE, - TOOLBAR_WIDTH, } from '../App.consts.ts'; -import {MouseButton} from '../App.types.ts'; -import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController.ts'; -import {calculateAngleGuidesAndSnapPoints} from '../helpers/calculate-angle-guides-and-snap-points.ts'; -import {findClosestEntity} from '../helpers/find-closest-entity.ts'; -import {getClosestSnapPointWithinRadius} from '../helpers/get-closest-snap-point.ts'; +import { MouseButton } from '../App.types.ts'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; +import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts'; +import { findClosestEntity } from '../helpers/find-closest-entity.ts'; +import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts'; import { getActiveToolActor, getCanvas, getEntities, getLastStateInstructions, getPanStartLocation, + getSnapEnabled, getScreenCanvasDrawController, getSelectedEntities, getSnapPoint, @@ -36,8 +36,8 @@ import { setShouldDrawCursor, undo, } from '../state.ts'; -import {Tool} from '../tools.ts'; -import {TOOL_STATE_MACHINES} from '../tools/tool.consts.ts'; +import { Tool } from '../tools.ts'; +import { TOOL_STATE_MACHINES } from '../tools/tool.consts.ts'; import { type AbsolutePointInputEvent, ActorEvent, @@ -120,6 +120,11 @@ export class InputController { this.drawListBelowInputField(drawController, texts); } + public submitText(value: string) { + this.text = value.trim().toUpperCase(); + this.handleEnterKey(); + } + public handleMouseUp(evt: MouseEvent) { if (evt.button === MouseButton.Right) { // Right click => confirm action (ENTER) @@ -146,10 +151,7 @@ export class InputController { ); const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld( - new Point( - evt.clientX - TOOLBAR_WIDTH, - getScreenCanvasDrawController().getCanvasSize().y - evt.clientY - ) + this.getCanvasPoint(evt) ); const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; @@ -171,10 +173,7 @@ export class InputController { public handleMouseMove(evt: MouseEvent) { setShouldDrawCursor(true); const screenCanvasDrawController = getScreenCanvasDrawController(); - const newScreenMouseLocation = new Point( - evt.clientX - TOOLBAR_WIDTH, - getScreenCanvasDrawController().getCanvasSize().y - evt.clientY - ); + const newScreenMouseLocation = this.getCanvasPoint(evt); screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation); // If the middle mouse button is pressed, pan the screen @@ -188,7 +187,9 @@ export class InputController { } // Calculate angle guides and snap points - calculateAngleGuidesAndSnapPoints(); + if (getSnapEnabled()) { + calculateAngleGuidesAndSnapPoints(); + } // Highlight the entity closest to the mouse when the select tool is active if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) { @@ -223,11 +224,14 @@ export class InputController { public handleMouseDown(evt: MouseEvent) { if (evt.button !== MouseButton.Middle) return; - setPanStartLocation( - new Point( - evt.clientX - TOOLBAR_WIDTH, - getScreenCanvasDrawController().getCanvasSize().y - evt.clientY - ) + setPanStartLocation(this.getCanvasPoint(evt)); + } + + private getCanvasPoint(evt: MouseEvent): Point { + const bounds = getCanvas()?.getBoundingClientRect(); + return new Point( + evt.clientX - (bounds?.left ?? 0), + (bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY ); } @@ -261,6 +265,9 @@ export class InputController { } public handleKeyStroke(evt: KeyboardEvent) { + if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) { + return; + } console.log(`key pressed: ${evt.key}`); if (evt.key === 'F12') { // F12 => open developer tools diff --git a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index c583c37d..465736fd 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,5 +1,7 @@ import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; +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 { HtmlEvent } from '../App.types.ts'; import { getScreenCanvasDrawController, setActiveLayerId, @@ -11,21 +13,28 @@ export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b07:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b07:drawing-ready'; export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b07:drawing-loaded'; export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b07:drawing-error'; +export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b07:drawing-changed'; +export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b07:save-request'; +export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b07:save-response'; interface DrawingLoadMessage { type: typeof AISLO_DRAWING_LOAD_MESSAGE; drawing: JsonDrawingFileSerialized; } +interface DrawingSaveRequestMessage { + type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE; +} + function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage { if (!value || typeof value !== 'object') return false; const candidate = value as Partial; return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing); } -function notifyParent(type: string, detail?: string) { +function notifyParent(type: string, payload: Record = {}) { if (window.parent === window) return; - window.parent.postMessage({ type, detail }, window.location.origin); + window.parent.postMessage({ type, ...payload }, window.location.origin); } /** @@ -35,20 +44,36 @@ function notifyParent(type: string, detail?: string) { export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; + const message = event.data as Partial; + if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { + try { + const drawing = JSON.parse( + await exportEntitiesAndLayersToJsonString() + ) as JsonDrawingFileSerialized; + notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing }); + } catch (error) { + const detail = error instanceof Error ? error.message : 'Unable to serialize drawing'; + notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail }); + } + return; + } if (!isDrawingLoadMessage(event.data)) return; try { const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing); - setEntities(drawing.entities, true); + setEntities(drawing.entities, false); setLayers(drawing.layers); setActiveLayerId(drawing.layers[0].id); getScreenCanvasDrawController().zoomToFitScreen(); notifyParent(AISLO_DRAWING_LOADED_MESSAGE); } catch (error) { const detail = error instanceof Error ? error.message : 'Invalid drawing JSON'; - notifyParent(AISLO_DRAWING_ERROR_MESSAGE, detail); + notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail }); } }); + window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => { + notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); + }); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index ec206223..a1884e83 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -2,14 +2,12 @@ import { Point } from '@flatten-js/core'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { Actor, type MachineSnapshot } from 'xstate'; -import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE, TOOLBAR_WIDTH } from './App.consts'; +import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE } from './App.consts'; import App from './App.tsx'; import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; import { getNewLayer } from './helpers/get-new-layer.ts'; -import type { JsonDrawingFileDeserialized } from './helpers/import-export-handlers/export-entities-to-json.ts'; -import { getEntitiesAndLayersFromLocalStorage } from './helpers/import-export-handlers/import-entities-from-local-storage.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts'; @@ -104,11 +102,14 @@ function startDrawLoop( } function handleWindowResize() { - getScreenCanvasDrawController().setCanvasSize(new Point(window.innerWidth, window.innerHeight)); const canvas = getCanvas(); if (canvas) { - canvas.width = window.innerWidth - TOOLBAR_WIDTH; - canvas.height = window.innerHeight; + const bounds = canvas.getBoundingClientRect(); + const width = Math.max(1, Math.round(bounds.width)); + const height = Math.max(1, Math.round(bounds.height)); + canvas.width = width; + canvas.height = height; + getScreenCanvasDrawController().setCanvasSize(new Point(width, height)); } } @@ -122,21 +123,15 @@ function initApplication() { setEntities([], true); // Creates the first undo entry - // Load the last drawing from local storage - getEntitiesAndLayersFromLocalStorage().then((file: JsonDrawingFileDeserialized) => { - setEntities(file.entities, true); - let layers = file.layers; - if (layers.length === 0) { - layers = [getNewLayer()]; - } - setLayers(layers); - setActiveLayerId(layers[0].id); - registerAisloDrawingBridge(); - }); + const layers = [getNewLayer()]; + setLayers(layers); + setActiveLayerId(layers[0].id); + registerAisloDrawingBridge(); const screenCanvasDrawController = new ScreenCanvasDrawController(context); setScreenCanvasDrawController(screenCanvasDrawController); window.addEventListener('resize', handleWindowResize); + new ResizeObserver(handleWindowResize).observe(canvas); const inputController = new InputController(); setInputController(inputController); diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index d504c2af..1b4f141c 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -147,6 +147,9 @@ let layers: Layer[] = [ */ let activeLayerId: string = layers[0].id; +let snapEnabled = true; +let gridEnabled = false; + // getters export const getCanvas = () => canvas; export const getActiveToolActor = () => activeToolActor; @@ -194,6 +197,8 @@ export const getLayers = () => { export const getActiveLayerId = (): string => { return activeLayerId; }; +export const getSnapEnabled = () => snapEnabled; +export const getGridEnabled = () => gridEnabled; // setters export const setCanvas = (newCanvas: HTMLCanvasElement) => { @@ -243,18 +248,23 @@ export const setActiveToolActor = ( }; export const setLastStateInstructions = (newInstructions: string | null) => { lastStateInstructions = newInstructions; + window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => { if (trackInUndoStack) { trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; + if (trackInUndoStack) { + window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); + } }; export const setHighlightedEntityIds = (newEntityIds: string[]) => { highlightedEntityIds = newEntityIds; }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setShouldDrawCursor = (newValue: boolean) => { shouldDrawCursor = newValue; @@ -335,6 +345,20 @@ export const setActiveLayerId = (newActiveLayerId: string, triggerReact = true) triggerReactUpdate(StateVariable.layers); } }; +export const setSnapEnabled = (enabled: boolean) => { + snapEnabled = enabled; + if (!enabled) { + setSnapPoint(null); + setSnapPointOnAngleGuide(null); + setHoveredSnapPoints([]); + setAngleGuideEntities([]); + } + window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); +}; +export const setGridEnabled = (enabled: boolean) => { + gridEnabled = enabled; + window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); +}; // Computed setters export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => { @@ -393,6 +417,7 @@ export function undo() { if (!undoState) return; updateStates(undoState); + window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } export function redo() { @@ -400,6 +425,7 @@ export function redo() { if (!redoState) return; updateStates(redoState); + window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } export function triggerReactUpdate(variable: StateVariable) { diff --git a/main.py b/main.py index 76a8a35e..01f2948b 100644 --- a/main.py +++ b/main.py @@ -33,6 +33,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router +from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router from common_util.common_util_auth import require_company, verify_session from common_util.common_util_resource_monitor import sample_resources_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -274,6 +275,7 @@ app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) +app.include_router(b07_design_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── From 6ec8fca60e01ac539802f761091c34e676da774d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 19:18:14 +0900 Subject: [PATCH 3/5] 260719_9 --- .../B05_wf2_Route_Engine_Sections.py | 27 +- .../B06_wf3_ProfileCross_Router.py | 8 +- .../B07_wf4_DesignDetail_Router.py | 12 +- .../openwebcad/src/App.consts.ts | 15 +- B07_wf4_DesignDetail/openwebcad/src/App.css | 39 ++ .../openwebcad/src/components/Toolbar.tsx | 161 +++++++ .../openwebcad/src/entities/ArcEntity.ts | 31 +- .../src/entities/ArrowHeadEntity.ts | 20 +- .../openwebcad/src/entities/CircleEntity.ts | 20 +- .../openwebcad/src/entities/Entity.ts | 23 +- .../openwebcad/src/entities/ImageEntity.ts | 26 +- .../openwebcad/src/entities/LineEntity.ts | 31 +- .../src/entities/MeasurementEntity.ts | 82 ++-- .../openwebcad/src/entities/PointEntity.ts | 20 +- .../openwebcad/src/entities/PolyLineEntity.ts | 22 +- .../src/entities/RectangleEntity.ts | 24 +- .../openwebcad/src/entities/TextEntity.ts | 28 +- .../openwebcad/src/helpers/undo-stack.ts | 2 + B07_wf4_DesignDetail/openwebcad/src/state.ts | 39 +- .../openwebcad/src/tools/circle-tool.ts | 295 ++++++------- .../openwebcad/src/tools/line-tool.ts | 308 +++++++------ .../openwebcad/src/tools/measurement-tool.ts | 411 +++++++++--------- .../openwebcad/src/tools/rectangle-tool.ts | 276 ++++++------ 23 files changed, 1130 insertions(+), 790 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index f94278a2..00799058 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -33,6 +33,30 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[ return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords] +def cross_filename(chainage_m: float) -> str: + """측점 chainage에 대응하는 횡단면 파일명(단일 규칙).""" + return f"cross_{int(round(float(chainage_m))):05d}m.json" + + +def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: + """stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다. + + 측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고 + 빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다). + """ + valid = { + cross_filename(station["chainage_m"]) + for station in stations + if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) + } + if not valid: + return valid + for path in cross_dir.glob("cross_*.json"): + if path.name not in valid: + path.unlink(missing_ok=True) + return valid + + def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: """횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다.""" samples = cross_section.get("samples", []) @@ -101,8 +125,7 @@ def run_section_generation( cross_records: list[dict[str, Any]] = [] for seq, cross_section in enumerate(result["cross_sections"]): chainage = float(cross_section["chainage_m"]) - filename = f"cross_{int(round(chainage)):05d}m.json" - cross_file = cross_dir / filename + cross_file = cross_dir / cross_filename(chainage) atomic_write_json(cross_file, cross_section) cross_records.append( { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 46535c22..a3f20ffb 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -12,7 +12,10 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( + prune_stale_cross_files, + run_section_generation, +) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, @@ -122,9 +125,12 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.") longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) cross_sections = [ json.loads(path.read_text(encoding="utf-8")) for path in sorted(cross_dir.glob("cross_*.json")) + if not valid_names or path.name in valid_names ] if not isinstance(longitudinal, dict) or not all( isinstance(section, dict) for section in cross_sections diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index 71d3cf29..dbeca753 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -12,6 +12,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_confirmed_route_context, get_longitudinal_section, @@ -63,11 +64,16 @@ def _read_json(path: Path) -> dict[str, Any]: return payload -def _cross_files(longitudinal_path: Path) -> list[Path]: +def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: cross_dir = longitudinal_path.parent.parent / "cross_sections" if not cross_dir.is_dir(): raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") - return sorted(cross_dir.glob("cross_*.json")) + stations = longitudinal.get("stations") + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + files = sorted(cross_dir.glob("cross_*.json")) + if valid_names: + files = [path for path in files if path.name in valid_names] + return files def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: @@ -114,7 +120,7 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")), ) ] - for path in _cross_files(longitudinal_path): + for path in _cross_files(longitudinal_path, longitudinal): match = _CROSS_ID.fullmatch(path.stem) if not match: continue diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts index 0ffd4cdb..50d6bb9a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts @@ -80,18 +80,21 @@ export const MAX_MARKED_SNAP_POINTS = 3; /** * Length of the extensions that extend past the measurement arrows of a measurement + * Screen pixels: converted to world units per current zoom so drawings in meters stay legible */ -export const MEASUREMENT_EXTENSION_LENGTH = 20; +export const MEASUREMENT_EXTENSION_LENGTH = 12; /** * Distance that measurement lines stay away from the point of origin of the measurement + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_ORIGIN_MARGIN = 20; +export const MEASUREMENT_ORIGIN_MARGIN = 8; /** * Distance the measurement is drawn while drawing the start and endpoints of the measurements but before the user decides the offset point + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_DEFAULT_OFFSET = 200; +export const MEASUREMENT_DEFAULT_OFFSET = 60; /** * Length of the arrow heads for measurements @@ -110,13 +113,15 @@ export const MEASUREMENT_DECIMAL_PLACES = 2; /** * Distance between the measurement line and the label of the measurement + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_LABEL_OFFSET = 20; +export const MEASUREMENT_LABEL_OFFSET = 8; /** * Size of the measurement labels containing the length of the measurements + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_FONT_SIZE = 40; +export const MEASUREMENT_FONT_SIZE = 16; /** * Colors for the selection rectangle diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.css b/B07_wf4_DesignDetail/openwebcad/src/App.css index 2ffbc824..cfbc3955 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.css +++ b/B07_wf4_DesignDetail/openwebcad/src/App.css @@ -148,6 +148,45 @@ body > canvas[data-id="canvas"] { font-size: 10px; text-align: center; } +.cad-ribbon-props { + gap: 8px; + align-items: center; +} +.cad-prop { + display: flex; + flex-direction: column; + gap: 3px; + align-items: stretch; + font-size: 10px; + color: #8293a5; +} +.cad-prop > span { + text-align: center; +} +.cad-prop select, +.cad-prop input[type='number'] { + height: 24px; + min-width: 64px; + padding: 0 4px; + color: #dce6f2; + background: #2a3745; + border: 1px solid #40505f; + border-radius: 4px; + font-size: 11px; +} +.cad-prop input[type='number'] { + min-width: 48px; + width: 48px; +} +.cad-prop input[type='color'] { + height: 24px; + width: 40px; + padding: 1px; + background: #2a3745; + border: 1px solid #40505f; + border-radius: 4px; + cursor: pointer; +} .cad-tool { display: flex; flex-direction: column; diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx index 5f18a42a..2524bee8 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -4,10 +4,18 @@ import { Actor } from 'xstate'; import { HtmlEvent, type Layer } from '../App.types'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; +import type { Entity } from '../entities/Entity'; +import { EntityName } from '../entities/Entity'; +import { TextEntity } from '../entities/TextEntity'; import { getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + getActiveTextStyle, getActiveToolActor, getAngleStep, + getEntities, getGridEnabled, getInputController, getLastStateInstructions, @@ -17,8 +25,13 @@ import { getSnapEnabled, redo, setActiveLayerId, + setActiveLineColor, + setActiveLineDash, + setActiveLineWidth, + setActiveTextStyle, setActiveToolActor, setAngleStep, + setEntities, setGridEnabled, setLayers, setSnapEnabled, @@ -69,6 +82,20 @@ const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [ const COMMANDS = Object.values(Tool); +const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [ + { value: 'solid', label: '실선', dash: undefined }, + { value: 'dashed', label: '파선', dash: [10, 5] }, + { value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] }, + { value: 'dotted', label: '점선', dash: [2, 4] }, +]; + +const LINE_WIDTHS = [1, 2, 3, 4, 5]; + +const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace']; + +const dashToLineType = (dash: number[] | undefined): string => + LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid'; + export const Toolbar: FC = () => { const [activeTool, setActiveTool] = useState(Tool.LINE); const [zoom, setZoom] = useState(1); @@ -84,6 +111,10 @@ export const Toolbar: FC = () => { const [ortho, setOrtho] = useState(getAngleStep() === 90); const [command, setCommand] = useState(''); const [commandLog, setCommandLog] = useState('준비'); + const [lineColor, setLineColorLocal] = useState(getActiveLineColor()); + const [lineWidth, setLineWidthLocal] = useState(getActiveLineWidth()); + const [lineType, setLineTypeLocal] = useState(dashToLineType(getActiveLineDash())); + const [textStyle, setTextStyleLocal] = useState(getActiveTextStyle()); const refresh = useCallback(() => { setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE); @@ -99,6 +130,10 @@ export const Toolbar: FC = () => { setSnap(getSnapEnabled()); setGrid(getGridEnabled()); setOrtho(getAngleStep() === 90); + setLineColorLocal(getActiveLineColor()); + setLineWidthLocal(getActiveLineWidth()); + setLineTypeLocal(dashToLineType(getActiveLineDash())); + setTextStyleLocal({ ...getActiveTextStyle() }); }, []); useEffect(() => { @@ -136,6 +171,55 @@ export const Toolbar: FC = () => { setZoom(controller.getScreenScale()); }; + /** 선택 객체가 있으면 스타일을 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */ + const applyToSelection = useCallback((mutate: (entity: Entity) => void): boolean => { + const selected = getSelectedEntities(); + if (!selected.length) return false; + for (const entity of selected) { + mutate(entity); + } + setEntities([...getEntities()], true); + return true; + }, []); + + const handleLineColor = (color: string) => { + setActiveLineColor(color); + setLineColorLocal(color); + if (applyToSelection((entity) => (entity.lineColor = color))) { + setCommandLog('선택 객체 색상 변경'); + } + }; + + const handleLineWidth = (width: number) => { + setActiveLineWidth(width); + setLineWidthLocal(width); + if (applyToSelection((entity) => (entity.lineWidth = width))) { + setCommandLog('선택 객체 선굵기 변경'); + } + }; + + const handleLineType = (value: string) => { + const dash = LINE_TYPES.find((type) => type.value === value)?.dash; + setActiveLineDash(dash ? [...dash] : undefined); + setLineTypeLocal(value); + if (applyToSelection((entity) => (entity.lineDash = dash ? [...dash] : undefined))) { + setCommandLog('선택 객체 선종류 변경'); + } + }; + + const handleTextStyle = (patch: Partial) => { + setActiveTextStyle(patch); + setTextStyleLocal((previous) => ({ ...previous, ...patch })); + const applied = applyToSelection((entity) => { + if (entity.getType() === EntityName.Text) { + (entity as TextEntity).setTextOptions(patch); + } + }); + if (applied) { + setCommandLog('선택 문자 스타일 변경'); + } + }; + return ( <>
@@ -195,6 +279,83 @@ export const Toolbar: FC = () => { {group.label} ))} +
+
+ + + +
+ 특성 +
+
+
+ + + +
+ 문자 +