Merge branch 'feature/B07-페이지-개발'
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* 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[];
|
||||
}
|
||||
|
||||
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
|
||||
export type QuantityTable = Record<string, number | null>;
|
||||
|
||||
export interface DesignDrawingResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
id: string;
|
||||
kind: "longitudinal" | "cross";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
quantity_table?: QuantityTable | null;
|
||||
}
|
||||
|
||||
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,
|
||||
quantityTable?: QuantityTable | null,
|
||||
): Promise<DesignDrawingConfirmResponse> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`,
|
||||
{ method: "PUT", body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }) },
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"""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 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,
|
||||
)
|
||||
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, longitudinal: dict[str, Any]) -> list[Path]:
|
||||
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
||||
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]]:
|
||||
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, longitudinal):
|
||||
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]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고,
|
||||
# planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다.
|
||||
_QUANTITY_ITEM_KEYS = (
|
||||
"cut_soil",
|
||||
"cut_soft_rock",
|
||||
"cut_rock",
|
||||
"tree_removal",
|
||||
"fill_slope_protection",
|
||||
"cut_slope_protection",
|
||||
"ditch_soil",
|
||||
"ditch_soft_rock",
|
||||
"ditch_rock",
|
||||
"embankment",
|
||||
"grubbing",
|
||||
"surface_grading",
|
||||
)
|
||||
|
||||
|
||||
def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
|
||||
"""횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다.
|
||||
|
||||
아직 산정되지 않은 값은 None으로 두어 프론트 입력칸에서 사용자가 채운다.
|
||||
절토고/성토고(cut/fill)는 지반고·계획고에서 파생한 초기값이다.
|
||||
"""
|
||||
|
||||
def num(value: Any) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
ground = num(source.get("center_z"))
|
||||
planned = num(source.get("planned_elevation_m", source.get("design_elevation_m")))
|
||||
cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None
|
||||
fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None
|
||||
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
|
||||
|
||||
table: dict[str, float | None] = {
|
||||
"ground": ground,
|
||||
"planned": planned,
|
||||
"cut": cut,
|
||||
"fill": fill,
|
||||
}
|
||||
for key in _QUANTITY_ITEM_KEYS:
|
||||
table[key] = num(quantities.get(key))
|
||||
return table
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
# 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다.
|
||||
return {
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
{
|
||||
"id": _GROUND_LAYER_ID,
|
||||
"name": "Existing Ground",
|
||||
"isVisible": True,
|
||||
"isLocked": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _read_drawing(
|
||||
project_root: Path, longitudinal_path: Path, drawing_id: str
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
|
||||
|
||||
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
|
||||
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다.
|
||||
"""
|
||||
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)
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, _read_json(saved_path), True, table
|
||||
if drawing_id == "longitudinal":
|
||||
source = _read_json(longitudinal_path)
|
||||
return (
|
||||
"longitudinal",
|
||||
"종단도 전체",
|
||||
_cad_drawing(source, drawing_id, "longitudinal"),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
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, _quantity_table(source)
|
||||
|
||||
|
||||
def _store_confirmed_drawing(
|
||||
project_root: Path,
|
||||
item: DesignDrawingItem,
|
||||
drawing: dict[str, Any],
|
||||
expected_ids: set[str],
|
||||
quantity_table: dict[str, Any] | None = None,
|
||||
) -> 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)
|
||||
entry: dict[str, Any] = {
|
||||
"kind": item.kind,
|
||||
"label": item.label,
|
||||
"confirmed": True,
|
||||
"file": f"drawings/{item.id}.json",
|
||||
}
|
||||
if item.kind == "cross" and isinstance(quantity_table, dict):
|
||||
entry["quantity_table"] = quantity_table
|
||||
manifest["drawings"][item.id] = entry
|
||||
_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, quantity_table = 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,
|
||||
quantity_table=quantity_table,
|
||||
)
|
||||
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},
|
||||
request.quantity_table,
|
||||
)
|
||||
|
||||
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,66 @@
|
||||
"""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
|
||||
# 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None.
|
||||
quantity_table: dict[str, float | None] | None = None
|
||||
|
||||
|
||||
class DesignDrawingConfirmRequest(BaseModel):
|
||||
"""CAD 앱에서 직렬화한 현재 편집 도면."""
|
||||
|
||||
drawing: dict[str, Any]
|
||||
# 사용자가 편집한 수량 산출표 값 (횡단도 확정 시 영구 저장).
|
||||
quantity_table: dict[str, Any] | None = None
|
||||
|
||||
|
||||
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,15 @@ 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";
|
||||
import { buildQuantityTable } from "./B07_wf4_DesignDetail_UI_QuantityTable";
|
||||
|
||||
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const B07_CAD_APP_URL = "/b07-cad/index.html";
|
||||
@@ -30,17 +45,104 @@ 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";
|
||||
|
||||
/** 측점 간격을 연속 chainage 차이의 최빈값으로 추정한다 (B06 그래프와 동일 방식). */
|
||||
function inferStationInterval(chainages: number[]): number {
|
||||
const counts = new Map<number, number>();
|
||||
const sorted = [...chainages].sort((a, b) => a - b);
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
const difference = sorted[index] - sorted[index - 1];
|
||||
if (difference <= 0) continue;
|
||||
const rounded = Math.round(difference * 10) / 10;
|
||||
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
|
||||
}
|
||||
return (
|
||||
[...counts.entries()].sort(
|
||||
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
|
||||
)[0]?.[0] ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
/** 측점 번호+나머지 표기 (B06 그래프 영역 횡단도 라벨과 동일 형식, 예: "2+0.0"). */
|
||||
function stationLabel(chainage: number, interval: number): string {
|
||||
const safeInterval = interval > 0 ? interval : 1;
|
||||
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
|
||||
let remainder = chainage - stationNumber * safeInterval;
|
||||
if (Math.abs(remainder) < 0.05) remainder = 0;
|
||||
if (remainder >= safeInterval - 0.05) {
|
||||
stationNumber += 1;
|
||||
remainder = 0;
|
||||
}
|
||||
return `${stationNumber}+${remainder.toFixed(1)}`;
|
||||
}
|
||||
|
||||
/** 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 crossChainages = drawings
|
||||
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
|
||||
.map((item) => item.chainage_m as number);
|
||||
const stationInterval = inferStationInterval(crossChainages);
|
||||
|
||||
const groups: [string, DesignDrawingItem["kind"], DesignDrawingItem[]][] = [
|
||||
["종단도", "longitudinal", drawings.filter((item) => item.kind === "longitudinal")],
|
||||
["횡단도", "cross", drawings.filter((item) => item.kind === "cross")],
|
||||
];
|
||||
for (const [label, kind, items] of groups) {
|
||||
if (!items.length) continue;
|
||||
const section = document.createElement("section");
|
||||
section.className = "b07-drawing-group";
|
||||
section.dataset.kind = kind;
|
||||
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.className = "b07-drawing-button__name";
|
||||
name.textContent =
|
||||
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label;
|
||||
button.append(name);
|
||||
button.addEventListener("click", () => void onSelect(drawing, button));
|
||||
section.append(button);
|
||||
}
|
||||
panel.append(section);
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -50,12 +152,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 +174,179 @@ 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);
|
||||
|
||||
// CAD 영역 + 하단 수량 산출표를 세로로 묶는 메인 콘텐츠
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.className = "b07-main-stack";
|
||||
mainContent.append(cadHost);
|
||||
|
||||
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);
|
||||
if (drawing.kind === "cross") {
|
||||
quantityTable.update(button.textContent ?? drawing.label, response.quantity_table);
|
||||
} else {
|
||||
quantityTable.element.hidden = true;
|
||||
}
|
||||
} 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,
|
||||
currentDrawing.kind === "cross" ? quantityTable.getValues() : null,
|
||||
);
|
||||
currentConfirmed = true;
|
||||
currentDrawing.confirmed = true;
|
||||
confirmButton.disabled = true;
|
||||
if (currentButton) currentButton.dataset.confirmed = "true";
|
||||
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";
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* =============================================================================
|
||||
* B07_wf4_DesignDetail_UI_QuantityTable.ts
|
||||
* 횡단도 수량 산출표 (편집 가능). 첨부 양식의 병합셀 구조를 12열 표로 재현한다.
|
||||
*
|
||||
* 값은 백엔드 `_quantity_table`가 내려준 초기값으로 채우되, 사용자가 각 칸을
|
||||
* 직접 수정할 수 있다. 절토고/성토고는 지반고·계획고에서 파생되어 읽기 전용이며
|
||||
* 입력 변경 시 즉시 재계산된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { QuantityTable } from "./B07_wf4_DesignDetail_Api_Fetch";
|
||||
|
||||
/** 편집 입력이 있는 항목 키 (cut/fill 제외 — 파생 읽기전용). */
|
||||
const EDITABLE_KEYS = [
|
||||
"ground",
|
||||
"planned",
|
||||
"cut_soil",
|
||||
"cut_soft_rock",
|
||||
"cut_rock",
|
||||
"tree_removal",
|
||||
"fill_slope_protection",
|
||||
"cut_slope_protection",
|
||||
"ditch_soil",
|
||||
"ditch_soft_rock",
|
||||
"ditch_rock",
|
||||
"embankment",
|
||||
"grubbing",
|
||||
"surface_grading",
|
||||
] as const;
|
||||
|
||||
export interface QuantityTableController {
|
||||
element: HTMLElement;
|
||||
/** 새 도면 선택 시 값·측점명을 갱신한다. */
|
||||
update(stationTitle: string, values: QuantityTable | null | undefined): void;
|
||||
/** 현재 입력값(파생 cut/fill 포함)을 수집한다. */
|
||||
getValues(): QuantityTable;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function formatValue(value: number | null | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? String(round2(value)) : "";
|
||||
}
|
||||
|
||||
function parseValue(raw: string): number | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/** 편집 가능한 값 입력 칸을 만든다. */
|
||||
function valueCell(
|
||||
key: string,
|
||||
colSpan: number,
|
||||
inputs: Map<string, HTMLInputElement>,
|
||||
options: { readonly?: boolean } = {},
|
||||
): HTMLTableCellElement {
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = colSpan;
|
||||
cell.className = "b07-qtable__value";
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.autocomplete = "off";
|
||||
input.dataset.key = key;
|
||||
if (options.readonly) {
|
||||
input.readOnly = true;
|
||||
cell.classList.add("b07-qtable__value--derived");
|
||||
}
|
||||
inputs.set(key, input);
|
||||
cell.append(input);
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** 라벨(헤더) 셀을 만든다. */
|
||||
function labelCell(
|
||||
text: string,
|
||||
colSpan: number,
|
||||
options: { rowSpan?: number; vertical?: boolean } = {},
|
||||
): HTMLTableCellElement {
|
||||
const cell = document.createElement("th");
|
||||
cell.scope = "row";
|
||||
cell.colSpan = colSpan;
|
||||
if (options.rowSpan) cell.rowSpan = options.rowSpan;
|
||||
cell.className = "b07-qtable__label";
|
||||
if (options.vertical) cell.classList.add("b07-qtable__label--vertical");
|
||||
cell.textContent = text;
|
||||
return cell;
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 양식과 동일한 편집 가능 수량 산출표를 생성한다.
|
||||
* @param onEdit 사용자가 값을 바꿀 때마다 호출 (확정 상태 롤백 연동용)
|
||||
*/
|
||||
export function buildQuantityTable(onEdit: () => void): QuantityTableController {
|
||||
const inputs = new Map<string, HTMLInputElement>();
|
||||
|
||||
const container = document.createElement("section");
|
||||
container.className = "b07-qtable";
|
||||
container.hidden = true;
|
||||
|
||||
const table = document.createElement("table");
|
||||
const body = document.createElement("tbody");
|
||||
|
||||
// 1행: 측 점 | (측점명)
|
||||
const titleRow = document.createElement("tr");
|
||||
titleRow.append(labelCell("측 점", 2));
|
||||
const titleCell = document.createElement("td");
|
||||
titleCell.colSpan = 10;
|
||||
titleCell.className = "b07-qtable__station";
|
||||
titleRow.append(titleCell);
|
||||
body.append(titleRow);
|
||||
|
||||
// 2행: 지반고 | 계획고 | 절토고 | 성토고
|
||||
const baseRow = document.createElement("tr");
|
||||
baseRow.append(labelCell("지반고", 2), valueCell("ground", 1, inputs));
|
||||
baseRow.append(labelCell("계획고", 2), valueCell("planned", 1, inputs));
|
||||
baseRow.append(labelCell("절토고", 2), valueCell("cut", 1, inputs, { readonly: true }));
|
||||
baseRow.append(labelCell("성토고", 2), valueCell("fill", 1, inputs, { readonly: true }));
|
||||
body.append(baseRow);
|
||||
|
||||
// 3행: 흙깎기 토사 | 지장목제거 | 옆도랑파기 토사
|
||||
const row3 = document.createElement("tr");
|
||||
row3.append(labelCell("흙깎기", 1, { rowSpan: 3, vertical: true }));
|
||||
row3.append(labelCell("토사", 1), valueCell("cut_soil", 2, inputs));
|
||||
row3.append(labelCell("지장목제거", 2), valueCell("tree_removal", 2, inputs));
|
||||
row3.append(labelCell("옆도랑파기", 1, { rowSpan: 3, vertical: true }));
|
||||
row3.append(labelCell("토사", 1), valueCell("ditch_soil", 2, inputs));
|
||||
body.append(row3);
|
||||
|
||||
// 4행: 연암 | 비탈보호공 성토면 | 연암
|
||||
const row4 = document.createElement("tr");
|
||||
row4.append(labelCell("연암", 1), valueCell("cut_soft_rock", 2, inputs));
|
||||
row4.append(labelCell("비탈보호공", 1, { rowSpan: 2, vertical: true }));
|
||||
row4.append(labelCell("성토면", 1), valueCell("fill_slope_protection", 2, inputs));
|
||||
row4.append(labelCell("연암", 1), valueCell("ditch_soft_rock", 2, inputs));
|
||||
body.append(row4);
|
||||
|
||||
// 5행: 보통암 | 절토면 | 보통암
|
||||
const row5 = document.createElement("tr");
|
||||
row5.append(labelCell("보통암", 1), valueCell("cut_rock", 2, inputs));
|
||||
row5.append(labelCell("절토면", 1), valueCell("cut_slope_protection", 2, inputs));
|
||||
row5.append(labelCell("보통암", 1), valueCell("ditch_rock", 2, inputs));
|
||||
body.append(row5);
|
||||
|
||||
// 6행: 흙쌓기 | 제근 | 노면고르기
|
||||
const row6 = document.createElement("tr");
|
||||
row6.append(labelCell("흙쌓기", 2), valueCell("embankment", 2, inputs));
|
||||
row6.append(labelCell("제근", 2), valueCell("grubbing", 2, inputs));
|
||||
row6.append(labelCell("노면고르기", 2), valueCell("surface_grading", 2, inputs));
|
||||
body.append(row6);
|
||||
|
||||
table.append(body);
|
||||
container.append(table);
|
||||
|
||||
const groundInput = inputs.get("ground");
|
||||
const plannedInput = inputs.get("planned");
|
||||
const cutInput = inputs.get("cut");
|
||||
const fillInput = inputs.get("fill");
|
||||
|
||||
const recomputeCutFill = () => {
|
||||
const ground = parseValue(groundInput?.value ?? "");
|
||||
const planned = parseValue(plannedInput?.value ?? "");
|
||||
if (ground !== null && planned !== null) {
|
||||
if (cutInput) cutInput.value = formatValue(Math.max(ground - planned, 0));
|
||||
if (fillInput) fillInput.value = formatValue(Math.max(planned - ground, 0));
|
||||
} else {
|
||||
if (cutInput) cutInput.value = "";
|
||||
if (fillInput) fillInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of EDITABLE_KEYS) {
|
||||
const input = inputs.get(key);
|
||||
input?.addEventListener("input", () => {
|
||||
if (key === "ground" || key === "planned") recomputeCutFill();
|
||||
onEdit();
|
||||
});
|
||||
}
|
||||
|
||||
const update: QuantityTableController["update"] = (stationTitle, values) => {
|
||||
titleCell.textContent = stationTitle;
|
||||
for (const [key, input] of inputs) {
|
||||
input.value = formatValue(values?.[key]);
|
||||
}
|
||||
recomputeCutFill();
|
||||
container.hidden = false;
|
||||
};
|
||||
|
||||
const getValues: QuantityTableController["getValues"] = () => {
|
||||
const result: QuantityTable = {};
|
||||
for (const [key, input] of inputs) {
|
||||
result[key] = parseValue(input.value);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return { element: container, update, getValues };
|
||||
}
|
||||
@@ -16,29 +16,109 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 빈 사이드 패널 (B06 전달 데이터 확정 후 구성 예정) */
|
||||
.b07-side-empty {
|
||||
/* B06 확정 산출물 도면 목록 */
|
||||
.b07-drawing-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
height: 100%;
|
||||
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);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b07-drawing-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 횡단도는 2열 배치 */
|
||||
.b07-drawing-group[data-kind="cross"] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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-group[data-kind="cross"] h3 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b07-drawing-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-16);
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding: var(--spacing-24);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
color: var(--color-text-muted);
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b07-side-empty__icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
.b07-drawing-button__name {
|
||||
overflow: hidden;
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b07-side-empty__msg {
|
||||
font-size: var(--text-body-sm);
|
||||
.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[data-confirmed="true"] {
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.b07-drawing-button[data-confirmed="true"] .b07-drawing-button__name {
|
||||
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 뷰어 호스트 (상세 페이지 영역) */
|
||||
@@ -60,3 +140,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
|
||||
@@ -85,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
|
||||
@@ -115,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
|
||||
|
||||
@@ -1 +1,449 @@
|
||||
@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-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;
|
||||
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__fit {
|
||||
color: #7fd0ff;
|
||||
font-size: 15px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+54
-23
@@ -1,12 +1,11 @@
|
||||
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 { 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, triggerReactUpdate } from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
|
||||
/**
|
||||
* Screen coordinate system:
|
||||
@@ -117,23 +116,40 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 도면(모든 엔티티)을 화면 중심에 여백을 두고 배치한다.
|
||||
* 가로/세로 중 더 제약이 큰 축에 맞춰 배율을 정하고, 도면 중심이 화면 중심에
|
||||
* 오도록 screenOffset(월드 좌표)을 역산한다. (기존 구현은 화면 픽셀 여백값을
|
||||
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
|
||||
*/
|
||||
public zoomToFitScreen() {
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(getEntities());
|
||||
const entities = getEntities();
|
||||
if (!entities.length) return;
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const fittedRect = containRectangle(
|
||||
boundingBox.minX,
|
||||
boundingBox.minY,
|
||||
boundingBox.maxX,
|
||||
boundingBox.maxY,
|
||||
0,
|
||||
0,
|
||||
getScreenCanvasDrawController().getCanvasSize().x,
|
||||
getScreenCanvasDrawController().getCanvasSize().y
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
const canvasSize = this.getCanvasSize();
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
this.setScreenScale(zoomLevel);
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
this.setScreenOffset(
|
||||
new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
)
|
||||
);
|
||||
const fittedWidth = fittedRect.maxX - fittedRect.minX;
|
||||
const zoomLevel = fittedWidth / boundingWidth;
|
||||
getScreenCanvasDrawController().setScreenScale(zoomLevel);
|
||||
getScreenCanvasDrawController().setScreenOffset(new Point(fittedRect.minX, fittedRect.minY));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +245,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,15 +1,20 @@
|
||||
import {Arc, type Box, Line, Point, type Segment} from '@flatten-js/core';
|
||||
import {uniqWith} from 'es-toolkit';
|
||||
import {type Shape, type SnapPoint, SnapPointType, type StartAndEndpointEntity} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {isPointEqual} from '../helpers/is-point-equal';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {sortPointsOnArc} from '../helpers/sort-points-on-arc';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { Arc, type Box, Line, Point, type Segment } from '@flatten-js/core';
|
||||
import { uniqWith } from 'es-toolkit';
|
||||
import {
|
||||
type Shape,
|
||||
type SnapPoint,
|
||||
SnapPointType,
|
||||
type StartAndEndpointEntity,
|
||||
} from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { isPointEqual } from '../helpers/is-point-equal';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { sortPointsOnArc } from '../helpers/sort-points-on-arc';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -211,6 +216,7 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
type: EntityName.Arc,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
center: { x: this.arc.center.x, y: this.arc.center.y },
|
||||
@@ -248,6 +254,7 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
arcEntity.id = jsonEntity.id;
|
||||
arcEntity.lineColor = jsonEntity.lineColor;
|
||||
arcEntity.lineWidth = jsonEntity.lineWidth;
|
||||
arcEntity.lineDash = jsonEntity.lineDash;
|
||||
return arcEntity;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {Box, Point, Segment} from '@flatten-js/core';
|
||||
import {max, min} from 'es-toolkit/compat';
|
||||
import type {Shape, SnapPoint} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { Box, Point, Segment } from '@flatten-js/core';
|
||||
import { max, min } from 'es-toolkit/compat';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class ArrowHeadEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -138,6 +138,7 @@ export class ArrowHeadEntity implements Entity {
|
||||
type: EntityName.ArrowHead,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
p1: { x: this.p1.x, y: this.p1.y },
|
||||
@@ -160,6 +161,7 @@ export class ArrowHeadEntity implements Entity {
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash ?? [];
|
||||
return lineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {type Box, Circle, Point, type Segment} from '@flatten-js/core';
|
||||
import {type Shape, type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { type Box, Circle, Point, type Segment } from '@flatten-js/core';
|
||||
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class CircleEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -168,6 +168,7 @@ export class CircleEntity implements Entity {
|
||||
type: EntityName.Circle,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
center: { x: this.circle.center.x, y: this.circle.center.y },
|
||||
@@ -186,6 +187,7 @@ export class CircleEntity implements Entity {
|
||||
circleEntity.id = jsonEntity.id;
|
||||
circleEntity.lineColor = jsonEntity.lineColor;
|
||||
circleEntity.lineWidth = jsonEntity.lineWidth;
|
||||
circleEntity.lineDash = jsonEntity.lineDash;
|
||||
return circleEntity;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type {Box, Point, Segment} from '@flatten-js/core';
|
||||
import type {Shape, SnapPoint} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts';
|
||||
import type {ArcJsonData} from './ArcEntity';
|
||||
import type {ArrowHeadJsonData} from './ArrowHeadEntity.ts';
|
||||
import type {CircleJsonData} from './CircleEntity';
|
||||
import type {ImageJsonData} from './ImageEntity';
|
||||
import type {LineEntity, LineJsonData} from './LineEntity';
|
||||
import type {PointJsonData} from './PointEntity';
|
||||
import type {RectangleJsonData} from './RectangleEntity';
|
||||
import type {TextJsonData} from './TextEntity.ts';
|
||||
import type { Box, Point, Segment } from '@flatten-js/core';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts';
|
||||
import type { ArcJsonData } from './ArcEntity';
|
||||
import type { ArrowHeadJsonData } from './ArrowHeadEntity.ts';
|
||||
import type { CircleJsonData } from './CircleEntity';
|
||||
import type { ImageJsonData } from './ImageEntity';
|
||||
import type { LineEntity, LineJsonData } from './LineEntity';
|
||||
import type { PointJsonData } from './PointEntity';
|
||||
import type { RectangleJsonData } from './RectangleEntity';
|
||||
import type { TextJsonData } from './TextEntity.ts';
|
||||
|
||||
export interface Entity {
|
||||
// Random uuid generated when the Entity is created
|
||||
@@ -74,6 +74,7 @@ export interface JsonEntity<TShapeJsonData = ShapeJsonData> {
|
||||
type: EntityName;
|
||||
lineColor: string;
|
||||
lineWidth: number;
|
||||
lineDash?: number[];
|
||||
layerId: string;
|
||||
shapeData: TShapeJsonData | null;
|
||||
children?: JsonEntity<ShapeJsonData>[];
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type * as Flatten from '@flatten-js/core';
|
||||
import {type Box, Point, Polygon, Relations, type Segment, Vector} from '@flatten-js/core';
|
||||
import {type Shape, type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts';
|
||||
import {twoPointBoxToPolygon} from '../helpers/box-to-polygon';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {mirrorAngleOverAxis} from '../helpers/mirror-angle-over-axis.ts';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {polygonToSegments} from '../helpers/polygon-to-segments';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core';
|
||||
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts';
|
||||
import { twoPointBoxToPolygon } from '../helpers/box-to-polygon';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorAngleOverAxis } from '../helpers/mirror-angle-over-axis.ts';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { polygonToSegments } from '../helpers/polygon-to-segments';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class ImageEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -213,6 +213,7 @@ export class ImageEntity implements Entity {
|
||||
type: EntityName.Image,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
points: this.polygon.vertices.map((vertex) => ({
|
||||
@@ -241,6 +242,7 @@ export class ImageEntity implements Entity {
|
||||
rectangleEntity.id = jsonEntity.id;
|
||||
rectangleEntity.lineColor = jsonEntity.lineColor;
|
||||
rectangleEntity.lineWidth = jsonEntity.lineWidth;
|
||||
rectangleEntity.lineDash = jsonEntity.lineDash;
|
||||
return rectangleEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import {type Box, Point, Segment} from '@flatten-js/core';
|
||||
import {sortBy, uniqWith} from 'es-toolkit';
|
||||
import {type Shape, type SnapPoint, SnapPointType, type StartAndEndpointEntity} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {pointDistance} from '../helpers/distance-between-points';
|
||||
import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {isPointEqual} from '../helpers/is-point-equal';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import { type Box, Point, Segment } from '@flatten-js/core';
|
||||
import { sortBy, uniqWith } from 'es-toolkit';
|
||||
import {
|
||||
type Shape,
|
||||
type SnapPoint,
|
||||
SnapPointType,
|
||||
type StartAndEndpointEntity,
|
||||
} from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { isPointEqual } from '../helpers/is-point-equal';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
|
||||
export class LineEntity implements Entity, StartAndEndpointEntity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -174,6 +179,7 @@ export class LineEntity implements Entity, StartAndEndpointEntity {
|
||||
type: EntityName.Line,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
startPoint: {
|
||||
@@ -202,6 +208,7 @@ export class LineEntity implements Entity, StartAndEndpointEntity {
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash;
|
||||
return lineEntity;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Box, Line, Point, Segment, Vector} from '@flatten-js/core';
|
||||
import {minBy, round} from 'es-toolkit';
|
||||
import {max, min} from 'es-toolkit/compat';
|
||||
import { Box, Line, Point, Segment, Vector } from '@flatten-js/core';
|
||||
import { minBy, round } from 'es-toolkit';
|
||||
import { max, min } from 'es-toolkit/compat';
|
||||
import {
|
||||
ARROW_HEAD_LENGTH,
|
||||
ARROW_HEAD_WIDTH,
|
||||
@@ -12,15 +12,32 @@ import {
|
||||
MEASUREMENT_ORIGIN_MARGIN,
|
||||
TO_RADIANS,
|
||||
} from '../App.consts';
|
||||
import type {Shape, SnapPoint} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {pointDistance} from '../helpers/distance-between-points';
|
||||
import {isPointEqual} from '../helpers/is-point-equal';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
import { isPointEqual } from '../helpers/is-point-equal';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getScreenCanvasDrawController,
|
||||
isEntityHighlighted,
|
||||
isEntitySelected,
|
||||
} from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
/**
|
||||
* 치수 상수는 화면 픽셀 기준이므로 현재 줌 배율(px/world)로 나눠 세계좌표 길이로 바꾼다.
|
||||
* 컨트롤러가 아직 없는 환경(단위 테스트 등)에서는 1을 반환해 상수를 그대로 쓴다.
|
||||
*/
|
||||
function annotationWorldFactor(): number {
|
||||
try {
|
||||
return getScreenCanvasDrawController().getScreenScale() || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class MeasurementEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -77,30 +94,41 @@ export class MeasurementEntity implements Entity {
|
||||
.clone()
|
||||
.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
|
||||
|
||||
// Screen-pixel constants are converted to world units so annotation size stays zoom-independent
|
||||
const worldFactor = annotationWorldFactor();
|
||||
|
||||
// Start of the perpendicular lines
|
||||
const offsetStartPointMargin = this.startPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_ORIGIN_MARGIN / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
const offsetEndPointMargin = this.endPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_ORIGIN_MARGIN / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
// End of the perpendicular lines
|
||||
const offsetStartPointExtend = offsetStartPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_EXTENSION_LENGTH / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
const offsetEndPointExtend = offsetEndPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_EXTENSION_LENGTH / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
// Location for label
|
||||
@@ -108,8 +136,8 @@ export class MeasurementEntity implements Entity {
|
||||
(offsetStartPoint.x + offsetEndPoint.x) / 2,
|
||||
(offsetStartPoint.y + offsetEndPoint.y) / 2
|
||||
);
|
||||
const textHeight = MEASUREMENT_FONT_SIZE;
|
||||
const totalOffset = MEASUREMENT_LABEL_OFFSET + textHeight / 2;
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
const totalOffset = MEASUREMENT_LABEL_OFFSET / worldFactor + textHeight / 2;
|
||||
const midpointMeasurementLineOffset = midpointMeasurementLine
|
||||
.clone()
|
||||
.translate(vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(totalOffset));
|
||||
@@ -163,20 +191,21 @@ export class MeasurementEntity implements Entity {
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean
|
||||
): void => {
|
||||
const screenScale = drawController.getScreenScale();
|
||||
// Arrow heads keep a constant on-screen size: divide pixel constants by zoom (px/world)
|
||||
const worldFactor = drawController.getScreenScale() || 1;
|
||||
const vectorFromEndToStart = new Vector(endPoint, startPoint);
|
||||
const vectorFromEndToStartUnit = vectorFromEndToStart.normalize();
|
||||
const baseOfArrow = endPoint
|
||||
.clone()
|
||||
.translate(vectorFromEndToStartUnit.multiply(ARROW_HEAD_LENGTH * screenScale));
|
||||
.translate(vectorFromEndToStartUnit.multiply(ARROW_HEAD_LENGTH / worldFactor));
|
||||
const perpendicularVector1 = vectorFromEndToStartUnit.rotate(90 * TO_RADIANS);
|
||||
const perpendicularVector2 = vectorFromEndToStartUnit.rotate(-90 * TO_RADIANS);
|
||||
const leftCornerOfArrow = baseOfArrow
|
||||
.clone()
|
||||
.translate(perpendicularVector1.multiply(ARROW_HEAD_WIDTH * screenScale));
|
||||
.translate(perpendicularVector1.multiply(ARROW_HEAD_WIDTH / worldFactor));
|
||||
const rightCornerOfArrow = baseOfArrow
|
||||
.clone()
|
||||
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH * screenScale));
|
||||
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH / worldFactor));
|
||||
|
||||
drawController.setLineStyles(
|
||||
isHighlighted,
|
||||
@@ -268,7 +297,7 @@ export class MeasurementEntity implements Entity {
|
||||
drawController.drawText(distance, midpointMeasurementLineOffset, {
|
||||
textAlign: 'center',
|
||||
textDirection: finalTextDirection,
|
||||
fontSize: MEASUREMENT_FONT_SIZE,
|
||||
fontSize: MEASUREMENT_FONT_SIZE / (drawController.getScreenScale() || 1),
|
||||
textColor: this.lineColor,
|
||||
});
|
||||
}
|
||||
@@ -374,9 +403,10 @@ export class MeasurementEntity implements Entity {
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), MEASUREMENT_DECIMAL_PLACES)
|
||||
);
|
||||
const textHeight = MEASUREMENT_FONT_SIZE;
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
const textWidth = distance.length * MEASUREMENT_FONT_SIZE * 0.6;
|
||||
const textWidth = (distance.length * MEASUREMENT_FONT_SIZE * 0.6) / worldFactor;
|
||||
|
||||
const { midpointMeasurementLineOffset, normalUnit } = drawPoints;
|
||||
|
||||
@@ -532,6 +562,7 @@ export class MeasurementEntity implements Entity {
|
||||
type: EntityName.Measurement,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
startPoint: { x: this.startPoint.x, y: this.startPoint.y },
|
||||
@@ -565,6 +596,7 @@ export class MeasurementEntity implements Entity {
|
||||
measurementEntity.id = jsonEntity.id;
|
||||
measurementEntity.lineColor = jsonEntity.lineColor;
|
||||
measurementEntity.lineWidth = jsonEntity.lineWidth;
|
||||
measurementEntity.lineDash = jsonEntity.lineDash;
|
||||
return measurementEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type * as Flatten from '@flatten-js/core';
|
||||
import {Box, Point, type Segment} from '@flatten-js/core';
|
||||
import {type Shape, type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { Box, Point, type Segment } from '@flatten-js/core';
|
||||
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class PointEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -124,6 +124,7 @@ export class PointEntity implements Entity {
|
||||
type: EntityName.Point,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
point: {
|
||||
@@ -143,6 +144,7 @@ export class PointEntity implements Entity {
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash;
|
||||
return lineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type * as Flatten from '@flatten-js/core';
|
||||
import {Box, type Point, type Segment} from '@flatten-js/core';
|
||||
import {mapLimit} from 'blend-promise-utils';
|
||||
import {compact, maxBy} from 'es-toolkit';
|
||||
import {minBy} from 'es-toolkit/compat';
|
||||
import type {Shape, SnapPoint} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {ArcEntity, type ArcJsonData} from './ArcEntity.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import {LineEntity, type LineJsonData} from './LineEntity.ts';
|
||||
import { Box, type Point, type Segment } from '@flatten-js/core';
|
||||
import { mapLimit } from 'blend-promise-utils';
|
||||
import { compact, maxBy } from 'es-toolkit';
|
||||
import { minBy } from 'es-toolkit/compat';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { ArcEntity, type ArcJsonData } from './ArcEntity.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import { LineEntity, type LineJsonData } from './LineEntity.ts';
|
||||
|
||||
export class PolyLineEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -133,6 +133,7 @@ export class PolyLineEntity implements Entity {
|
||||
type: EntityName.PolyLine,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: null,
|
||||
children: compact(await mapLimit(this.entities, 20, (entity) => entity.toJson())),
|
||||
@@ -178,6 +179,7 @@ export class PolyLineEntity implements Entity {
|
||||
polyLineEntity.id = jsonEntity.id;
|
||||
polyLineEntity.lineColor = jsonEntity.lineColor;
|
||||
polyLineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
polyLineEntity.lineDash = jsonEntity.lineDash;
|
||||
return polyLineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type * as Flatten from '@flatten-js/core';
|
||||
import {type Box, Point, Polygon, Relations, type Segment, Vector} from '@flatten-js/core';
|
||||
import {type Shape, type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {DrawController} from '../drawControllers/DrawController';
|
||||
import {twoPointBoxToPolygon} from '../helpers/box-to-polygon';
|
||||
import {getExportColor} from '../helpers/get-export-color';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {polygonToSegments} from '../helpers/polygon-to-segments';
|
||||
import {scalePoint} from '../helpers/scale-point';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core';
|
||||
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { twoPointBoxToPolygon } from '../helpers/box-to-polygon';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { polygonToSegments } from '../helpers/polygon-to-segments';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export class RectangleEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
@@ -176,6 +176,7 @@ export class RectangleEntity implements Entity {
|
||||
type: EntityName.Rectangle,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
points: this.polygon.vertices.map((vertex) => ({
|
||||
@@ -202,6 +203,7 @@ export class RectangleEntity implements Entity {
|
||||
rectangleEntity.id = jsonEntity.id;
|
||||
rectangleEntity.lineColor = jsonEntity.lineColor;
|
||||
rectangleEntity.lineWidth = jsonEntity.lineWidth;
|
||||
rectangleEntity.lineDash = jsonEntity.lineDash;
|
||||
return rectangleEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {Box, Point, type Segment, Vector} from '@flatten-js/core';
|
||||
import {cloneDeep} from 'es-toolkit/compat';
|
||||
import type {Shape, SnapPoint} from '../App.types';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from '../drawControllers/DrawController';
|
||||
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
|
||||
import {scalePoint} from '../helpers/scale-point.ts';
|
||||
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {type Entity, EntityName, type JsonEntity} from './Entity';
|
||||
import type {LineEntity} from './LineEntity.ts';
|
||||
import { Box, Point, type Segment, Vector } from '@flatten-js/core';
|
||||
import { cloneDeep } from 'es-toolkit/compat';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
|
||||
import { scalePoint } from '../helpers/scale-point.ts';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity.ts';
|
||||
|
||||
export interface TextOptions {
|
||||
textDirection: Vector;
|
||||
@@ -101,6 +101,14 @@ export class TextEntity implements Entity {
|
||||
);
|
||||
}
|
||||
|
||||
public getTextOptions(): TextOptions {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
public setTextOptions(newOptions: Partial<Omit<TextOptions, 'textDirection'>>): void {
|
||||
Object.assign(this.options, newOptions);
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return null; // TODO see why we need to get the shape out of an entity
|
||||
}
|
||||
@@ -141,6 +149,7 @@ export class TextEntity implements Entity {
|
||||
type: EntityName.Text,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
label: this.label,
|
||||
@@ -181,6 +190,7 @@ export class TextEntity implements Entity {
|
||||
textEntity.id = jsonEntity.id;
|
||||
textEntity.lineColor = jsonEntity.lineColor;
|
||||
textEntity.lineWidth = jsonEntity.lineWidth;
|
||||
textEntity.lineDash = jsonEntity.lineDash ?? [];
|
||||
return textEntity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export enum StateVariable {
|
||||
lastDrawTimestamp = 'lastDrawTimestamp',
|
||||
activeLineColor = 'activeLineColor',
|
||||
activeLineWidth = 'activeLineWidth',
|
||||
activeLineDash = 'activeLineDash',
|
||||
activeTextStyle = 'activeTextStyle',
|
||||
layers = 'layers',
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -121,15 +121,29 @@ let hoveredSnapPoints: HoverPoint[] = [];
|
||||
let lastDrawTimestamp: DOMHighResTimeStamp = 0;
|
||||
|
||||
/**
|
||||
* Active line color
|
||||
* Active line color (7-char hex so <input type="color"> can consume it directly)
|
||||
*/
|
||||
let activeLineColor = '#fff';
|
||||
let activeLineColor = '#ffffff';
|
||||
|
||||
/**
|
||||
* Active line width
|
||||
*/
|
||||
let activeLineWidth = 1;
|
||||
|
||||
/**
|
||||
* Active line dash pattern (screen px). undefined → solid line
|
||||
*/
|
||||
let activeLineDash: number[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Active text style defaults applied to newly created text and selected text entities
|
||||
*/
|
||||
let activeTextStyle = {
|
||||
fontFamily: 'Noto Sans KR',
|
||||
fontSize: 16,
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Layers that can contain entities
|
||||
*/
|
||||
@@ -147,6 +161,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;
|
||||
@@ -167,6 +184,8 @@ export const getHoveredSnapPoints = () => hoveredSnapPoints;
|
||||
export const getLastDrawTimestamp = () => lastDrawTimestamp;
|
||||
export const getActiveLineColor = () => activeLineColor;
|
||||
export const getActiveLineWidth = () => activeLineWidth;
|
||||
export const getActiveLineDash = () => activeLineDash;
|
||||
export const getActiveTextStyle = () => activeTextStyle;
|
||||
export const getScreenCanvasDrawController = (): ScreenCanvasDrawController => {
|
||||
if (!screenCanvasDrawController) {
|
||||
throw new Error('getScreenCanvasDrawController() returned null');
|
||||
@@ -194,6 +213,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 +264,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;
|
||||
@@ -321,6 +347,23 @@ export const setActiveLineWidth = (newWidth: number, triggerReact = true) => {
|
||||
triggerReactUpdate(StateVariable.activeLineWidth);
|
||||
}
|
||||
};
|
||||
export const setActiveLineDash = (newDash: number[] | undefined, triggerReact = true) => {
|
||||
activeLineDash = newDash;
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.activeLineDash);
|
||||
}
|
||||
};
|
||||
export const setActiveTextStyle = (
|
||||
newStyle: Partial<typeof activeTextStyle>,
|
||||
triggerReact = true
|
||||
) => {
|
||||
activeTextStyle = { ...activeTextStyle, ...newStyle };
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.activeTextStyle);
|
||||
}
|
||||
};
|
||||
export const setLayers = (newLayers: Layer[], triggerReact = true) => {
|
||||
layers = newLayers;
|
||||
|
||||
@@ -335,6 +378,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[] => {
|
||||
@@ -355,6 +412,8 @@ const reactStateVariables: StateVariable[] = [
|
||||
StateVariable.angleStep,
|
||||
StateVariable.activeLineColor,
|
||||
StateVariable.activeLineWidth,
|
||||
StateVariable.activeLineDash,
|
||||
StateVariable.activeTextStyle,
|
||||
StateVariable.screenZoom,
|
||||
StateVariable.layers,
|
||||
];
|
||||
@@ -393,6 +452,7 @@ export function undo() {
|
||||
if (!undoState) return;
|
||||
|
||||
updateStates(undoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function redo() {
|
||||
@@ -400,6 +460,7 @@ export function redo() {
|
||||
if (!redoState) return;
|
||||
|
||||
updateStates(redoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function triggerReactUpdate(variable: StateVariable) {
|
||||
|
||||
@@ -1,161 +1,164 @@
|
||||
import {CircleEntity} from '../entities/CircleEntity';
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import { CircleEntity } from '../entities/CircleEntity';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import type {DrawEvent, PointInputEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {Tool} from '../tools';
|
||||
import {assign, createMachine} from 'xstate';
|
||||
import {pointDistance} from '../helpers/distance-between-points';
|
||||
import {LineState} from './line-tool.ts';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
import { LineState } from './line-tool.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface CircleContext extends ToolContext {
|
||||
centerPoint: Point | null;
|
||||
centerPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum CircleState {
|
||||
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
|
||||
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
|
||||
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
|
||||
INIT = 'INIT',
|
||||
}
|
||||
|
||||
export enum CircleAction {
|
||||
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
|
||||
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
|
||||
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
|
||||
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
|
||||
}
|
||||
|
||||
export const circleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: CircleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
centerPoint: null,
|
||||
type: Tool.CIRCLE,
|
||||
},
|
||||
initial: CircleState.INIT,
|
||||
states: {
|
||||
[CircleState.INIT]: {
|
||||
description: 'Initializing the circle tool',
|
||||
always: {
|
||||
actions: CircleAction.INIT_CIRCLE_TOOL,
|
||||
target: CircleState.WAITING_FOR_CENTER_POINT,
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_CENTER_POINT]: {
|
||||
description: 'Select the center point of the circle tool',
|
||||
meta: {
|
||||
instructions: 'Select the center point of the circle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
|
||||
description: 'Select a point on the circle',
|
||||
meta: {
|
||||
instructions: 'Select the point on the circle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: CircleAction.DRAW_TEMP_CIRCLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
centerPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
context.centerPoint as Point,
|
||||
),
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
setGhostHelperEntities([activeCircle]);
|
||||
},
|
||||
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
|
||||
if (!context.centerPoint) {
|
||||
throw new Error(
|
||||
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool',
|
||||
);
|
||||
}
|
||||
const pointOnCircle: Point = getPointFromEvent(
|
||||
context.centerPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(pointOnCircle, context.centerPoint as Point),
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
addEntities([activeCircle], true);
|
||||
{
|
||||
types: {} as {
|
||||
context: CircleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
centerPoint: null,
|
||||
type: Tool.CIRCLE,
|
||||
},
|
||||
initial: CircleState.INIT,
|
||||
states: {
|
||||
[CircleState.INIT]: {
|
||||
description: 'Initializing the circle tool',
|
||||
always: {
|
||||
actions: CircleAction.INIT_CIRCLE_TOOL,
|
||||
target: CircleState.WAITING_FOR_CENTER_POINT,
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_CENTER_POINT]: {
|
||||
description: 'Select the center point of the circle tool',
|
||||
meta: {
|
||||
instructions: 'Select the center point of the circle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.RECORD_START_POINT,
|
||||
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
|
||||
description: 'Select a point on the circle',
|
||||
meta: {
|
||||
instructions: 'Select the point on the circle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: CircleAction.DRAW_TEMP_CIRCLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: CircleAction.DRAW_FINAL_CIRCLE,
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: CircleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
centerPoint: startPoint,
|
||||
};
|
||||
}),
|
||||
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
context.centerPoint as Point
|
||||
)
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
activeCircle.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeCircle]);
|
||||
},
|
||||
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
|
||||
if (!context.centerPoint) {
|
||||
throw new Error(
|
||||
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool'
|
||||
);
|
||||
}
|
||||
const pointOnCircle: Point = getPointFromEvent(
|
||||
context.centerPoint,
|
||||
event as PointInputEvent
|
||||
);
|
||||
const activeCircle = new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
context.centerPoint as Point,
|
||||
pointDistance(pointOnCircle, context.centerPoint as Point)
|
||||
);
|
||||
activeCircle.lineColor = getActiveLineColor();
|
||||
activeCircle.lineWidth = getActiveLineWidth();
|
||||
activeCircle.lineDash = getActiveLineDash();
|
||||
addEntities([activeCircle], true);
|
||||
|
||||
setGhostHelperEntities([]);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
setGhostHelperEntities([]);
|
||||
return {
|
||||
centerPoint: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,171 +1,169 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineWidth,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {Actor, assign, createMachine} from 'xstate';
|
||||
import type {DrawEvent, PointInputEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {selectToolStateMachine} from './select-tool.ts';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
import { Tool } from '../tools';
|
||||
import { Actor, assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface LineContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum LineState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum LineAction {
|
||||
INIT_LINE_TOOL = 'INIT_LINE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
|
||||
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
INIT_LINE_TOOL = 'INIT_LINE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
|
||||
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
|
||||
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
|
||||
}
|
||||
|
||||
export const lineToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: LineContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.LINE,
|
||||
},
|
||||
initial: LineState.INIT,
|
||||
states: {
|
||||
[LineState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: LineAction.INIT_LINE_TOOL,
|
||||
target: LineState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: LineAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: LineAction.DRAW_TEMP_LINE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ENTER: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[LineAction.INIT_LINE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
setGhostHelperEntities([activeLine]);
|
||||
},
|
||||
[LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'Start point is not set during DRAW_FINAL_LINE in LineEntity',
|
||||
);
|
||||
}
|
||||
{
|
||||
types: {} as {
|
||||
context: LineContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.LINE,
|
||||
},
|
||||
initial: LineState.INIT,
|
||||
states: {
|
||||
[LineState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: LineAction.INIT_LINE_TOOL,
|
||||
target: LineState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the line',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.RECORD_START_POINT,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: LineAction.SWITCH_TO_SELECT_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[LineState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the line',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the line',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: LineAction.DRAW_TEMP_LINE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: LineAction.DRAW_FINAL_LINE,
|
||||
target: LineState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
ENTER: {
|
||||
target: LineState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[LineAction.INIT_LINE_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
activeLine.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeLine]);
|
||||
},
|
||||
[LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity');
|
||||
}
|
||||
|
||||
const endPoint = getPointFromEvent(
|
||||
context.startPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
addEntities([activeLine], true);
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
activeLine.lineDash = getActiveLineDash();
|
||||
addEntities([activeLine], true);
|
||||
|
||||
// Keep drawing from the last point
|
||||
setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
startPoint: endPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
},
|
||||
// Keep drawing from the last point
|
||||
setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
startPoint: endPoint,
|
||||
};
|
||||
}),
|
||||
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,226 +1,221 @@
|
||||
import {type Point, Vector} from '@flatten-js/core';
|
||||
import {MeasurementEntity} from '../entities/MeasurementEntity';
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import {Tool} from '../tools';
|
||||
import {assign, createMachine} from 'xstate';
|
||||
import type {DrawEvent, PointInputEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS} from '../App.consts';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
import {isPointEqual} from '../helpers/is-point-equal.ts';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal.ts';
|
||||
|
||||
export interface MeasurementContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
endPoint: Point | null;
|
||||
startPoint: Point | null;
|
||||
endPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum MeasurementState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
|
||||
}
|
||||
|
||||
export enum MeasurementAction {
|
||||
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
RECORD_END_POINT = 'RECORD_END_POINT',
|
||||
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
|
||||
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
|
||||
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
RECORD_END_POINT = 'RECORD_END_POINT',
|
||||
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
|
||||
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
|
||||
}
|
||||
|
||||
export const measurementToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MeasurementContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
type: Tool.MEASUREMENT,
|
||||
},
|
||||
initial: MeasurementState.INIT,
|
||||
states: {
|
||||
[MeasurementState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
|
||||
target: MeasurementState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the measurement',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the measurement',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_OFFSET]: {
|
||||
description: 'Select the offset to display the measurement at',
|
||||
meta: {
|
||||
instructions: 'Select the offset to display the measurement at',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
|
||||
const endPoint = getPointFromEvent(
|
||||
context.startPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
...context,
|
||||
endPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
|
||||
const startPoint = context.startPoint as Point;
|
||||
{
|
||||
types: {} as {
|
||||
context: MeasurementContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
type: Tool.MEASUREMENT,
|
||||
},
|
||||
initial: MeasurementState.INIT,
|
||||
states: {
|
||||
[MeasurementState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
|
||||
target: MeasurementState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the measurement',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the measurement',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_OFFSET]: {
|
||||
description: 'Select the offset to display the measurement at',
|
||||
meta: {
|
||||
instructions: 'Select the offset to display the measurement at',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
...context,
|
||||
endPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
|
||||
const startPoint = context.startPoint as Point;
|
||||
|
||||
let endPoint: Point;
|
||||
let offsetPoint: Point;
|
||||
if (!context.endPoint) {
|
||||
// User has drawn startPoint, but not yet endPoint
|
||||
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
|
||||
endPoint = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
let endPoint: Point;
|
||||
let offsetPoint: Point;
|
||||
if (!context.endPoint) {
|
||||
// User has drawn startPoint, but not yet endPoint
|
||||
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
|
||||
endPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
if (isPointEqual(startPoint, endPoint)) {
|
||||
return; // Cannot draw temp measurement when start and endpoint are equal
|
||||
}
|
||||
if (isPointEqual(startPoint, endPoint)) {
|
||||
return; // Cannot draw temp measurement when start and endpoint are equal
|
||||
}
|
||||
|
||||
const normalVector = new Vector(startPoint, endPoint)
|
||||
.rotate(-90 * TO_RADIANS)
|
||||
.normalize();
|
||||
offsetPoint = startPoint
|
||||
.clone()
|
||||
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET));
|
||||
} else {
|
||||
// User has already selected a startPoint and endPoint
|
||||
// The offsetPoint should be set to the mouse location
|
||||
endPoint = context.endPoint as Point;
|
||||
offsetPoint = (
|
||||
event as DrawEvent
|
||||
).drawController.getWorldMouseLocation();
|
||||
}
|
||||
const normalVector = new Vector(startPoint, endPoint)
|
||||
.rotate(-90 * TO_RADIANS)
|
||||
.normalize();
|
||||
// Pixel constant → world units so the default offset is zoom-independent
|
||||
const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1;
|
||||
offsetPoint = startPoint
|
||||
.clone()
|
||||
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor));
|
||||
} else {
|
||||
// User has already selected a startPoint and endPoint
|
||||
// The offsetPoint should be set to the mouse location
|
||||
endPoint = context.endPoint as Point;
|
||||
offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
offsetPoint,
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
setGhostHelperEntities([activeMeasurement]);
|
||||
},
|
||||
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
|
||||
const offsetPoint = getPointFromEvent(
|
||||
context.endPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
context.endPoint as Point,
|
||||
offsetPoint,
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
addEntities([activeMeasurement], true);
|
||||
},
|
||||
},
|
||||
},
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeMeasurement]);
|
||||
},
|
||||
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
|
||||
const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent);
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
context.endPoint as Point,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
addEntities([activeMeasurement], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,154 +1,152 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {RectangleEntity} from '../entities/RectangleEntity';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { RectangleEntity } from '../entities/RectangleEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import type {DrawEvent, PointInputEvent, StateEvent, ToolContext,} from './tool.types';
|
||||
import {Tool} from '../tools';
|
||||
import {assign, createMachine} from 'xstate';
|
||||
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
|
||||
export interface RectangleContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
startPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum RectangleState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum RectangleAction {
|
||||
INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE',
|
||||
DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE',
|
||||
INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE',
|
||||
DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE',
|
||||
}
|
||||
|
||||
export const rectangleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: RectangleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.RECTANGLE,
|
||||
},
|
||||
initial: RectangleState.INIT,
|
||||
states: {
|
||||
[RectangleState.INIT]: {
|
||||
description: 'Initializing the rectangle tool',
|
||||
always: {
|
||||
actions: RectangleAction.INIT_RECTANGLE_TOOL,
|
||||
target: RectangleState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: RectangleAction.DRAW_TEMP_RECTANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RectangleAction.INIT_RECTANGLE_TOOL]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RectangleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error(
|
||||
'[RECTANGLE]: calling draw without start point being set',
|
||||
);
|
||||
}
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation(),
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
setGhostHelperEntities([activeRectangle]);
|
||||
},
|
||||
[RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw Error(
|
||||
'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool',
|
||||
);
|
||||
}
|
||||
const endPoint = getPointFromEvent(
|
||||
context.startPoint,
|
||||
event as PointInputEvent,
|
||||
);
|
||||
{
|
||||
types: {} as {
|
||||
context: RectangleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.RECTANGLE,
|
||||
},
|
||||
initial: RectangleState.INIT,
|
||||
states: {
|
||||
[RectangleState.INIT]: {
|
||||
description: 'Initializing the rectangle tool',
|
||||
always: {
|
||||
actions: RectangleAction.INIT_RECTANGLE_TOOL,
|
||||
target: RectangleState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.RECORD_START_POINT,
|
||||
target: RectangleState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RectangleState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the rectangle',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the rectangle',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: RectangleAction.DRAW_TEMP_RECTANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: RectangleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RectangleAction.INIT_RECTANGLE_TOOL]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RectangleAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[RECTANGLE]: calling draw without start point being set');
|
||||
}
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
activeRectangle.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeRectangle]);
|
||||
},
|
||||
[RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw Error(
|
||||
'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool'
|
||||
);
|
||||
}
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
addEntities([activeRectangle], true);
|
||||
},
|
||||
},
|
||||
},
|
||||
const activeRectangle = new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint
|
||||
);
|
||||
activeRectangle.lineColor = getActiveLineColor();
|
||||
activeRectangle.lineWidth = getActiveLineWidth();
|
||||
activeRectangle.lineDash = getActiveLineDash();
|
||||
addEntities([activeRectangle], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user