This commit is contained in:
2026-07-19 18:26:00 +09:00
parent 29a17f33ce
commit ef0dab9bc3
16 changed files with 1872 additions and 696 deletions
@@ -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<string, unknown>[];
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`);
}
export function fetchDesignDrawing(
projectId: string,
drawingId: string,
): Promise<DesignDrawingResponse> {
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
}
export function confirmDesignDrawing(
projectId: string,
drawingId: string,
drawing: CadDrawing,
): Promise<DesignDrawingConfirmResponse> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`,
{ method: "PUT", body: JSON.stringify({ drawing }) },
);
}
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
{ method: "POST" },
);
}
@@ -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": "상세 설계 도면 상태를 되돌리지 못했습니다."},
)
@@ -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
@@ -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<void>,
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<void> {
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<void> {
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<HTMLButtonElement>(".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<CadDrawing> =>
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<void> {
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<unknown>) => {
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");
@@ -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;
}
@@ -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
+405
View File
@@ -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;
}
}
+1 -19
View File
@@ -4,26 +4,8 @@ import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div
className="overflow-y-scroll h-lvh pb-6 w-80 bg-slate-950"
style={{ scrollbarWidth: 'none' }}
>
<header className="px-3 py-3 border-b border-slate-700 text-white">
<strong className="block text-sm">Aislo 2D Drawing</strong>
<span className="text-xs text-slate-400">B07 </span>
</header>
<div className="cad-app">
<Toolbar />
<footer className="px-3 py-3 border-t border-slate-700 text-xs text-slate-400">
Drawing engine based on OpenWebCAD ·{' '}
<a
className="underline hover:text-white"
href="./THIRD_PARTY_LICENSES.txt"
target="_blank"
rel="noreferrer"
>
MIT License
</a>
</footer>
<ToastContainer position="bottom-right" theme="light" />
</div>
);
@@ -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 {
@@ -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>(Tool.LINE);
const [angleStepLocal, setAngleStepLocal] = useState<number>(45);
const [activeLineColorLocal, setActiveLineColorLocal] = useState<string>('#FFF');
const [activeLineWidthLocal, setActiveLineWidthLocal] = useState<number>(1);
const [screenZoomLocal, setScreenZoomLocal] = useState<number>(1);
const [layersLocal, setLayersLocal] = useState<Layer[]>(getLayers());
const [activeLayerIdLocal, setActiveLayerIdLocal] = useState(getLayers()[0].id);
const [activeTool, setActiveTool] = useState<Tool>(Tool.LINE);
const [zoom, setZoom] = useState(1);
const [layers, setLayersLocal] = useState<Layer[]>(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 (
<div className="controls top-0 left-0 flex flex-col gap-1 p-1 bg-slate-950 overscroll-y-auto">
<DropdownButton
label="Draw"
title={'Draw tools'}
iconName={IconName.Edit}
defaultOpen
dataId="dropdown-draw-tools"
>
<Button
className="w-full"
title="Select (s)"
dataId="select-button"
iconName={IconName.Direction}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SELECT);
}}
active={activeToolLocal === Tool.SELECT}
label="Select"
/>
<Button
className="w-full"
title="Line (l)"
dataId="line-button"
iconName={IconName.Line}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.LINE);
}}
active={activeToolLocal === Tool.LINE}
label="Line"
/>
<Button
className="w-full"
title="Rectangle (r)"
dataId="rectangle-button"
iconName={IconName.Square}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.RECTANGLE);
}}
active={activeToolLocal === Tool.RECTANGLE}
label="Rectangle"
/>
<Button
className="w-full"
title="Circle (c)"
dataId="circle-button"
iconName={IconName.Circle}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.CIRCLE);
}}
active={activeToolLocal === Tool.CIRCLE}
label="Circle"
/>
<Button
className="mt-2 w-full"
title="Move"
dataId="move-button"
iconName={IconName.Expand}
iconClassname={'transform rotate-45'}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MOVE);
}}
active={activeToolLocal === Tool.MOVE}
label="Move"
/>
<Button
className="w-full"
title="Copy"
dataId="copy-button"
iconName={IconName.Documents}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.COPY);
}}
active={activeToolLocal === Tool.COPY}
label="Copy"
/>
<Button
className="w-full"
title="Scale"
dataId="scale-button"
iconName={IconName.Scale}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SCALE);
}}
active={activeToolLocal === Tool.SCALE}
label="Scale"
/>
<Button
className="w-full"
title="Rotate"
dataId="rotate-button"
iconName={IconName.Clockwise}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ROTATE);
}}
active={activeToolLocal === Tool.ROTATE}
label="Rotate"
/>
<Button
className="w-full"
title="Array"
dataId="array-button"
iconName={IconName.GridLayout}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ARRAY);
}}
active={activeToolLocal === Tool.ARRAY}
label="Array copy"
/>
<Button
className="w-full"
title="Create polyline lines and arcs"
dataId="pedit-button"
iconComponent={<Icon name={IconName.HomeAlt} className="rotate-270" />}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.PEDIT);
}}
active={activeToolLocal === Tool.PEDIT}
label="Polyline edit"
/>
<Button
className="mt-2 w-full"
title="Add measurements"
dataId="measurement-button"
iconName={IconName.Measurement}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MEASUREMENT);
}}
active={activeToolLocal === Tool.MEASUREMENT}
label="Measurement"
/>
<Button
className="mt-2 w-full"
title="Delete segments"
dataId="delete-segment-button"
iconName={IconName.Crop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ERASER);
}}
active={activeToolLocal === Tool.ERASER}
label="Eraser"
/>
</DropdownButton>
<DropdownButton dataId="layers" label="Layers" iconName={IconName.AlignTextJustify}>
<LayerManager
className="w-full"
layers={layersLocal}
activeLayerId={activeLayerIdLocal}
setLayers={handleSetLayers}
setActiveLayerId={handleSetActiveLayerId}
/>
</DropdownButton>
<Button
className="mt-2"
title="Undo (ctrl + z)"
dataId="undo-button"
iconName={IconName.ArrowLeftCircle}
onClick={() => undo()}
label="Undo"
/>
<Button
title="Redo (ctrl + shift + z)"
dataId="redo-button"
iconName={IconName.ArrowRightCircle}
onClick={() => redo()}
label="Redo"
/>
<DropdownButton
className="mt-2"
title="Align"
dataId="align-button"
label="Align"
iconName={IconName.AlignCenterHorizontal}
>
<Button
className="w-full"
title="Align left"
dataId="align-left-button"
iconName={IconName.AlignLeft}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_LEFT);
}}
active={activeToolLocal === Tool.ALIGN_LEFT}
label="Left"
/>
<Button
className="w-full"
title="Align center horizontal"
dataId="align-center-horizontal-button"
iconName={IconName.AlignCenterHorizontal}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_HORIZONTAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_HORIZONTAL}
label="Center"
/>
<Button
className="w-full"
title="Align right"
dataId="align-right-button"
iconName={IconName.AlignRight}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_RIGHT);
}}
active={activeToolLocal === Tool.ALIGN_RIGHT}
label="Right"
/>
<Button
className="w-full"
title="Align top"
dataId="align-top-button"
iconName={IconName.AlignTop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_TOP);
}}
active={activeToolLocal === Tool.ALIGN_TOP}
label="Top"
/>
<Button
className="w-full"
title="Align center vertical"
dataId="align-center-vertical-button"
iconName={IconName.AlignCenterVertical}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_VERTICAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_VERTICAL}
label="Middle"
/>
<Button
className="w-full"
title="Align bottom"
dataId="align-bottom-button"
iconName={IconName.AlignBottom}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_BOTTOM);
}}
active={activeToolLocal === Tool.ALIGN_BOTTOM}
label="Bottom"
/>
</DropdownButton>
<DropdownButton
className="mt-2"
title="Line color"
dataId="line-color-button"
label="Line color"
iconComponent={
<div className="w-5 h-5" style={{ backgroundColor: activeLineColorLocal }} />
}
>
{COLOR_LIST.map((color) => (
<Button
key={`line-color--${color}`}
title="Change line color"
dataId={`line-color-${color}-button`}
className="w-10"
style={{ backgroundColor: color }}
active={color === activeLineColorLocal}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineColor(color);
<>
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B07 </span>
</div>
<div className="cad-file-state">
<span className="cad-file-state__dot" />
·
</div>
<div className="cad-title-actions">
<button type="button" onClick={() => undo()} title="실행 취소 (Ctrl+Z)">
</button>
<button type="button" onClick={() => redo()} title="다시 실행 (Ctrl+Y)">
</button>
<button
type="button"
onClick={async () => {
await exportEntitiesToLocalStorage();
toast.success('도면을 저장했습니다.');
}}
/>
>
</button>
<button type="button" onClick={() => exportEntitiesToJsonFile()}>
</button>
</div>
</header>
<nav className="cad-ribbon controls" aria-label="CAD 도구 리본">
{RIBBON_GROUPS.map((group) => (
<section className="cad-ribbon-group" key={group.label}>
<div className="cad-ribbon-tools">
{group.tools.map((item) => (
<button
type="button"
key={item.label}
className="cad-tool"
data-active={item.tool === activeTool}
disabled={item.disabled}
title={
item.disabled
? `${item.label} 도구는 후속 구현 예정입니다.`
: `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`
}
onClick={() => item.tool && activateTool(item.tool)}
>
<span className="cad-tool__glyph">{item.glyph}</span>
<span>{item.label}</span>
</button>
))}
</div>
<span className="cad-ribbon-group__label">{group.label}</span>
</section>
))}
</DropdownButton>
<DropdownButton
title="Line width"
dataId="line-width-button"
label="Line width"
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${activeLineWidthLocal}px` }}
/>
}
>
{times<number>(9).map((width: number) => {
const lineWidth = width + 1;
return (
<Button
key={`line-width--${lineWidth}`}
title="Change line width"
dataId={`line-width-${lineWidth}-button`}
label={`${String(lineWidth)}px`}
active={lineWidth === activeLineWidthLocal}
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${lineWidth}px` }}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineWidth(lineWidth);
}}
/>
);
})}
</DropdownButton>
<DropdownButton
title="Snap angles"
iconComponent={<div className="w-5 text-blue-700">{`${angleStepLocal}°`}</div>}
label="Snap angles"
dataId="angle-guide-button"
>
{[5, 15, 30, 45, 90].map((angle: number) => (
<Button
key={`angle-guide--${angle}`}
title={`Add guide every ${angle} degrees`}
dataId={`angle-guide-${angle}-button`}
label={`${angle}°`}
iconComponent={
<div
className={'w-5 h-0 border-t-2 border-t-white'}
style={{ rotate: `${-angle}deg` }}
</nav>
<aside className="cad-inspector controls" data-collapsed={panelCollapsed}>
<button
className="cad-inspector__collapse"
type="button"
onClick={() => setPanelCollapsed((value) => !value)}
title={panelCollapsed ? '패널 펼치기' : '패널 접기'}
>
{panelCollapsed ? '' : ''}
</button>
{!panelCollapsed && (
<>
<div className="cad-inspector-tabs">
<button
type="button"
data-active={panelTab === 'properties'}
onClick={() => setPanelTab('properties')}
>
</button>
<button
type="button"
data-active={panelTab === 'layers'}
onClick={() => setPanelTab('layers')}
>
</button>
</div>
{panelTab === 'properties' ? (
<div className="cad-properties">
<h2>{selectedType}</h2>
<dl>
<div>
<dt> </dt>
<dd>{selectedCount}</dd>
</div>
<div>
<dt> </dt>
<dd>{activeTool}</dd>
</div>
<div>
<dt> </dt>
<dd>{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}</dd>
</div>
</dl>
</div>
) : (
<LayerManager
className="cad-layer-manager"
layers={layers}
activeLayerId={activeLayerId}
setLayers={(next) => {
setLayersLocal(next);
setLayers(next);
}}
setActiveLayerId={(id) => {
setActiveLayerIdLocal(id);
setActiveLayerId(id);
}}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
handleAngleChanged(angle);
}}
active={angle === angleStepLocal}
/>
))}
</DropdownButton>
<DropdownButton
title="Zoom level"
iconComponent={<div className="w-5 text-blue-700">{screenZoomLocal.toFixed(1)}</div>}
label="Zoom level"
dataId="zoom-level-button"
>
{[20, 50, 75, 100, 150, 200, 400].map((zoom: number) => (
<Button
key={`zoom-level--${zoom}`}
title={`Zoom level ${zoom}%`}
dataId={`zoom-level-${zoom}-button`}
label={`${zoom.toFixed(0)}%`}
style={{ width: 'calc(30% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
getScreenCanvasDrawController().setScreenScale(zoom / 100);
setScreenZoomLocal(zoom / 100);
}}
active={zoom === screenZoomLocal}
/>
))}
<Button
key="zoom-level--fit"
title="Zoom fit screen"
dataId="zoom-level-fit-button"
label="Fit screen"
style={{ width: 'calc(60% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
)}
</>
)}
</aside>
<div className="cad-view-controls controls">
<button
type="button"
onClick={() => {
getScreenCanvasDrawController().zoomToFitScreen();
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
refresh();
}}
active={false}
/>
</DropdownButton>
<Button
className="mt-2"
title="Save current drawing"
dataId="save-button"
iconName={IconName.Save}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToLocalStorage();
toast.success('Saved');
}}
label="Save drawing"
/>
<Button
className="mt-2"
title="Start a new drawing"
dataId="new-button"
iconName={IconName.FilePlus}
onClick={(evt) => {
evt.stopPropagation();
setEntities([]);
}}
label="New drawing"
/>
<DropdownButton
label="Import"
title={'Import files'}
iconName={IconName.SendUp}
dataId="dropdown-import-tools"
>
<Button
className="relative w-full"
title="Import image into the current drawing"
dataId="import-image-button"
iconName={IconName.ImageSolid}
onClick={noopClickHandler}
label="image"
title="화면에 맞춤"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.jpg,*.jpeg,*.png"
onChange={async (evt) => {
const image: HTMLImageElement = await importImageFromFile(evt.target.files?.[0]);
const imageImportActor = new Actor(imageImportToolStateMachine);
imageImportActor.start();
imageImportActor.send({
type: ActorEvent.FILE_SELECTED,
image,
});
setActiveToolActor(imageImportActor);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from JSON file"
dataId="json-open-button"
iconName={IconName.JavascriptSolid}
onClick={noopClickHandler}
label="JSON"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.json"
onChange={async (evt) => {
await importEntitiesFromJsonFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from SVG file"
dataId="svg-open-button"
iconName={IconName.VectorDocumentSolid}
onClick={noopClickHandler}
label="SVG"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.svg"
onChange={async (evt) => {
await importEntitiesFromSvgFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
</DropdownButton>
</button>
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => changeZoom(0.8)} title="축소">
</button>
</div>
<DropdownButton
label="Export"
title={'Export file'}
iconName={IconName.SendDown}
dataId="dropdown-export-tools"
>
<Button
className="w-full"
title="Save to JSON file"
dataId="json-save-button"
iconName={IconName.JavascriptSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToJsonFile();
}}
label="JSON"
/>
<Button
className="w-full"
title="Export to SVG file"
dataId="svg-export-button"
iconName={IconName.VectorDocumentSolid}
onClick={(evt) => {
evt.stopPropagation();
exportEntitiesToSvgFile();
}}
label="SVG"
/>
<Button
className="w-full"
title="Export to PNG file"
dataId="png-export-button"
iconName={IconName.ImageSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToPngFile();
}}
label="PNG"
/>
</DropdownButton>
</div>
<section className="cad-command-area controls">
<div className="cad-command-prompt">
<span>{commandLog}</span>
<strong>{instruction}</strong>
</div>
<form onSubmit={handleCommand}>
<label htmlFor="cad-command">:</label>
<input
id="cad-command"
list="cad-command-list"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)"
autoComplete="off"
/>
<datalist id="cad-command-list">
{COMMANDS.map((item) => (
<option value={item} key={item} />
))}
</datalist>
</form>
</section>
<footer className="cad-statusbar controls">
<button type="button" data-active={snap} onClick={() => setSnapEnabled(!snap)}>
OSNAP
</button>
<button type="button" data-active={ortho} onClick={() => setAngleStep(ortho ? 45 : 90)}>
</button>
<button type="button" data-active={grid} onClick={() => setGridEnabled(!grid)}>
</button>
<span className="cad-statusbar__hint">: · 드래그: · Esc: 취소</span>
</footer>
</>
);
};
@@ -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();
}
}
/**
@@ -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
@@ -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<DrawingLoadMessage>;
return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing);
}
function notifyParent(type: string, detail?: string) {
function notifyParent(type: string, payload: Record<string, unknown> = {}) {
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<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
const message = event.data as Partial<DrawingSaveRequestMessage>;
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);
}
+12 -17
View File
@@ -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);
@@ -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) {
+2
View File
@@ -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)
# ─────────────────────────────────────────────────────────────────────────