Merge branch 'feature/B07-페이지-개발'

This commit is contained in:
2026-07-19 19:55:30 +09:00
36 changed files with 3222 additions and 1491 deletions
+25 -2
View File
@@ -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
+448
View File
@@ -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;
}
}
+1 -19
View File
@@ -4,26 +4,8 @@ import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div
className="overflow-y-scroll h-lvh pb-6 w-80 bg-slate-950"
style={{ scrollbarWidth: 'none' }}
>
<header className="px-3 py-3 border-b border-slate-700 text-white">
<strong className="block text-sm">Aislo 2D Drawing</strong>
<span className="text-xs text-slate-400">B07 </span>
</header>
<div className="cad-app">
<Toolbar />
<footer className="px-3 py-3 border-t border-slate-700 text-xs text-slate-400">
Drawing engine based on OpenWebCAD ·{' '}
<a
className="underline hover:text-white"
href="./THIRD_PARTY_LICENSES.txt"
target="_blank"
rel="noreferrer"
>
MIT License
</a>
</footer>
<ToastContainer position="bottom-right" theme="light" />
</div>
);
@@ -1,4 +1,4 @@
import type {Arc, Circle, Point, Polygon, Segment} from '@flatten-js/core';
import type { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core';
export type Shape = Polygon | Segment | Point | Circle | Arc;
@@ -35,6 +35,7 @@ export enum MouseButton {
export enum HtmlEvent {
UPDATE_STATE = 'UPDATE_STATE',
DRAWING_CHANGED = 'DRAWING_CHANGED',
}
export interface StateMetaData {
File diff suppressed because it is too large Load Diff
@@ -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);
}
+12 -17
View File
@@ -2,14 +2,12 @@ import { Point } from '@flatten-js/core';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Actor, type MachineSnapshot } from 'xstate';
import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE, TOOLBAR_WIDTH } from './App.consts';
import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE } from './App.consts';
import App from './App.tsx';
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import { draw } from './helpers/draw';
import { findClosestEntity } from './helpers/find-closest-entity';
import { getNewLayer } from './helpers/get-new-layer.ts';
import type { JsonDrawingFileDeserialized } from './helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromLocalStorage } from './helpers/import-export-handlers/import-entities-from-local-storage.ts';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
@@ -104,11 +102,14 @@ function startDrawLoop(
}
function handleWindowResize() {
getScreenCanvasDrawController().setCanvasSize(new Point(window.innerWidth, window.innerHeight));
const canvas = getCanvas();
if (canvas) {
canvas.width = window.innerWidth - TOOLBAR_WIDTH;
canvas.height = window.innerHeight;
const bounds = canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(bounds.width));
const height = Math.max(1, Math.round(bounds.height));
canvas.width = width;
canvas.height = height;
getScreenCanvasDrawController().setCanvasSize(new Point(width, height));
}
}
@@ -122,21 +123,15 @@ function initApplication() {
setEntities([], true); // Creates the first undo entry
// Load the last drawing from local storage
getEntitiesAndLayersFromLocalStorage().then((file: JsonDrawingFileDeserialized) => {
setEntities(file.entities, true);
let layers = file.layers;
if (layers.length === 0) {
layers = [getNewLayer()];
}
setLayers(layers);
setActiveLayerId(layers[0].id);
registerAisloDrawingBridge();
});
const layers = [getNewLayer()];
setLayers(layers);
setActiveLayerId(layers[0].id);
registerAisloDrawingBridge();
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
setScreenCanvasDrawController(screenCanvasDrawController);
window.addEventListener('resize', handleWindowResize);
new ResizeObserver(handleWindowResize).observe(canvas);
const inputController = new InputController();
setInputController(inputController);
+63 -2
View File
@@ -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);
},
},
}
);
+2
View File
@@ -33,6 +33,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router
from common_util.common_util_auth import require_company, verify_session
from common_util.common_util_resource_monitor import sample_resources_loop
from config.config_db import close_db_pool, get_db_pool, init_db_pool
@@ -274,6 +275,7 @@ app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
app.include_router(tiles_router, dependencies=protected_with_company)
app.include_router(b05_route_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
# ─────────────────────────────────────────────────────────────────────────
+14 -7
View File
@@ -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;
}