auto: 2026-08-29 16:13 (EOMSANGDON-HOME)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/* 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>;
|
||||
|
||||
/** 측구 형식별 규격 (B06 엔진 ditch_spec 신구조와 1:1). */
|
||||
export type DitchSpec =
|
||||
| { type: "none" }
|
||||
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
|
||||
| { type: "l_type"; width_m: number; depth_m: number };
|
||||
|
||||
/** B06에서 지정한 설계(지반정보·계획정보). 횡단도에만 존재. status로 잠정/확정 구분. */
|
||||
export interface CrossDesignInfo {
|
||||
ground_type: "soil" | "ripping_rock" | "blasting_rock";
|
||||
geometry_preset: "soil" | "rock";
|
||||
section_mode: "left_cut" | "right_cut" | "both_cut" | "both_fill";
|
||||
ditch_side: "left" | "right";
|
||||
ditch_type?: "standard" | "l_type" | null;
|
||||
ditch_enabled?: boolean;
|
||||
cut_slope_ratio: number;
|
||||
fill_slope_ratio: number;
|
||||
roadbed_width_m: number;
|
||||
carriageway_width_m?: number;
|
||||
cross_slope_pct?: number;
|
||||
paved?: boolean;
|
||||
ditch: DitchSpec;
|
||||
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
status?: "provisional" | "confirmed";
|
||||
}
|
||||
|
||||
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;
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
export interface DesignDrawingConfirmResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
id: string;
|
||||
confirmed: boolean;
|
||||
all_confirmed: boolean;
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
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,692 @@
|
||||
"""B07 CAD 도면 조립 엔진 — B06/B05 산출물을 openwebcad 엔티티로 직렬화한다.
|
||||
|
||||
납품 도면 양식 복제:
|
||||
- 종단도: 30측점 N분할 + 하단 측점 테이블(곡선/측점/거리/추가거리/지반고/
|
||||
계획고/절토고/성토고/구배). 곡선·구배 행은 B05 profile_alignment의
|
||||
종단곡선(curves)·구배(segments) 기하를 그대로 표기한다.
|
||||
- 횡단도: 지표/설계/구조물 레이어 분리 + 하단 수량 산출표(No.행, 지반고~
|
||||
절토고 헤더행, 깍기·측구·쌓기·층따기 / 면고르기·지장목제거·표토제거 /
|
||||
편책·성토파종·절토살포·제근·노면다짐 3그룹 병합 셀 그리드).
|
||||
|
||||
모든 값 텍스트는 잠금 해제 레이어의 Text 엔티티라 CAD에서 직접 수정할 수
|
||||
있고, id가 결정적(uuid5)이라 확정 시 도면 JSON에서 수량표 값을 역추출한다.
|
||||
|
||||
좌표 규약: 종단도 x=chainage_m, 횡단도 x=offset_m(+좌/-우), y=elevation_m.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3).
|
||||
GROUND_LAYER_ID = "b08-ground"
|
||||
GROUND_COLOR = "#f5f7fa"
|
||||
DESIGN_LAYER_ID = "b08-design"
|
||||
DESIGN_COLOR = "#b794f6"
|
||||
STRUCTURE_LAYER_ID = "b08-structure"
|
||||
STRUCTURE_COLOR = "#f6d55c"
|
||||
ROCK_LAYER_ID = "b08-rock-boundary"
|
||||
ROCK_COLOR = "#f59e0b"
|
||||
FRAME_LAYER_ID = "b08-frame"
|
||||
LONG_TABLE_LAYER_ID = "b08-long-table"
|
||||
CROSS_TABLE_LAYER_ID = "b08-cross-table"
|
||||
TABLE_LINE_COLOR = "#8ea0b5"
|
||||
TABLE_LABEL_COLOR = "#e8edf4"
|
||||
TABLE_VALUE_COLOR = "#ffe066"
|
||||
|
||||
# 종단도 분할 기준: 측점 30개 초과 시 30개 단위(경계 1측점 중복)로 나눈다.
|
||||
LONG_SPLIT_STATION_COUNT = 30
|
||||
|
||||
_ENTITY_NS = UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96")
|
||||
_POLY_NS = UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8")
|
||||
|
||||
# 도면 직렬화 포맷 버전. 테이블·레이어 구성 변경 시 올린다 — 확정 저장본이
|
||||
# 이 버전과 다르면 캐시를 버리고 원본에서 재생성한다(구양식 서빙 방지).
|
||||
# v3: 횡단 로컬좌표(계획고=0) 정규화 + 공통 프레임 + 암 경계선 레이어.
|
||||
# v4: 종단 그래프 축·회색 측점 세로선·기준선(datum) + 테이블 눈금·세로쓰기·구배 원 표기.
|
||||
# v5: 횡단 콘텐츠 bbox 중심 정렬(경사 드리프트 제거) + 외곽 테두리 제거.
|
||||
# v6: 외곽 테두리 복구 (콘텐츠 중심 정렬 유지, 노선 공통 크기 사각형).
|
||||
# v7: 종단도 A1 도각 템플릿 프레임 병합 (b08-frame 잠금 레이어).
|
||||
DRAWING_FORMAT = 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 엔티티 직렬화 헬퍼
|
||||
# ---------------------------------------------------------------------------
|
||||
def _line_entity(
|
||||
seed: str,
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float],
|
||||
layer_id: str,
|
||||
color: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(uuid5(_ENTITY_NS, seed)),
|
||||
"type": "Line",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": layer_id,
|
||||
"shapeData": {
|
||||
"startPoint": {"x": start[0], "y": start[1]},
|
||||
"endPoint": {"x": end[0], "y": end[1]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def polyline_entity(
|
||||
drawing_id: str,
|
||||
points: list[tuple[float, float]],
|
||||
layer_id: str,
|
||||
color: str,
|
||||
suffix: str = "",
|
||||
dash: list[int] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None)."""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
seed_base = f"{drawing_id}:{layer_id}{suffix}"
|
||||
children = []
|
||||
for index in range(len(points) - 1):
|
||||
child = _line_entity(
|
||||
f"{seed_base}:{index}", points[index], points[index + 1], layer_id, color
|
||||
)
|
||||
if dash:
|
||||
child["lineDash"] = dash
|
||||
children.append(child)
|
||||
poly: dict[str, Any] = {
|
||||
"id": str(uuid5(_POLY_NS, seed_base)),
|
||||
"type": "PolyLine",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": layer_id,
|
||||
"shapeData": None,
|
||||
"children": children,
|
||||
}
|
||||
if dash:
|
||||
poly["lineDash"] = dash
|
||||
return poly
|
||||
|
||||
|
||||
def _text_entity(
|
||||
seed: str,
|
||||
label: str,
|
||||
x: float,
|
||||
y: float,
|
||||
layer_id: str,
|
||||
font_size: float,
|
||||
color: str,
|
||||
align: str = "center",
|
||||
direction: tuple[float, float] = (1.0, 0.0),
|
||||
) -> dict[str, Any]:
|
||||
"""direction=(0,1)이면 세로쓰기(아래→위) — 납품 종단 테이블 값 표기."""
|
||||
return {
|
||||
"id": str(uuid5(_ENTITY_NS, seed)),
|
||||
"type": "Text",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": layer_id,
|
||||
"shapeData": {
|
||||
"label": label,
|
||||
"basePoint": {"x": x, "y": y},
|
||||
"options": {
|
||||
"textDirection": {"x": direction[0], "y": direction[1]},
|
||||
"textAlign": align,
|
||||
"textColor": color,
|
||||
"fontSize": font_size,
|
||||
"fontFamily": "sans-serif",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _layer(layer_id: str, name: str, locked: bool = False) -> dict[str, Any]:
|
||||
return {"id": layer_id, "name": name, "isVisible": True, "isLocked": locked}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 공용 데이터 헬퍼
|
||||
# ---------------------------------------------------------------------------
|
||||
def points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]:
|
||||
"""유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m)."""
|
||||
points: list[tuple[float, float]] = []
|
||||
for sample in 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)))
|
||||
return points
|
||||
|
||||
|
||||
def _design_profile_points(longitudinal: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
if not isinstance(profiles, list) or not profiles:
|
||||
return []
|
||||
points: list[tuple[float, float]] = []
|
||||
for point in profiles[0].get("samples", []):
|
||||
if not isinstance(point, dict):
|
||||
continue
|
||||
x = point.get("chainage_m")
|
||||
y = point.get("elevation_m")
|
||||
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
||||
points.append((float(x), float(y)))
|
||||
return points
|
||||
|
||||
|
||||
def _interpolate(points: list[tuple[float, float]], x: float) -> float | None:
|
||||
"""정렬 점열의 선형 보간(범위 밖 끝값 클램프). 점이 없으면 None."""
|
||||
if not points:
|
||||
return None
|
||||
if x <= points[0][0]:
|
||||
return points[0][1]
|
||||
if x >= points[-1][0]:
|
||||
return points[-1][1]
|
||||
for index in range(1, len(points)):
|
||||
x1, y1 = points[index]
|
||||
if x > x1:
|
||||
continue
|
||||
x0, y0 = points[index - 1]
|
||||
span = x1 - x0
|
||||
if span <= 0:
|
||||
return y1
|
||||
return y0 + (y1 - y0) * (x - x0) / span
|
||||
return points[-1][1]
|
||||
|
||||
|
||||
def _stations(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
stations = longitudinal.get("stations")
|
||||
if not isinstance(stations, list):
|
||||
return []
|
||||
return [
|
||||
station
|
||||
for station in stations
|
||||
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
|
||||
]
|
||||
|
||||
|
||||
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
|
||||
"""연속 chainage 차이의 최빈값으로 측점 간격을 추정한다 (No. 표기·칸 폭 기준)."""
|
||||
counts: dict[float, int] = {}
|
||||
chainages = sorted(
|
||||
float(s["chainage_m"])
|
||||
for s in stations
|
||||
if isinstance(s, dict) and isinstance(s.get("chainage_m"), (int, float))
|
||||
)
|
||||
for index in range(1, len(chainages)):
|
||||
difference = round(chainages[index] - chainages[index - 1], 1)
|
||||
if difference > 0:
|
||||
counts[difference] = counts.get(difference, 0) + 1
|
||||
if not counts:
|
||||
return 20.0
|
||||
return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0]
|
||||
|
||||
|
||||
def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
"""납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리)."""
|
||||
safe = interval_m if interval_m > 0 else 1.0
|
||||
number = int((chainage_m + 1e-6) // safe)
|
||||
remainder = chainage_m - number * safe
|
||||
if remainder >= safe - 0.05:
|
||||
number += 1
|
||||
remainder = 0.0
|
||||
if abs(remainder) < 0.05:
|
||||
return f"No.{number}"
|
||||
return f"No.{number}+{remainder:.1f}"
|
||||
|
||||
|
||||
def _profile_alignment(longitudinal: dict[str, Any]) -> dict[str, Any]:
|
||||
alignment = longitudinal.get("profile_alignment")
|
||||
return alignment if isinstance(alignment, dict) else {}
|
||||
|
||||
|
||||
def _format(value: float | None, decimals: int = 2) -> str:
|
||||
return "" if value is None else f"{value:.{decimals}f}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 횡단도: 레이어 분리 + 수량 산출표 (납품 양식)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _ditch_bounds(design: dict[str, Any] | None) -> tuple[float, float] | None:
|
||||
"""설계 dict에서 측구(구조물) 오프셋 구간 [lo, hi]를 구한다. 없으면 None."""
|
||||
if not isinstance(design, dict) or not design.get("ditch_enabled"):
|
||||
return None
|
||||
ditch = design.get("ditch")
|
||||
edges = design.get("road_edges")
|
||||
side = design.get("ditch_side")
|
||||
if not isinstance(ditch, dict) or not isinstance(edges, dict) or side not in ("left", "right"):
|
||||
return None
|
||||
ditch_type = ditch.get("type")
|
||||
if ditch_type == "standard":
|
||||
width = ditch.get("top_width_m")
|
||||
elif ditch_type == "l_type":
|
||||
width = ditch.get("width_m")
|
||||
else:
|
||||
return None
|
||||
edge = edges.get(side)
|
||||
if not isinstance(edge, dict) or not isinstance(width, (int, float)):
|
||||
return None
|
||||
inner = edge.get("offset_m")
|
||||
if not isinstance(inner, (int, float)):
|
||||
return None
|
||||
outer = float(inner) + (float(width) if side == "left" else -float(width))
|
||||
return (min(float(inner), outer), max(float(inner), outer))
|
||||
|
||||
|
||||
def _cross_line_entities(
|
||||
drawing_id: str,
|
||||
design_line: list[Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
dy: float = 0.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""횡단 설계선을 설계(b08-design)/구조물(b08-structure, 측구 구간)로 분리한다."""
|
||||
points: list[tuple[float, float]] = []
|
||||
for point in design_line if isinstance(design_line, list) else []:
|
||||
if not isinstance(point, dict):
|
||||
continue
|
||||
x = point.get("offset_m")
|
||||
y = point.get("elevation_m")
|
||||
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
||||
points.append((float(x), float(y) - dy))
|
||||
if len(points) < 2:
|
||||
return []
|
||||
|
||||
bounds = _ditch_bounds(design)
|
||||
entities: list[dict[str, Any]] = []
|
||||
if bounds is None:
|
||||
design_poly = polyline_entity(drawing_id, points, DESIGN_LAYER_ID, DESIGN_COLOR)
|
||||
return [design_poly] if design_poly else []
|
||||
|
||||
lo, hi = bounds
|
||||
tolerance = 1e-6
|
||||
before = [p for p in points if p[0] <= lo + tolerance]
|
||||
ditch_part = [p for p in points if lo - tolerance <= p[0] <= hi + tolerance]
|
||||
after = [p for p in points if p[0] >= hi - tolerance]
|
||||
for suffix, part in ((":a", before), (":b", after)):
|
||||
poly = polyline_entity(drawing_id, part, DESIGN_LAYER_ID, DESIGN_COLOR, suffix)
|
||||
if poly:
|
||||
entities.append(poly)
|
||||
structure = polyline_entity(drawing_id, ditch_part, STRUCTURE_LAYER_ID, STRUCTURE_COLOR)
|
||||
if structure:
|
||||
entities.append(structure)
|
||||
return entities
|
||||
|
||||
|
||||
# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹.
|
||||
# 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기
|
||||
# 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토)
|
||||
# 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐
|
||||
_CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = (
|
||||
("지반고", "ground"),
|
||||
("계획고", "planned"),
|
||||
("성토고", "fill"),
|
||||
("절토고", "cut"),
|
||||
)
|
||||
# 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로.
|
||||
_CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = (
|
||||
("깍기", "토사", "cut_soil"),
|
||||
(None, "암석", "cut_rock"),
|
||||
("측구", "토사", "ditch_soil"),
|
||||
(None, "암석", "ditch_rock"),
|
||||
("쌓기", None, "embankment"),
|
||||
("층따기", None, "benching"),
|
||||
)
|
||||
_CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = (
|
||||
("면고르기", "성토", "grading_fill"),
|
||||
(None, "절토", "grading_cut"),
|
||||
("지장목제거", "성토", "tree_removal_fill"),
|
||||
(None, "절토", "tree_removal_cut"),
|
||||
("표토제거", "성토", "topsoil_fill"),
|
||||
(None, "절토", "topsoil_cut"),
|
||||
)
|
||||
_CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
|
||||
("편책", "fence"),
|
||||
("성토파종", "fill_seeding"),
|
||||
("절토살포", "cut_spraying"),
|
||||
("제근", "grubbing"),
|
||||
(None, None),
|
||||
("노면다짐", "road_compaction"),
|
||||
)
|
||||
|
||||
QUANTITY_VALUE_KEYS: tuple[str, ...] = (
|
||||
"ground",
|
||||
"planned",
|
||||
"fill",
|
||||
"cut",
|
||||
"cut_soil",
|
||||
"cut_rock",
|
||||
"ditch_soil",
|
||||
"ditch_rock",
|
||||
"embankment",
|
||||
"benching",
|
||||
"grading_fill",
|
||||
"grading_cut",
|
||||
"tree_removal_fill",
|
||||
"tree_removal_cut",
|
||||
"topsoil_fill",
|
||||
"topsoil_cut",
|
||||
"fence",
|
||||
"fill_seeding",
|
||||
"cut_spraying",
|
||||
"grubbing",
|
||||
"road_compaction",
|
||||
)
|
||||
|
||||
_CROSS_TABLE_WIDTH = 26.0
|
||||
_CROSS_TABLE_ROW_HEIGHT = 1.6
|
||||
_CROSS_TABLE_FONT = 0.55
|
||||
|
||||
# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백)
|
||||
_CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0)
|
||||
|
||||
|
||||
def _cross_column_edges(width: float) -> list[float]:
|
||||
total = sum(_CROSS_COLUMN_WEIGHTS)
|
||||
left = -width / 2.0
|
||||
edges = [left]
|
||||
accumulated = 0.0
|
||||
for weight in _CROSS_COLUMN_WEIGHTS:
|
||||
accumulated += weight
|
||||
edges.append(left + width * accumulated / total)
|
||||
return edges
|
||||
|
||||
|
||||
def _cross_table_entities(
|
||||
drawing_id: str,
|
||||
quantity_table: dict[str, float | None],
|
||||
table_top: float,
|
||||
title_label: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다."""
|
||||
width = _CROSS_TABLE_WIDTH
|
||||
row_h = _CROSS_TABLE_ROW_HEIGHT
|
||||
font = _CROSS_TABLE_FONT
|
||||
left = -width / 2.0
|
||||
right = width / 2.0
|
||||
edges = _cross_column_edges(width)
|
||||
entities: list[dict[str, Any]] = []
|
||||
|
||||
def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]:
|
||||
value = quantity_table.get(seed_key)
|
||||
return _text_entity(
|
||||
f"{drawing_id}:qtable:{seed_key}",
|
||||
_format(value) if isinstance(value, (int, float)) else "-",
|
||||
x,
|
||||
y,
|
||||
CROSS_TABLE_LAYER_ID,
|
||||
font,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
|
||||
def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]:
|
||||
return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR)
|
||||
|
||||
def h_line(seed: str, x_from: float, x_to: float, y: float) -> None:
|
||||
entities.append(
|
||||
_line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
|
||||
)
|
||||
|
||||
def v_line(seed: str, x: float, y_from: float, y_to: float) -> None:
|
||||
entities.append(
|
||||
_line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
|
||||
)
|
||||
|
||||
# ── 행 y 좌표 (제목행, 헤더행, 본문 6행)
|
||||
y_title_top = table_top
|
||||
y_header_top = y_title_top - row_h
|
||||
y_body_top = y_header_top - row_h
|
||||
body_rows = len(_CROSS_LEFT_ROWS)
|
||||
y_bottom = y_body_top - row_h * body_rows
|
||||
|
||||
def body_y(row_index: int) -> float:
|
||||
return y_body_top - row_h * row_index
|
||||
|
||||
# ── 외곽/가로선
|
||||
h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top)
|
||||
h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top)
|
||||
h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top)
|
||||
h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom)
|
||||
v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom)
|
||||
v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom)
|
||||
|
||||
# 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다.
|
||||
for boundary in range(1, body_rows):
|
||||
y = body_y(boundary)
|
||||
left_merged = _CROSS_LEFT_ROWS[boundary][0] is None
|
||||
middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None
|
||||
seed = f"{drawing_id}:qgrid:b{boundary}"
|
||||
if left_merged:
|
||||
h_line(f"{seed}:l", edges[1], edges[4], y)
|
||||
else:
|
||||
h_line(f"{seed}:l", edges[0], edges[4], y)
|
||||
if middle_merged:
|
||||
h_line(f"{seed}:m", edges[5], edges[8], y)
|
||||
else:
|
||||
h_line(f"{seed}:m", edges[4], edges[8], y)
|
||||
h_line(f"{seed}:r", edges[8], edges[11], y)
|
||||
|
||||
# ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할
|
||||
header_cell = width / 8.0
|
||||
for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS):
|
||||
x_label = left + header_cell * (pair_index * 2 + 0.5)
|
||||
x_value = left + header_cell * (pair_index * 2 + 1.5)
|
||||
y_mid = y_header_top - row_h * 0.5
|
||||
entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid))
|
||||
entities.append(value_text(key, x_value, y_mid))
|
||||
if pair_index > 0:
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:hv{pair_index}",
|
||||
left + header_cell * pair_index * 2,
|
||||
y_header_top,
|
||||
y_body_top,
|
||||
)
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:hvl{pair_index}",
|
||||
left + header_cell * (pair_index * 2 + 1),
|
||||
y_header_top,
|
||||
y_body_top,
|
||||
)
|
||||
|
||||
# ── 제목행 (No.측점)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:qtitle",
|
||||
title_label,
|
||||
left + width * 0.03,
|
||||
y_title_top - row_h * 0.5,
|
||||
CROSS_TABLE_LAYER_ID,
|
||||
font * 1.15,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="left",
|
||||
)
|
||||
)
|
||||
|
||||
# ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만.
|
||||
ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None]
|
||||
if ab_rows:
|
||||
v_line(
|
||||
f"{drawing_id}:qgrid:vab",
|
||||
edges[1],
|
||||
body_y(min(ab_rows)),
|
||||
body_y(max(ab_rows) + 1),
|
||||
)
|
||||
for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10):
|
||||
v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom)
|
||||
|
||||
# ── 본문 셀 (좌/중/우 그룹)
|
||||
def cell_mid(edge_from: int, edge_to: int) -> float:
|
||||
return (edges[edge_from] + edges[edge_to]) / 2.0
|
||||
|
||||
for row_index in range(body_rows):
|
||||
y_mid = body_y(row_index) - row_h * 0.5
|
||||
group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index]
|
||||
if group_l is not None and sub_l is not None:
|
||||
# 그룹 라벨은 2행 병합 중앙 배치
|
||||
y_group = body_y(row_index) - row_h # 병합 2행의 중앙
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group)
|
||||
)
|
||||
elif group_l is not None:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid)
|
||||
)
|
||||
if sub_l is not None:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid)
|
||||
)
|
||||
if key_l is not None:
|
||||
entities.append(value_text(key_l, cell_mid(2, 3), y_mid))
|
||||
|
||||
group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index]
|
||||
if group_m is not None:
|
||||
y_group = body_y(row_index) - row_h
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group)
|
||||
)
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid)
|
||||
)
|
||||
entities.append(value_text(key_m, cell_mid(6, 7), y_mid))
|
||||
|
||||
label_r, key_r = _CROSS_RIGHT_ROWS[row_index]
|
||||
if label_r is not None:
|
||||
entities.append(
|
||||
label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid)
|
||||
)
|
||||
if key_r is not None:
|
||||
entities.append(value_text(key_r, cell_mid(9, 10), y_mid))
|
||||
return entities
|
||||
|
||||
|
||||
def build_cross_drawing(
|
||||
source: dict[str, Any],
|
||||
drawing_id: str,
|
||||
design_line: list[Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
quantity_table: dict[str, float | None] | None,
|
||||
title_label: str,
|
||||
design_elevation_m: float | None = None,
|
||||
frame: dict[str, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
|
||||
|
||||
로컬 좌표 정규화: 계획고(design_elevation_m)를 y=0으로 두어 모든 측점
|
||||
도면이 같은 화면 배치를 갖는다.
|
||||
|
||||
배치 규약: 이 단면의 선(지표+설계+암 경계) 전체 bbox 중심을 y=0에 두어
|
||||
측점마다 콘텐츠가 화면 중앙에 온다(종단 경사 드리프트 제거). 테이블은
|
||||
frame(노선 공통 최대 반높이 half_height)이 오면 전 측점 동일 y에 고정해
|
||||
Fit-in-all 배율·중심이 측점 간 흔들리지 않게 한다. design_elevation_m는
|
||||
현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
|
||||
"""
|
||||
raw_ground = points_from_samples(source.get("samples", []), "offset_m")
|
||||
raw_design: list[tuple[float, float]] = []
|
||||
for point in design_line if isinstance(design_line, list) else []:
|
||||
if not isinstance(point, dict):
|
||||
continue
|
||||
x = point.get("offset_m")
|
||||
y = point.get("elevation_m")
|
||||
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
||||
raw_design.append((float(x), float(y)))
|
||||
rock_offset = design.get("rock_boundary_offset_m") if isinstance(design, dict) else None
|
||||
|
||||
# 콘텐츠 bbox: 지표선 + 설계선 + 암 경계선(지표+오프셋) 표고 범위.
|
||||
all_ys = [y for _x, y in raw_ground] + [y for _x, y in raw_design]
|
||||
if isinstance(rock_offset, (int, float)):
|
||||
all_ys.extend(y + float(rock_offset) for _x, y in raw_ground)
|
||||
dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0
|
||||
own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0
|
||||
|
||||
ground_points = [(x, y - dy) for x, y in raw_ground]
|
||||
entities: list[dict[str, Any]] = []
|
||||
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
|
||||
if ground:
|
||||
entities.append(ground)
|
||||
entities.extend(_cross_line_entities(drawing_id, design_line, design, dy))
|
||||
|
||||
# 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재.
|
||||
if isinstance(rock_offset, (int, float)) and ground_points:
|
||||
rock_points = [(x, y + float(rock_offset)) for x, y in ground_points]
|
||||
rock = polyline_entity(drawing_id, rock_points, ROCK_LAYER_ID, ROCK_COLOR, dash=[6, 4])
|
||||
if rock:
|
||||
entities.append(rock)
|
||||
|
||||
# 테이블 상단: 노선 공통 최대 반높이 아래 고정(전 측점 동일). frame 없으면 자체 폴백.
|
||||
half_height = (
|
||||
frame["half_height"] if frame and "half_height" in frame else own_half_height + 1.0
|
||||
)
|
||||
table_bottom = -half_height
|
||||
if quantity_table is not None:
|
||||
table_top = -half_height - 2.0
|
||||
entities.extend(_cross_table_entities(drawing_id, quantity_table, table_top, title_label))
|
||||
table_bottom = table_top - _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)
|
||||
|
||||
# 외곽 테두리: 전 측점 동일 크기 사각형(노선 공통 half/half_height 기준) —
|
||||
# Fit-in-all 바운딩박스를 고정해 측점 이동 시 배율·중심이 흔들리지 않는다.
|
||||
half = frame["half"] if frame else max((abs(x) for x, _y in ground_points), default=12.0)
|
||||
frame_x = max(half + 2.0, _CROSS_TABLE_WIDTH / 2.0 + 1.0)
|
||||
frame_top = half_height + 1.0
|
||||
frame_bottom = table_bottom - 1.0
|
||||
corners = [
|
||||
(-frame_x, frame_bottom),
|
||||
(frame_x, frame_bottom),
|
||||
(frame_x, frame_top),
|
||||
(-frame_x, frame_top),
|
||||
(-frame_x, frame_bottom),
|
||||
]
|
||||
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
|
||||
if border:
|
||||
entities.append(border)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
|
||||
_layer(DESIGN_LAYER_ID, "Design Plan"),
|
||||
_layer(STRUCTURE_LAYER_ID, "Structure"),
|
||||
_layer(ROCK_LAYER_ID, "Rock Boundary"),
|
||||
_layer(CROSS_TABLE_LAYER_ID, "Quantity Table"),
|
||||
_layer(FRAME_LAYER_ID, "Frame", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def extract_quantity_table(
|
||||
drawing_id: str, drawing: dict[str, Any]
|
||||
) -> dict[str, float | None] | None:
|
||||
"""확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다.
|
||||
|
||||
값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을
|
||||
반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다.
|
||||
"""
|
||||
id_to_key = {
|
||||
str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS
|
||||
}
|
||||
entities = drawing.get("entities")
|
||||
if not isinstance(entities, list):
|
||||
return None
|
||||
table: dict[str, float | None] = {}
|
||||
found = False
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
key = id_to_key.get(str(entity.get("id")))
|
||||
if key is None:
|
||||
continue
|
||||
found = True
|
||||
shape = entity.get("shapeData")
|
||||
label = shape.get("label") if isinstance(shape, dict) else None
|
||||
try:
|
||||
table[key] = float(str(label).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
table[key] = None
|
||||
if not found:
|
||||
return None
|
||||
for key in QUANTITY_VALUE_KEYS:
|
||||
table.setdefault(key, None)
|
||||
ground = table.get("ground")
|
||||
planned = table.get("planned")
|
||||
if isinstance(ground, (int, float)) and isinstance(planned, (int, float)):
|
||||
table["cut"] = max(ground - planned, 0.0)
|
||||
table["fill"] = max(planned - ground, 0.0)
|
||||
return table
|
||||
@@ -0,0 +1,660 @@
|
||||
"""B07 종단도 CAD 조립 — 30측점 N분할 + 그래프 축·격자 + 하단 측점 테이블.
|
||||
|
||||
납품 양식:
|
||||
- 그래프 영역: 좌측 Y축(표고 눈금·라벨), 기준선(X축, 최저 표고에서 5m 이상
|
||||
여유), 측점별 회색 세로선.
|
||||
- 테이블 영역: 측점 세로선 없이 가로 구분선 위 눈금(틱)으로 측점 표현,
|
||||
값 텍스트는 세로쓰기. 행: 곡선/측점/거리/추가거리/지반고/계획고/절토고/
|
||||
성토고/구배.
|
||||
- 곡선행: 종단곡선 BVC·EVC 세로틱 + 수평선 + R/L 표기(브래킷형).
|
||||
- 구배행: 구간 사선(상향/하향) + 가로 "구배% L=길이" + 구배 변화점에
|
||||
원(내부 세로쓰기 계획고), 노선 시·종점은 반원.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from uuid import uuid5
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_ENTITY_NS,
|
||||
DESIGN_COLOR,
|
||||
DESIGN_LAYER_ID,
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
GROUND_COLOR,
|
||||
GROUND_LAYER_ID,
|
||||
LONG_SPLIT_STATION_COUNT,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LABEL_COLOR,
|
||||
TABLE_LINE_COLOR,
|
||||
TABLE_VALUE_COLOR,
|
||||
_design_profile_points,
|
||||
_format,
|
||||
_interpolate,
|
||||
_layer,
|
||||
_line_entity,
|
||||
_profile_alignment,
|
||||
_stations,
|
||||
_text_entity,
|
||||
infer_station_interval,
|
||||
points_from_samples,
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
|
||||
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
|
||||
LONG_GRID_LAYER_ID = "b08-long-grid"
|
||||
GRID_COLOR = "#5b6572"
|
||||
|
||||
_VERTICAL = (0.0, 1.0) # 세로쓰기(아래→위)
|
||||
|
||||
# 테이블 행: (키, 헤더 라벨, 행 높이 m). 값 세로쓰기 행은 높게 잡는다.
|
||||
_ROW_TALL = 9.0
|
||||
_LONG_TABLE_ROWS: tuple[tuple[str, str, float], ...] = (
|
||||
("curve", "곡선", 5.0),
|
||||
("station", "측점", 7.0),
|
||||
("distance", "거리", 4.5),
|
||||
("cum_distance", "추가거리", _ROW_TALL),
|
||||
("ground", "지반고", _ROW_TALL),
|
||||
("design", "계획고", _ROW_TALL),
|
||||
("cut", "절토고", _ROW_TALL),
|
||||
("fill", "성토고", _ROW_TALL),
|
||||
("grade", "구배", 10.0),
|
||||
)
|
||||
_FONT_SIZE = 2.2
|
||||
_TICK_LEN = 0.8 # 측점 눈금 길이
|
||||
|
||||
|
||||
def _circle_entity(
|
||||
seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(uuid5(_ENTITY_NS, seed)),
|
||||
"type": "Circle",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": layer_id,
|
||||
"shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius},
|
||||
}
|
||||
|
||||
|
||||
def _arc_entity(
|
||||
seed: str,
|
||||
center: tuple[float, float],
|
||||
radius: float,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer_id: str,
|
||||
color: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(uuid5(_ENTITY_NS, seed)),
|
||||
"type": "Arc",
|
||||
"lineColor": color,
|
||||
"lineWidth": 1,
|
||||
"layerId": layer_id,
|
||||
"shapeData": {
|
||||
"center": {"x": center[0], "y": center[1]},
|
||||
"radius": radius,
|
||||
"startAngle": start_angle,
|
||||
"endAngle": end_angle,
|
||||
"counterClockwise": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}.
|
||||
|
||||
30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시
|
||||
경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례).
|
||||
"""
|
||||
stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"]))
|
||||
if not stations:
|
||||
return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}]
|
||||
if len(stations) <= LONG_SPLIT_STATION_COUNT:
|
||||
return [
|
||||
{
|
||||
"id": "longitudinal",
|
||||
"label": "종단도",
|
||||
"start_m": float(stations[0]["chainage_m"]),
|
||||
"end_m": float(stations[-1]["chainage_m"]),
|
||||
}
|
||||
]
|
||||
chunks: list[dict[str, Any]] = []
|
||||
stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복
|
||||
index = 0
|
||||
number = 1
|
||||
while index < len(stations) - 1:
|
||||
chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT]
|
||||
chunks.append(
|
||||
{
|
||||
"id": f"longitudinal_{number}",
|
||||
"label": f"종단도({number})",
|
||||
"start_m": float(chunk_stations[0]["chainage_m"]),
|
||||
"end_m": float(chunk_stations[-1]["chainage_m"]),
|
||||
}
|
||||
)
|
||||
index += stride
|
||||
number += 1
|
||||
return chunks
|
||||
|
||||
|
||||
def _long_table_values(
|
||||
stations: list[dict[str, Any]],
|
||||
all_stations: list[dict[str, Any]],
|
||||
ground_points: list[tuple[float, float]],
|
||||
longitudinal: dict[str, Any],
|
||||
interval_m: float,
|
||||
) -> list[dict[str, str]]:
|
||||
"""청크 측점별 테이블 셀 문자열. 거리는 노선 전체 기준 직전 측점과의 차."""
|
||||
chainage_all = sorted(float(s["chainage_m"]) for s in all_stations)
|
||||
rows: list[dict[str, str]] = []
|
||||
for station in stations:
|
||||
chainage = float(station["chainage_m"])
|
||||
ground = station.get("center_z")
|
||||
ground = (
|
||||
float(ground)
|
||||
if isinstance(ground, (int, float))
|
||||
else _interpolate(ground_points, chainage)
|
||||
)
|
||||
design = design_elevation_from_longitudinal(longitudinal, chainage)
|
||||
cut = max(ground - design, 0.0) if ground is not None and design is not None else None
|
||||
fill = max(design - ground, 0.0) if ground is not None and design is not None else None
|
||||
position = chainage_all.index(chainage) if chainage in chainage_all else -1
|
||||
distance = chainage - chainage_all[position - 1] if position > 0 else None
|
||||
rows.append(
|
||||
{
|
||||
"station": station_no_label(chainage, interval_m),
|
||||
"distance": _format(distance, 1),
|
||||
"cum_distance": _format(chainage, 1),
|
||||
"ground": _format(ground),
|
||||
"design": _format(design),
|
||||
"cut": _format(cut) if cut else "",
|
||||
"fill": _format(fill) if fill else "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _graph_grid_entities(
|
||||
drawing_id: str,
|
||||
chainages: list[float],
|
||||
x0: float,
|
||||
x1: float,
|
||||
datum_y: float,
|
||||
top_y: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선."""
|
||||
entities: list[dict[str, Any]] = [
|
||||
# 기준선(X축)
|
||||
_line_entity(
|
||||
f"{drawing_id}:lgx", (x0, datum_y), (x1, datum_y), LONG_GRID_LAYER_ID, GRID_COLOR
|
||||
),
|
||||
# Y축
|
||||
_line_entity(
|
||||
f"{drawing_id}:lgy", (x0, datum_y), (x0, top_y), LONG_GRID_LAYER_ID, GRID_COLOR
|
||||
),
|
||||
]
|
||||
# Y축 표고 눈금·라벨 (5m 간격, 가로쓰기)
|
||||
level = datum_y
|
||||
tick_index = 0
|
||||
while level <= top_y + 1e-6:
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:lgyt{tick_index}",
|
||||
(x0 - 1.0, level),
|
||||
(x0, level),
|
||||
LONG_GRID_LAYER_ID,
|
||||
GRID_COLOR,
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:lgyl{tick_index}",
|
||||
_format(level),
|
||||
x0 - 1.6,
|
||||
level,
|
||||
LONG_GRID_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="right",
|
||||
)
|
||||
)
|
||||
level += 5.0
|
||||
tick_index += 1
|
||||
# 측점별 회색 세로선 (기준선 → 그래프 상단)
|
||||
for index, x in enumerate(chainages):
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:lgv{index}",
|
||||
(x, datum_y),
|
||||
(x, top_y),
|
||||
LONG_GRID_LAYER_ID,
|
||||
GRID_COLOR,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _curve_row_entities(
|
||||
drawing_id: str,
|
||||
longitudinal: dict[str, Any],
|
||||
x0: float,
|
||||
x1: float,
|
||||
y_top: float,
|
||||
row_height: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""곡선행: 종단곡선(BVC~EVC) 세로틱+수평선+R/L 표기 (브래킷형)."""
|
||||
entities: list[dict[str, Any]] = []
|
||||
curves = _profile_alignment(longitudinal).get("curves")
|
||||
if not isinstance(curves, list):
|
||||
return entities
|
||||
y_line = y_top - row_height * 0.62
|
||||
y_text = y_top - row_height * 0.30
|
||||
tick = row_height * 0.24
|
||||
for index, curve in enumerate(curves):
|
||||
if not isinstance(curve, dict) or curve.get("omitted"):
|
||||
continue
|
||||
bvc = curve.get("bvc_m")
|
||||
evc = curve.get("evc_m")
|
||||
if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)):
|
||||
continue
|
||||
start = max(float(bvc), x0)
|
||||
end = min(float(evc), x1)
|
||||
if end <= start:
|
||||
continue
|
||||
seed = f"{drawing_id}:lcurve:{index}"
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{seed}:l", (start, y_line), (end, y_line), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{seed}:t0",
|
||||
(start, y_line - tick),
|
||||
(start, y_line + tick),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{seed}:t1",
|
||||
(end, y_line - tick),
|
||||
(end, y_line + tick),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
r_m = curve.get("r_m")
|
||||
l_m = curve.get("l_m")
|
||||
parts = []
|
||||
if isinstance(r_m, (int, float)):
|
||||
parts.append(f"R={r_m:g}")
|
||||
if isinstance(l_m, (int, float)):
|
||||
parts.append(f"L={l_m:g}")
|
||||
if parts:
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{seed}:txt",
|
||||
" ".join(parts),
|
||||
(start + end) / 2.0,
|
||||
y_text,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE * 0.9,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _grade_break_points(
|
||||
segments: list[Any], x0: float, x1: float, route_start: float, route_end: float
|
||||
) -> list[tuple[float, bool]]:
|
||||
"""구배 변화점 목록 [(chainage, 노선 시·종점 여부)] — 청크 범위 내만."""
|
||||
boundaries: set[float] = set()
|
||||
for segment in segments:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("from_m", "to_m"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, (int, float)) and x0 - 1e-6 <= float(value) <= x1 + 1e-6:
|
||||
boundaries.add(round(float(value), 4))
|
||||
return [
|
||||
(chainage, abs(chainage - route_start) < 1e-3 or abs(chainage - route_end) < 1e-3)
|
||||
for chainage in sorted(boundaries)
|
||||
]
|
||||
|
||||
|
||||
def _grade_row_entities(
|
||||
drawing_id: str,
|
||||
longitudinal: dict[str, Any],
|
||||
stations: list[dict[str, Any]],
|
||||
x0: float,
|
||||
x1: float,
|
||||
y_top: float,
|
||||
row_height: float,
|
||||
route_start: float,
|
||||
route_end: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""구배행: 구간 사선 + 가로 구배 표기 + 변화점 원(내부 세로쓰기 계획고).
|
||||
|
||||
노선 시·종점은 반원(상반원)으로 표기한다. profile_alignment.segments가
|
||||
없으면(구 폴백 계획선) 측점 간 계획고 차이 텍스트만 표기한다.
|
||||
"""
|
||||
entities: list[dict[str, Any]] = []
|
||||
y_mid = y_top - row_height * 0.5
|
||||
y_high = y_top - row_height * 0.18
|
||||
y_low = y_top - row_height * 0.82
|
||||
radius = row_height * 0.40
|
||||
segments = _profile_alignment(longitudinal).get("segments")
|
||||
if not (isinstance(segments, list) and segments):
|
||||
# 폴백: 측점 간 계획고 차이 기반 구배 텍스트
|
||||
for index in range(len(stations) - 1):
|
||||
c0 = float(stations[index]["chainage_m"])
|
||||
c1 = float(stations[index + 1]["chainage_m"])
|
||||
d0 = design_elevation_from_longitudinal(longitudinal, c0)
|
||||
d1 = design_elevation_from_longitudinal(longitudinal, c1)
|
||||
span = c1 - c0
|
||||
if d0 is None or d1 is None or span <= 0:
|
||||
continue
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:lgrade:fb{index}",
|
||||
f"{(d1 - d0) / span * 100:.2f}%",
|
||||
(c0 + c1) / 2.0,
|
||||
y_mid,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE * 0.9,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
# 구간 사선 + 가로 구배/거리 표기 (원 반경만큼 안쪽으로 클리핑)
|
||||
for index, segment in enumerate(segments):
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
from_m = segment.get("from_m")
|
||||
to_m = segment.get("to_m")
|
||||
grade = segment.get("grade_percent")
|
||||
if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)):
|
||||
continue
|
||||
start = max(float(from_m), x0) + radius
|
||||
end = min(float(to_m), x1) - radius
|
||||
if end <= start:
|
||||
continue
|
||||
seed = f"{drawing_id}:lgrade:{index}"
|
||||
rising = isinstance(grade, (int, float)) and grade >= 0
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{seed}:d",
|
||||
(start, y_low if rising else y_high),
|
||||
(end, y_high if rising else y_low),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
if isinstance(grade, (int, float)):
|
||||
length = segment.get("length_m")
|
||||
length_text = f" L={length:g}" if isinstance(length, (int, float)) else ""
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{seed}:txt",
|
||||
f"{grade:.2f}%{length_text}",
|
||||
(start + end) / 2.0,
|
||||
y_high + 0.2 if rising else y_low - 0.2,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE * 0.85,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
)
|
||||
|
||||
# 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고
|
||||
for index, (chainage, is_route_end) in enumerate(
|
||||
_grade_break_points(segments, x0, x1, route_start, route_end)
|
||||
):
|
||||
seed = f"{drawing_id}:lgradebp:{index}"
|
||||
if is_route_end:
|
||||
entities.append(
|
||||
_arc_entity(
|
||||
seed,
|
||||
(chainage, y_mid),
|
||||
radius,
|
||||
0.0,
|
||||
math.pi,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
else:
|
||||
entities.append(
|
||||
_circle_entity(
|
||||
seed, (chainage, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
|
||||
)
|
||||
)
|
||||
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
|
||||
if elevation is not None:
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{seed}:txt",
|
||||
_format(elevation),
|
||||
chainage,
|
||||
y_mid,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE * 0.8,
|
||||
TABLE_VALUE_COLOR,
|
||||
direction=_VERTICAL,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _long_table_entities(
|
||||
drawing_id: str,
|
||||
stations: list[dict[str, Any]],
|
||||
values: list[dict[str, str]],
|
||||
longitudinal: dict[str, Any],
|
||||
table_top: float,
|
||||
interval_m: float,
|
||||
route_start: float,
|
||||
route_end: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값."""
|
||||
chainages = [float(s["chainage_m"]) for s in stations]
|
||||
header_width = max(interval_m, 12.0)
|
||||
left = chainages[0] - header_width
|
||||
right = chainages[-1]
|
||||
entities: list[dict[str, Any]] = []
|
||||
|
||||
# 행 y 경계 (가변 행 높이)
|
||||
boundaries = [table_top]
|
||||
for _key, _header, height in _LONG_TABLE_ROWS:
|
||||
boundaries.append(boundaries[-1] - height)
|
||||
bottom = boundaries[-1]
|
||||
|
||||
# 가로 구분선 + 측점 눈금(구분선 아래 짧은 틱)
|
||||
for row_index, y in enumerate(boundaries):
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:ltgrid:h{row_index}",
|
||||
(left, y),
|
||||
(right, y),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
if row_index < len(boundaries) - 1:
|
||||
for column_index, x in enumerate(chainages):
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:lttick:{row_index}:{column_index}",
|
||||
(x, y),
|
||||
(x, y - _TICK_LEN),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
# 외곽·헤더 구분 세로선 (측점 세로선은 없음)
|
||||
for seed, x in (("v-left", left), ("v-header", chainages[0]), ("v-right", right)):
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:ltgrid:{seed}",
|
||||
(x, table_top),
|
||||
(x, bottom),
|
||||
LONG_TABLE_LAYER_ID,
|
||||
TABLE_LINE_COLOR,
|
||||
)
|
||||
)
|
||||
|
||||
for row_index, (key, header, height) in enumerate(_LONG_TABLE_ROWS):
|
||||
y_top = boundaries[row_index]
|
||||
y_mid = y_top - height * 0.5
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:ltable:header:{key}",
|
||||
header,
|
||||
left + header_width / 2.0,
|
||||
y_mid,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
if key == "curve":
|
||||
entities.extend(
|
||||
_curve_row_entities(drawing_id, longitudinal, chainages[0], right, y_top, height)
|
||||
)
|
||||
continue
|
||||
if key == "grade":
|
||||
entities.extend(
|
||||
_grade_row_entities(
|
||||
drawing_id,
|
||||
longitudinal,
|
||||
stations,
|
||||
chainages[0],
|
||||
right,
|
||||
y_top,
|
||||
height,
|
||||
route_start,
|
||||
route_end,
|
||||
)
|
||||
)
|
||||
continue
|
||||
for value_index, row_values in enumerate(values):
|
||||
label = row_values.get(key, "")
|
||||
if not label:
|
||||
continue
|
||||
if key == "distance":
|
||||
# 거리는 직전 측점과의 구간 중앙, 가로쓰기 (청크 첫 측점 제외)
|
||||
if value_index == 0:
|
||||
continue
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:ltable:{key}:{value_index}",
|
||||
label,
|
||||
(chainages[value_index - 1] + chainages[value_index]) / 2.0,
|
||||
y_mid,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE * 0.9,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# 측점·추가거리·지반고·계획고·절토고·성토고: 측점 위치 세로쓰기
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:ltable:{key}:{value_index}",
|
||||
label,
|
||||
chainages[value_index],
|
||||
y_mid,
|
||||
LONG_TABLE_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_VALUE_COLOR,
|
||||
direction=_VERTICAL,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def build_longitudinal_drawing(
|
||||
longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다."""
|
||||
start_m = chunk.get("start_m")
|
||||
end_m = chunk.get("end_m")
|
||||
|
||||
def in_range(x: float) -> bool:
|
||||
if start_m is None or end_m is None:
|
||||
return True
|
||||
return start_m - 1e-6 <= x <= end_m + 1e-6
|
||||
|
||||
ground_all = points_from_samples(longitudinal.get("samples", []), "chainage_m")
|
||||
ground_points = [point for point in ground_all if in_range(point[0])]
|
||||
design_points = [point for point in _design_profile_points(longitudinal) if in_range(point[0])]
|
||||
all_stations = _stations(longitudinal)
|
||||
stations = sorted(
|
||||
(s for s in all_stations if in_range(float(s["chainage_m"]))),
|
||||
key=lambda s: float(s["chainage_m"]),
|
||||
)
|
||||
interval_m = infer_station_interval(all_stations)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
|
||||
if ground:
|
||||
entities.append(ground)
|
||||
design = polyline_entity(drawing_id, design_points, DESIGN_LAYER_ID, DESIGN_COLOR)
|
||||
if design:
|
||||
entities.append(design)
|
||||
|
||||
if stations:
|
||||
chainages = [float(s["chainage_m"]) for s in stations]
|
||||
elevations = [y for _x, y in [*ground_points, *design_points]]
|
||||
min_e = min(elevations) if elevations else 0.0
|
||||
max_e = max(elevations) if elevations else 10.0
|
||||
# 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림.
|
||||
datum_y = math.floor((min_e - 5.0) / 5.0) * 5.0
|
||||
top_y = max_e + 3.0
|
||||
entities.extend(
|
||||
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_y, top_y)
|
||||
)
|
||||
|
||||
values = _long_table_values(stations, all_stations, ground_all, longitudinal, interval_m)
|
||||
all_chainages = sorted(float(s["chainage_m"]) for s in all_stations)
|
||||
table_top = datum_y - 3.0 # 그래프-테이블 영역 분리 간격
|
||||
entities.extend(
|
||||
_long_table_entities(
|
||||
drawing_id,
|
||||
stations,
|
||||
values,
|
||||
longitudinal,
|
||||
table_top,
|
||||
interval_m,
|
||||
all_chainages[0],
|
||||
all_chainages[-1],
|
||||
)
|
||||
)
|
||||
|
||||
# A1 도각 프레임: 콘텐츠 bbox를 감싸도록 배치 (잠금 레이어, 좌표는 콘텐츠 불변).
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
entities.extend(frame_entities(drawing_id, bbox))
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
|
||||
_layer(DESIGN_LAYER_ID, "Design Plan"),
|
||||
_layer(LONG_GRID_LAYER_ID, "Graph Grid", locked=True),
|
||||
_layer(LONG_TABLE_LAYER_ID, "Station Table"),
|
||||
_layer(FRAME_LAYER_ID, "Frame", locked=True),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다.
|
||||
|
||||
resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
|
||||
도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어
|
||||
(b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 —
|
||||
템플릿 쪽을 확대해 콘텐츠를 감싼다.
|
||||
|
||||
A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란,
|
||||
내부 작도 영역 (42, 47) ~ (812, 567).
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid5
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_ENTITY_NS,
|
||||
FRAME_LAYER_ID,
|
||||
)
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
|
||||
|
||||
A1_TEMPLATE = "00_template_A1"
|
||||
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
|
||||
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
|
||||
_CONTENT_MARGIN = 0.05 # 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향 5%)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load_template(name: str) -> dict[str, Any] | None:
|
||||
path = _TEMPLATE_DIR / f"{name}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
|
||||
"""엔티티 목록의 (min_x, min_y, max_x, max_y). 좌표가 없으면 None."""
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
|
||||
def _collect(entity: dict[str, Any]) -> None:
|
||||
shape = entity.get("shapeData") or {}
|
||||
for key in ("startPoint", "endPoint", "basePoint", "point"):
|
||||
p = shape.get(key)
|
||||
if isinstance(p, dict):
|
||||
xs.append(float(p["x"]))
|
||||
ys.append(float(p["y"]))
|
||||
center = shape.get("center")
|
||||
if isinstance(center, dict):
|
||||
r = float(shape.get("radius", 0.0))
|
||||
xs.extend((float(center["x"]) - r, float(center["x"]) + r))
|
||||
ys.extend((float(center["y"]) - r, float(center["y"]) + r))
|
||||
for child in entity.get("children") or []:
|
||||
_collect(child)
|
||||
|
||||
for entity in entities:
|
||||
_collect(entity)
|
||||
if not xs:
|
||||
return None
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
|
||||
def _transform_entity(
|
||||
entity: dict[str, Any], seed: str, scale: float, dx: float, dy: float
|
||||
) -> dict[str, Any]:
|
||||
"""템플릿 엔티티를 스케일+이동 복사한다. id는 도면별 결정적 재생성."""
|
||||
out = dict(entity)
|
||||
out["id"] = str(uuid5(_ENTITY_NS, seed))
|
||||
out["layerId"] = FRAME_LAYER_ID
|
||||
shape = entity.get("shapeData")
|
||||
if isinstance(shape, dict):
|
||||
new_shape = dict(shape)
|
||||
for key in ("startPoint", "endPoint", "basePoint", "point", "center"):
|
||||
p = shape.get(key)
|
||||
if isinstance(p, dict):
|
||||
new_shape[key] = {"x": p["x"] * scale + dx, "y": p["y"] * scale + dy}
|
||||
if "radius" in shape:
|
||||
new_shape["radius"] = shape["radius"] * scale
|
||||
options = shape.get("options")
|
||||
if isinstance(options, dict):
|
||||
new_options = dict(options)
|
||||
new_options["fontSize"] = options.get("fontSize", 4.0) * scale
|
||||
new_shape["options"] = new_options
|
||||
out["shapeData"] = new_shape
|
||||
children = entity.get("children")
|
||||
if isinstance(children, list):
|
||||
out["children"] = [
|
||||
_transform_entity(child, f"{seed}:{index}", scale, dx, dy)
|
||||
for index, child in enumerate(children)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def frame_entities(
|
||||
drawing_id: str,
|
||||
content_bbox: tuple[float, float, float, float],
|
||||
template_name: str = A1_TEMPLATE,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록."""
|
||||
template = _load_template(template_name)
|
||||
if not template:
|
||||
return []
|
||||
min_x, min_y, max_x, max_y = content_bbox
|
||||
content_w = max(max_x - min_x, 1e-6)
|
||||
content_h = max(max_y - min_y, 1e-6)
|
||||
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
usable_w = (ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN)
|
||||
usable_h = (iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN)
|
||||
scale = max(content_w / usable_w, content_h / usable_h)
|
||||
|
||||
# 콘텐츠 중심 = 내부 작도 영역 중심이 되도록 이동량 산출.
|
||||
dx = (min_x + max_x) / 2.0 - (ix0 + ix1) / 2.0 * scale
|
||||
dy = (min_y + max_y) / 2.0 - (iy0 + iy1) / 2.0 * scale
|
||||
|
||||
return [
|
||||
_transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy)
|
||||
for index, entity in enumerate(template.get("entities", []))
|
||||
]
|
||||
@@ -0,0 +1,602 @@
|
||||
"""B06 확정 종·횡단 산출물을 B07 CAD 도면으로 변환하는 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_design,
|
||||
get_longitudinal_section,
|
||||
merge_cross_section_design_by_round,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
QUANTITY_VALUE_KEYS,
|
||||
build_cross_drawing,
|
||||
extract_quantity_table,
|
||||
infer_station_interval,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingConfirmRequest,
|
||||
DesignDrawingConfirmResponse,
|
||||
DesignDrawingInvalidateResponse,
|
||||
DesignDrawingItem,
|
||||
DesignDrawingListResponse,
|
||||
DesignDrawingResponse,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
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$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
_STAGE_DIR = "B07_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"]
|
||||
# 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1).
|
||||
drawings = [
|
||||
DesignDrawingItem(
|
||||
id=str(chunk["id"]),
|
||||
kind="longitudinal",
|
||||
label=str(chunk["label"]),
|
||||
confirmed=bool(manifest_drawings.get(str(chunk["id"]), {}).get("confirmed")),
|
||||
)
|
||||
for chunk in longitudinal_chunks(longitudinal)
|
||||
]
|
||||
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 _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
|
||||
"""횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키).
|
||||
|
||||
center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값.
|
||||
나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로
|
||||
두어 CAD 테이블에서 사용자가 채운다.
|
||||
"""
|
||||
|
||||
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_VALUE_KEYS:
|
||||
table.setdefault(key, num(quantities.get(key)))
|
||||
return table
|
||||
|
||||
|
||||
def _route_cross_frame(
|
||||
longitudinal_path: Path, longitudinal: dict[str, Any]
|
||||
) -> dict[str, float] | None:
|
||||
"""노선 전체 횡단면의 기준 레이아웃 {half, half_height}를 구한다.
|
||||
|
||||
측점별 단면 높이(최대-최소 표고)와 폭의 노선 최대값 — 모든 횡단도가 자기
|
||||
콘텐츠 중심 기준으로 같은 크기 레이아웃에 배치되도록 하는 기준값(테이블
|
||||
y 고정용). 실패 시 None(측점 자체 범위 폴백).
|
||||
"""
|
||||
half = half_height = 0.0
|
||||
found = False
|
||||
try:
|
||||
for path in _cross_files(longitudinal_path, longitudinal):
|
||||
source = _read_json(path)
|
||||
min_e: float | None = None
|
||||
max_e: float | None = None
|
||||
for sample in source.get("samples", []):
|
||||
if not isinstance(sample, dict) or not sample.get("valid", False):
|
||||
continue
|
||||
offset = sample.get("offset_m")
|
||||
elevation = sample.get("elevation_m")
|
||||
if isinstance(offset, (int, float)):
|
||||
half = max(half, abs(float(offset)))
|
||||
if isinstance(elevation, (int, float)):
|
||||
value = float(elevation)
|
||||
min_e = value if min_e is None else min(min_e, value)
|
||||
max_e = value if max_e is None else max(max_e, value)
|
||||
if min_e is not None and max_e is not None:
|
||||
half_height = max(half_height, (max_e - min_e) / 2.0)
|
||||
found = True
|
||||
except (OSError, ValueError, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
if not found:
|
||||
return None
|
||||
# 여유: 측구 깊이·사면 연장 등 설계선이 지반 포락선을 소폭 벗어나는 분 반영.
|
||||
return {"half": half or 12.0, "half_height": half_height + 1.5}
|
||||
|
||||
|
||||
def _cross_design_line(
|
||||
longitudinal_path: Path, source: dict[str, Any], stored_design: dict[str, Any] | None
|
||||
) -> list[Any] | None:
|
||||
"""횡단 CAD 계획선용 design_line을 정한다: 저장 설계 우선, 없으면 기본값 계산."""
|
||||
if isinstance(stored_design, dict) and isinstance(stored_design.get("design_line"), list):
|
||||
return stored_design["design_line"]
|
||||
try:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
design = compute_cross_design(
|
||||
source.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))),
|
||||
ground_type="soil",
|
||||
section_mode="left_cut",
|
||||
)
|
||||
return design["design_line"]
|
||||
except (ValueError, KeyError, OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_drawing(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_design: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
|
||||
|
||||
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
|
||||
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. 횡단도의 계획선은
|
||||
stored_design(없으면 기본값)에서 만든다.
|
||||
"""
|
||||
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():
|
||||
saved = _read_json(saved_path)
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) 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, saved, True, table
|
||||
if _LONG_ID.fullmatch(drawing_id):
|
||||
source = _read_json(longitudinal_path)
|
||||
chunk = next(
|
||||
(item for item in longitudinal_chunks(source) if item["id"] == drawing_id), None
|
||||
)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 종단도 분할 도면을 찾을 수 없습니다.")
|
||||
return (
|
||||
"longitudinal",
|
||||
str(chunk["label"]),
|
||||
build_longitudinal_drawing(source, drawing_id, chunk),
|
||||
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)
|
||||
design_line = _cross_design_line(longitudinal_path, source, stored_design)
|
||||
quantity_table = _quantity_table(source)
|
||||
# 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식)
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
title = station_no_label(float(source.get("chainage_m", 0.0)), interval)
|
||||
# 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정.
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
frame = _route_cross_frame(longitudinal_path, longitudinal)
|
||||
return (
|
||||
"cross",
|
||||
label,
|
||||
build_cross_drawing(
|
||||
source,
|
||||
drawing_id,
|
||||
design_line,
|
||||
stored_design,
|
||||
quantity_table,
|
||||
title,
|
||||
design_elevation,
|
||||
frame,
|
||||
),
|
||||
False,
|
||||
quantity_table,
|
||||
)
|
||||
|
||||
|
||||
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 도면 스키마가 올바르지 않습니다.")
|
||||
# CAD 앱 직렬화본에는 format이 없으므로 저장 시 현재 포맷 버전을 스탬프한다.
|
||||
drawing = {"format": DRAWING_FORMAT, **drawing}
|
||||
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)
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
design: dict[str, Any] | None = None
|
||||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||||
if cross_match:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
design = await get_cross_section_design(
|
||||
connection, route_id, int(cross_match.group(1))
|
||||
)
|
||||
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
||||
_read_drawing, project_root, longitudinal_path, drawing_id, design
|
||||
)
|
||||
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,
|
||||
design=design,
|
||||
)
|
||||
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": "상세 설계 도면을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
def _recompute_confirmed_design(
|
||||
longitudinal_path: Path, drawing_id: str, designation: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
|
||||
|
||||
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
|
||||
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
|
||||
source = _read_json(cross_path)
|
||||
samples = source.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
)
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
ground_type=designation["ground_type"],
|
||||
section_mode=designation["section_mode"],
|
||||
ditch_side=designation.get("ditch_side"),
|
||||
)
|
||||
design["status"] = "confirmed"
|
||||
return design
|
||||
|
||||
|
||||
@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:
|
||||
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
|
||||
|
||||
횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
|
||||
"""
|
||||
try:
|
||||
route_id, 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("확정할 도면을 찾을 수 없습니다.")
|
||||
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
|
||||
quantity_table = (
|
||||
extract_quantity_table(drawing_id, request.drawing) or request.quantity_table
|
||||
if item.kind == "cross"
|
||||
else None
|
||||
)
|
||||
# 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관.
|
||||
all_confirmed = await asyncio.to_thread(
|
||||
_store_confirmed_drawing,
|
||||
project_root,
|
||||
item,
|
||||
request.drawing,
|
||||
{candidate.id for candidate in items if candidate.kind == "cross"},
|
||||
quantity_table,
|
||||
)
|
||||
|
||||
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
|
||||
confirmed_design: dict[str, Any] | None = None
|
||||
chainage_int: int | None = None
|
||||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||||
pool = get_db_pool()
|
||||
if item.kind == "cross" and cross_match:
|
||||
chainage_int = int(cross_match.group(1))
|
||||
async with pool.acquire() as connection:
|
||||
designation = await get_cross_section_design(connection, route_id, chainage_int)
|
||||
if designation:
|
||||
try:
|
||||
confirmed_design = await asyncio.to_thread(
|
||||
_recompute_confirmed_design, longitudinal_path, drawing_id, designation
|
||||
)
|
||||
except (ValueError, KeyError, FileNotFoundError, OSError):
|
||||
logger.warning(
|
||||
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s",
|
||||
drawing_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
if confirmed_design is not None and chainage_int is not None:
|
||||
await merge_cross_section_design_by_round(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_int=chainage_int,
|
||||
patch=confirmed_design,
|
||||
)
|
||||
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,
|
||||
design=confirmed_design,
|
||||
)
|
||||
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:
|
||||
route_id, 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)
|
||||
|
||||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
# 확정 도면을 편집하면 해당 측점 설계도 잠정 상태로 되돌린다.
|
||||
if cross_match:
|
||||
await merge_cross_section_design_by_round(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_int=int(cross_match.group(1)),
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
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,70 @@
|
||||
"""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
|
||||
# B06에서 지정한 잠정 설계(지반유형·단면유형·절성토 단면적). 횡단도만, 없으면 None.
|
||||
design: dict[str, Any] | 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
|
||||
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
|
||||
design: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DesignDrawingInvalidateResponse(BaseModel):
|
||||
"""확정 도면 변경에 따른 상태 롤백 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
id: str
|
||||
confirmed: bool = False
|
||||
@@ -0,0 +1,546 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_Page.ts
|
||||
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
|
||||
*
|
||||
* B07_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
|
||||
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
|
||||
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
|
||||
*
|
||||
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
|
||||
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B07_DesignDetail_UI_Style.css";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
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";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmDesignDrawing,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
invalidateDesignDrawing,
|
||||
type CadDrawing,
|
||||
type CrossDesignInfo,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
kind: "cross" | "longitudinal";
|
||||
title: string;
|
||||
info: string;
|
||||
confirmed: boolean;
|
||||
quantityTable: QuantityTable | null;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
||||
interface SaveResult {
|
||||
drawing: CadDrawing;
|
||||
quantityTable: QuantityTable | null;
|
||||
}
|
||||
|
||||
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const B07_CAD_APP_URL = "/b07-cad/index.html";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
||||
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
||||
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
||||
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
||||
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
|
||||
/** 측점 간격을 연속 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[],
|
||||
stationInterval: number,
|
||||
onSelect: (drawing: DesignDrawingItem) => void,
|
||||
errorMessage?: string,
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-drawing-list";
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "b07-drawing-list__heading";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "설계 도면";
|
||||
const count = document.createElement("span");
|
||||
count.textContent = `${drawings.length}건`;
|
||||
heading.append(title, count);
|
||||
panel.append(heading);
|
||||
|
||||
if (errorMessage || drawings.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b07-drawing-list__empty";
|
||||
empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다.";
|
||||
panel.append(empty);
|
||||
return panel;
|
||||
}
|
||||
|
||||
const groups: [string, DesignDrawingItem["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");
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
|
||||
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
|
||||
section.dataset.kind = kind;
|
||||
const sectionTitle = document.createElement("h3");
|
||||
sectionTitle.className = "ui-collapsible__title";
|
||||
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", () => onSelect(drawing));
|
||||
section.append(button);
|
||||
}
|
||||
panel.append(section);
|
||||
}
|
||||
attachCollapsible(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||||
soil: "B06_Design_Ground_Soil",
|
||||
ripping_rock: "B06_Design_Ground_Ripping",
|
||||
blasting_rock: "B06_Design_Ground_Blasting",
|
||||
};
|
||||
|
||||
/** 단면유형에서 절토측 표기를 유도한다. */
|
||||
function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||||
if (mode === "left_cut") return L("B06_Design_Ditch_Left");
|
||||
if (mode === "right_cut") return L("B06_Design_Ditch_Right");
|
||||
if (mode === "both_cut") return L("B06_Design_Mode_BothCut");
|
||||
return L("B06_Design_Mode_BothFill");
|
||||
}
|
||||
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
||||
if (ditch.type === "l_type")
|
||||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
}
|
||||
|
||||
function infoRow(label: string, value: string): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b07-info__row";
|
||||
const key = document.createElement("span");
|
||||
key.className = "b07-info__key";
|
||||
key.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.className = "b07-info__val";
|
||||
val.textContent = value;
|
||||
row.append(key, val);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
|
||||
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-info";
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "b07-info__heading";
|
||||
const stationName = document.createElement("strong");
|
||||
stationName.textContent = `${L("B07_Info_Station")} ${title}`;
|
||||
const confirmed = design?.status === "confirmed";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
||||
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
|
||||
heading.append(stationName, badge);
|
||||
panel.append(heading);
|
||||
|
||||
if (!design) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b07-info__empty";
|
||||
empty.textContent = L("B07_Info_NoDesign");
|
||||
panel.append(empty);
|
||||
return panel;
|
||||
}
|
||||
|
||||
const ground = document.createElement("section");
|
||||
ground.className = "b07-info__block";
|
||||
const groundTitle = document.createElement("h4");
|
||||
groundTitle.textContent = L("B07_Info_Ground_Title");
|
||||
ground.append(
|
||||
groundTitle,
|
||||
infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])),
|
||||
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
||||
infoRow(
|
||||
L("B07_Info_DitchSide"),
|
||||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
||||
),
|
||||
);
|
||||
|
||||
const plan = document.createElement("section");
|
||||
plan.className = "b07-info__block";
|
||||
const planTitle = document.createElement("h4");
|
||||
planTitle.textContent = L("B07_Info_Plan_Title");
|
||||
plan.append(
|
||||
planTitle,
|
||||
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
|
||||
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
||||
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
||||
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
||||
infoRow(L("B07_Info_Ditch"), ditchLabel(design)),
|
||||
infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`),
|
||||
infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`),
|
||||
);
|
||||
|
||||
panel.append(ground, plan);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
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) {
|
||||
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");
|
||||
cadHost.className = "b07-cad-host";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "b07-cad-frame";
|
||||
frame.src = B07_CAD_APP_URL;
|
||||
frame.title = L("B07_Design_Title");
|
||||
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);
|
||||
|
||||
const crossChainages = drawings
|
||||
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
|
||||
.map((item) => item.chainage_m as number);
|
||||
const stationInterval = inferStationInterval(crossChainages);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined;
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
let currentIndex = -1;
|
||||
let currentConfirmed = false;
|
||||
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const title =
|
||||
typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label;
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
|
||||
};
|
||||
|
||||
const confirmButton = createButton({
|
||||
label: "현재 도면 확정",
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentDrawing(),
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
|
||||
const findButton = (drawingId: string) =>
|
||||
drawingListEl?.querySelector<HTMLButtonElement>(
|
||||
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
|
||||
) ?? undefined;
|
||||
|
||||
const highlightActive = (drawingId: string) => {
|
||||
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
};
|
||||
|
||||
const buildMeta = (
|
||||
drawing: DesignDrawingItem,
|
||||
response: DesignDrawingResponse,
|
||||
index: number,
|
||||
): DesignMeta => ({
|
||||
kind: drawing.kind,
|
||||
title:
|
||||
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
|
||||
? stationLabel(drawing.chainage_m, stationInterval)
|
||||
: drawing.label,
|
||||
info: drawing.kind === "cross" ? drawing.label : "",
|
||||
confirmed: response.confirmed,
|
||||
quantityTable: response.quantity_table ?? null,
|
||||
hasPrev: index > 0,
|
||||
hasNext: index < drawings.length - 1,
|
||||
});
|
||||
|
||||
const sendLoad = (drawing: CadDrawing, meta: DesignMeta) => {
|
||||
pendingLoad = { drawing, meta };
|
||||
if (!cadReady) return;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta },
|
||||
window.location.origin,
|
||||
);
|
||||
pendingLoad = undefined;
|
||||
};
|
||||
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
|
||||
if (!projectId) return;
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cadHost.dataset.loading = "true";
|
||||
try {
|
||||
const response = await fetchDesignDrawing(projectId, drawing.id);
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
currentConfirmed = response.confirmed;
|
||||
confirmButton.disabled = response.confirmed;
|
||||
updateInfoPanel(drawing, response);
|
||||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||||
} catch (error) {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error =
|
||||
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
||||
} finally {
|
||||
if (button) button.dataset.loading = "false";
|
||||
}
|
||||
};
|
||||
|
||||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||||
void loadDrawing(drawing, drawings.indexOf(drawing));
|
||||
};
|
||||
|
||||
const navigateDrawing = (direction: "prev" | "next") => {
|
||||
if (currentIndex < 0) return;
|
||||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (target < 0 || target >= drawings.length) return;
|
||||
void loadDrawing(drawings[target], target);
|
||||
};
|
||||
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
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 saved = await requestCadDrawing();
|
||||
const result = await confirmDesignDrawing(
|
||||
projectId,
|
||||
currentDrawing.id,
|
||||
saved.drawing,
|
||||
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
|
||||
);
|
||||
currentConfirmed = true;
|
||||
currentDrawing.confirmed = true;
|
||||
confirmButton.disabled = true;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "true";
|
||||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
||||
if (currentDrawing.kind === "cross") {
|
||||
const infoTitle =
|
||||
typeof currentDrawing.chainage_m === "number"
|
||||
? stationLabel(currentDrawing.chainage_m, stationInterval)
|
||||
: currentDrawing.label;
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null));
|
||||
}
|
||||
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;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "false";
|
||||
if (wasConfirmed) {
|
||||
try {
|
||||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
};
|
||||
if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta);
|
||||
} 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_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
stationInterval,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
||||
confirmActions.className = "b07-drawing-actions ui-sidebar-actions";
|
||||
confirmActions.append(confirmButton);
|
||||
|
||||
drawingPanel.append(infoPanelHost, confirmActions);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B07_Design_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 4,
|
||||
leftPanel: drawingPanel,
|
||||
mainContent: cadHost,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (!projectId) return;
|
||||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||||
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
layout.root.classList.add("b07-design-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
|
||||
if (drawings.length > 0) {
|
||||
void loadDrawing(drawings[0], 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_Style.css
|
||||
* 상세 설계(독립형 2D CAD) 화면 스타일 — theme.css 변수만 사용
|
||||
* ========================================================================== */
|
||||
|
||||
/* 워크플로우 레이아웃 높이 (B06 패턴 준수) */
|
||||
.b07-design-layout {
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--spacing-64));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b07-design-layout .ui-workflow-layout__body,
|
||||
.b07-design-layout .ui-workflow-layout__main {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 횡단도는 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;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
/* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
|
||||
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, #000);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b07-drawing-button__name {
|
||||
overflow: hidden;
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합).
|
||||
여기서는 B07 고유 여백만 남긴다. */
|
||||
.b07-drawing-actions {
|
||||
padding-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
.b07-drawing-actions > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* CAD 뷰어 호스트 (상세 페이지 영역) */
|
||||
.b07-cad-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* B07 독립형 CAD 앱 임베드 */
|
||||
.b07-cad-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
|
||||
.b07-info-host:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-info {
|
||||
margin-top: var(--spacing-16);
|
||||
padding-top: var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-info__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-info__badge {
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-mist-violet);
|
||||
border-radius: var(--radius-buttons);
|
||||
}
|
||||
|
||||
.b07-info__badge--confirmed {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.b07-info__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b07-info__block h4 {
|
||||
margin: var(--spacing-8) 0 2px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b07-info__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.b07-info__key {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b07-info__val {
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b07-info__empty {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "openwebcad"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024 Bert Verhelst
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Aislo B08 2D Drawing
|
||||
|
||||
This directory is maintained as part of the Aislo project and is not connected
|
||||
to an upstream Git repository. It provides the independent browser-based 2D
|
||||
drawing workspace used only by the B08 page.
|
||||
|
||||
The drawing engine is based on OpenWebCAD by Bert Verhelst and retains its MIT
|
||||
license notice in `LICENSE.md` and `public/THIRD_PARTY_LICENSES.txt`.
|
||||
|
||||
## Aislo integration boundary
|
||||
|
||||
- Business drawings enter the iframe only as same-origin JSON messages with
|
||||
type `aislo:b08:load-drawing`.
|
||||
- Browser-side DXF/DWG parsing is intentionally excluded.
|
||||
- DXF, DWG and PDF input/output are separate future modules and require a new
|
||||
dependency-license review before implementation.
|
||||
|
||||
## Original project description
|
||||
|
||||
This is a React-based canvas drawing application that allows users to draw various shapes, such as lines, rectangles, and circles, on a fullscreen canvas. The application also includes features for selecting and erasing shapes, as well as exporting the drawing as an SVG file.
|
||||
|
||||

|
||||
|
||||
## DEMO: [https://bertyhell.github.io/openwebcad](https://bertyhell.github.io/openwebcad)
|
||||
|
||||
## Features
|
||||
|
||||
- Fullscreen canvas with a black background
|
||||
- Drawing tools: Line, Rectangle, Circle, measurements
|
||||
- Zoom and pan
|
||||
- Eraser tool to delete segments
|
||||
- Undo and redo
|
||||
- Choose angle guides
|
||||
- Draw with snap points for
|
||||
- endpoints
|
||||
- midpoints
|
||||
- intersections
|
||||
- circle centers
|
||||
- circle quadrants
|
||||
- Selection tool to highlight and modify shapes
|
||||
- Use CTRL to toggle selection
|
||||
- Use shift to add to the current selection
|
||||
- drag left, to select by intersecting
|
||||
- drag right, to select by containing
|
||||
- Move
|
||||
- Rotate
|
||||
- Scale
|
||||
- Align shapes to each other
|
||||
- Array copy linear
|
||||
- Array copy radial
|
||||
- Import images into the drawing
|
||||
- Import SVG files
|
||||
- Export to PDF
|
||||
- Export drawing as an SVG file
|
||||
- Export drawing as an PNG file
|
||||
- Save and load drawings from/to json files
|
||||
- Select line color and thickness
|
||||
- Eraser tool to delete segments
|
||||
|
||||
|
||||
### Possible future feature ideas (TODO) in order of likelihood
|
||||
- Eraser tool to delete segments
|
||||
- Max distance to delete
|
||||
- Layers for drawing shapes in different layers that can be toggled on or off
|
||||
- Mirror
|
||||
- Offset
|
||||
- Add text
|
||||
- Ellipses
|
||||
- Regular polygons (pentagon, hexagon, etc)
|
||||
- Combine lines into a polygon
|
||||
- Explode polygons into lines
|
||||
- Polygon circumference
|
||||
- Polygon area
|
||||
- Chamfer, Round corners
|
||||
- Draw with snap points for
|
||||
- circle tangents
|
||||
- nearest point on line
|
||||
- prioritize certain snap points over others (eg: midpoint over nearest)
|
||||
- Edit existing lines and circles by dragging endpoints/middle points
|
||||
- Hatching and fill areas
|
||||
- gradient fills
|
||||
- Import DXF files
|
||||
- Import DWG files
|
||||
- Export to DWG
|
||||
- Export to DXF
|
||||
- Export drawing to ASCII code
|
||||
|
||||
### Maintenance
|
||||
|
||||
- replace react with webcomponents (Lit)
|
||||
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- TypeScript
|
||||
- JavaScript
|
||||
- React
|
||||
- NPM
|
||||
- HTML canvas
|
||||
- SVG
|
||||
- SCSS
|
||||
- Tailwind CSS
|
||||
|
||||
|
||||
## Demo
|
||||
Visit https://bertyhell.github.io/openwebcad
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone <repository-url>
|
||||
cd <repository-directory>
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
Start the development server:
|
||||
```sh
|
||||
npm dev
|
||||
```
|
||||
|
||||
Open your browser and navigate to http://localhost:5173
|
||||
|
||||
|
||||
## Development
|
||||
Available Scripts
|
||||
* npm dev: Runs the app in development mode.
|
||||
* npm run build: Builds the app for production.
|
||||
* npm preview: Runs the production build in a local server.
|
||||
|
||||
|
||||
## Project Structure
|
||||
* src/: Contains the source code of the application.
|
||||
* docs/: Contains the github pages site.
|
||||
* public/: Contains assets that need to be accessible from the url. Like favicon.
|
||||
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome! Please open an issue or submit a pull request for any changes.
|
||||
|
||||
|
||||
## License
|
||||
This project is licensed under the MIT License.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [".vscode", ".idea", "node_modules", "docs"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"indentStyle": "space"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" class="m-0 p-0 overflow-hidden w-full h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
||||
<title>Aislo 2D Drawing</title>
|
||||
</head>
|
||||
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row bg-white">
|
||||
<div id="root" data-id="root" class="bg-slate-950"></div>
|
||||
<canvas data-id="canvas" class="block bg-black cursor-none"></canvas>
|
||||
<div class="export-pdf-wrapper">
|
||||
<!-- Used to append the svg before exporting it to pdf -->
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "aislo-b08-cad",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome lint --write",
|
||||
"lint-check": "biome lint",
|
||||
"preview": "vite preview",
|
||||
"stats": "npx cloc ./src",
|
||||
"check-types": "tsc -p tsconfig.app.json --noEmit",
|
||||
"test": "vitest",
|
||||
"postinstall": "npx patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flatten-js/core": "^1.6.2",
|
||||
"blend-promise-utils": "^1.29.2",
|
||||
"clsx": "^2.1.1",
|
||||
"es-toolkit": "^1.16.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"forest-road-webapp": "file:../..",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"svg-parser": "^2.0.4",
|
||||
"teenyicons": "^0.4.1",
|
||||
"undo-stacker": "^0.2.1",
|
||||
"use-local-storage-state": "^19.5.0",
|
||||
"xstate": "^5.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/svg-parser": "^2.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||
"@typescript-eslint/parser": "^7.15.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"biome": "^0.3.3",
|
||||
"puppeteer": "^24.1.1",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^6.2.6",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
diff --git a/node_modules/@flatten-js/core/dist/main.cjs b/node_modules/@flatten-js/core/dist/main.cjs
|
||||
index 9ed46e1..d6b1b59 100644
|
||||
--- a/node_modules/@flatten-js/core/dist/main.cjs
|
||||
+++ b/node_modules/@flatten-js/core/dist/main.cjs
|
||||
@@ -6601,7 +6601,7 @@ class Box extends Shape {
|
||||
|
||||
if (shape instanceof Flatten.Arc) {
|
||||
return shape.vertices.every(vertex => this.contains(vertex)) &&
|
||||
- shape.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
+ this.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
}
|
||||
|
||||
if (shape instanceof Flatten.Line || shape instanceof Flatten.Ray) {
|
||||
diff --git a/node_modules/@flatten-js/core/dist/main.mjs b/node_modules/@flatten-js/core/dist/main.mjs
|
||||
index 0d1bec8..12eb0a2 100644
|
||||
--- a/node_modules/@flatten-js/core/dist/main.mjs
|
||||
+++ b/node_modules/@flatten-js/core/dist/main.mjs
|
||||
@@ -6597,7 +6597,7 @@ class Box extends Shape {
|
||||
|
||||
if (shape instanceof Flatten.Arc) {
|
||||
return shape.vertices.every(vertex => this.contains(vertex)) &&
|
||||
- shape.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
+ this.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
}
|
||||
|
||||
if (shape instanceof Flatten.Line || shape instanceof Flatten.Ray) {
|
||||
diff --git a/node_modules/@flatten-js/core/dist/main.umd.js b/node_modules/@flatten-js/core/dist/main.umd.js
|
||||
index a886341..8a0b6bb 100644
|
||||
--- a/node_modules/@flatten-js/core/dist/main.umd.js
|
||||
+++ b/node_modules/@flatten-js/core/dist/main.umd.js
|
||||
@@ -6603,7 +6603,7 @@
|
||||
|
||||
if (shape instanceof Flatten.Arc) {
|
||||
return shape.vertices.every(vertex => this.contains(vertex)) &&
|
||||
- shape.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
+ this.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
}
|
||||
|
||||
if (shape instanceof Flatten.Line || shape instanceof Flatten.Ray) {
|
||||
diff --git a/node_modules/@flatten-js/core/src/classes/box.js b/node_modules/@flatten-js/core/src/classes/box.js
|
||||
index af21b93..be48775 100644
|
||||
--- a/node_modules/@flatten-js/core/src/classes/box.js
|
||||
+++ b/node_modules/@flatten-js/core/src/classes/box.js
|
||||
@@ -269,7 +269,7 @@ export class Box extends Shape {
|
||||
|
||||
if (shape instanceof Flatten.Arc) {
|
||||
return shape.vertices.every(vertex => this.contains(vertex)) &&
|
||||
- shape.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
+ this.toSegments().every(segment => intersectSegment2Arc(segment, shape).length === 0)
|
||||
}
|
||||
|
||||
if (shape instanceof Flatten.Line || shape instanceof Flatten.Ray) {
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
Aislo B08 2D Drawing - Third-Party Notices
|
||||
|
||||
This application is based in part on OpenWebCAD.
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024 Bert Verhelst
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 729 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="15" height="15">
|
||||
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 10v1H6v-1H5V7h1V6h3v1h1v3H9zM4 5v1h1V5H4zm6 0v1h1V5h-1zm-6 7v-1h1v1H4zm6-1v1h1v-1h-1z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M1 1.5A1.5 1.5 0 012.5 0h8.207L14 3.293V13.5a1.5 1.5 0 01-1.5 1.5h-10A1.5 1.5 0 011 13.5v-12zM3 4h3v1h3V4h3v3h-1v3h1v3H9v-1H6v1H3v-3h1V7H3V4z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
<style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: invert(100%); } }
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const EPSILON = 1e-6;
|
||||
|
||||
/**
|
||||
* Margin around the SVG elements when exporting to a .svg image
|
||||
*/
|
||||
export const SVG_MARGIN = 10;
|
||||
|
||||
/**
|
||||
* Margin around the PDF elements when exporting to a .pdf document
|
||||
*/
|
||||
export const PDF_MARGIN = 10;
|
||||
|
||||
/**
|
||||
* The width and height of the cross that will be drawn instead of the cursor when hovering the drawing canvas
|
||||
*/
|
||||
export const CURSOR_SIZE = 30;
|
||||
|
||||
/**
|
||||
* The background color of the canvas
|
||||
*/
|
||||
export const CANVAS_BACKGROUND_COLOR = '#111';
|
||||
|
||||
/**
|
||||
* The foreground color of the canvas
|
||||
* This will be the color of the lines you draw
|
||||
*/
|
||||
export const CANVAS_FOREGROUND_COLOR = '#fff';
|
||||
|
||||
/**
|
||||
* The color of the angle guide lines that are drawn when you are close to an angle step from the last drawn point
|
||||
*/
|
||||
export const ANGLE_GUIDES_COLOR = '#999999';
|
||||
|
||||
/**
|
||||
* The style of the line dash of the angle guide lines that are drawn when you are close to an angle step from the last drawn point
|
||||
*/
|
||||
export const ANGLE_GUIDES_DASH = [5, 5];
|
||||
|
||||
/**
|
||||
* The color of the snap points that are drawn when you are close to a snap point
|
||||
* These are the shapes you see when you get near an line endpoint or a circle center point, ...
|
||||
*/
|
||||
export const SNAP_POINT_COLOR = '#FFFF00';
|
||||
|
||||
/**
|
||||
* How far a snap point can be from the mouse to still be considered a close snap point
|
||||
*/
|
||||
export const SNAP_POINT_DISTANCE = 15;
|
||||
|
||||
/**
|
||||
* How far the mouse can be from an angle guide line to show the "near angle step" snap point
|
||||
*/
|
||||
export const SNAP_ANGLE_DISTANCE = 15;
|
||||
|
||||
/**
|
||||
* How far the mouse can be from an entity to highlight it and subsequently select it when you click
|
||||
*/
|
||||
export const HIGHLIGHT_ENTITY_DISTANCE = 15;
|
||||
|
||||
/**
|
||||
* The size of the snap point indicator shapes that are shown on active snap points
|
||||
*/
|
||||
export const SNAP_POINT_SIZE = 15;
|
||||
|
||||
/**
|
||||
* How long you need to hover over a snap point to make it a marked snap point that will show angle guides
|
||||
* in milliseconds
|
||||
*/
|
||||
export const HOVERED_SNAP_POINT_TIME = 1000;
|
||||
|
||||
/**
|
||||
* Maximum number of snap points that can be marked at the same time
|
||||
* Marked snap points also get angle guides
|
||||
*/
|
||||
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 = 12;
|
||||
|
||||
/**
|
||||
* Distance that measurement lines stay away from the point of origin of the measurement
|
||||
* Screen pixels (zoom-independent)
|
||||
*/
|
||||
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 = 60;
|
||||
|
||||
/**
|
||||
* Length of the arrow heads for measurements
|
||||
*/
|
||||
export const ARROW_HEAD_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Width of the arrow heads for measurements
|
||||
*/
|
||||
export const ARROW_HEAD_WIDTH = 7;
|
||||
|
||||
/**
|
||||
* Number of decimals to show on measurements. eg: 2 would give a measurement of: 503.32
|
||||
*/
|
||||
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 = 8;
|
||||
|
||||
/**
|
||||
* Size of the measurement labels containing the length of the measurements
|
||||
* Screen pixels (zoom-independent)
|
||||
*/
|
||||
export const MEASUREMENT_FONT_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Colors for the selection rectangle
|
||||
*/
|
||||
export const SELECTION_RECTANGLE_COLOR_INTERSECTION = '#b6ff9a';
|
||||
export const SELECTION_RECTANGLE_COLOR_CONTAINS = '#6899f3';
|
||||
export const SELECTION_RECTANGLE_WIDTH = 1;
|
||||
export const SELECTION_RECTANGLE_STYLE = [5, 5]; // Dashed line
|
||||
|
||||
/**
|
||||
* Angle guides and move tool line styles
|
||||
*/
|
||||
export const GUIDE_LINE_COLOR = '#999';
|
||||
export const GUIDE_LINE_WIDTH = 1;
|
||||
export const GUIDE_LINE_STYLE = [5, 5]; // Dashed line
|
||||
|
||||
/**
|
||||
* Mouse zoom multiplier. Higher zooms faster for each mouse scroll
|
||||
*/
|
||||
export const MOUSE_ZOOM_MULTIPLIER = 0.1;
|
||||
|
||||
/**
|
||||
* Canvas input field offset to mouse location
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_MOUSE_OFFSET = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field width
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* Canvas input field height
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field background color
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#161616';
|
||||
|
||||
/**
|
||||
* Canvas input field text color
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#FFF';
|
||||
|
||||
/**
|
||||
* Canvas input field background color when text is selected
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_SELECTION_BACKGROUND_COLOR = '#1e90ff';
|
||||
|
||||
/**
|
||||
* Canvas input field text color when text is selected
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_SELECTION_TEXT_COLOR = '#000';
|
||||
|
||||
/**
|
||||
* Canvas input field text size in pixels
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_FONT_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Canvas input field instruction text color
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#999';
|
||||
|
||||
/**
|
||||
* Multiplier to determine the pdf line width from the in application line width
|
||||
* This seems to be needed since 1px line widths look quite fat in pdf
|
||||
*/
|
||||
export const PDF_LINE_WIDTH_FACTOR = 0.25;
|
||||
|
||||
export const COLOR_LIST = [
|
||||
'#ffffff',
|
||||
'#2f4f4f',
|
||||
'#800000',
|
||||
'#006400',
|
||||
'#d2b48c',
|
||||
'#ff0000',
|
||||
'#00ced1',
|
||||
'#ffa500',
|
||||
'#ffff00',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#ff00ff',
|
||||
'#1e90ff',
|
||||
'#dda0dd',
|
||||
'#ff1493',
|
||||
'#98fb98',
|
||||
];
|
||||
|
||||
/**
|
||||
* Number to multiply degrees with to end up with the equivalent radians
|
||||
*/
|
||||
export const TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Number to multiply radians with to end up with the equivalent degrees
|
||||
*/
|
||||
export const TO_DEGREES = 180 / Math.PI;
|
||||
@@ -0,0 +1,577 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--cad-title-height: 42px;
|
||||
--cad-ribbon-height: 82px;
|
||||
/* 하단 명령어 입력창 제거 → 높이 0으로 캔버스가 공간을 회수 (추후 사용성 개선 예정) */
|
||||
--cad-command-height: 0px;
|
||||
--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 {
|
||||
display: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 수량 산출표 패널 (CAD 화면 하단 중심, 접이식) — 첨부 양식
|
||||
* -------------------------------------------------------------------------- */
|
||||
.cad-qtable {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
bottom: calc(var(--cad-status-height) + 8px);
|
||||
left: calc(var(--cad-panel-width) + (100vw - var(--cad-panel-width)) / 2);
|
||||
transform: translateX(-50%);
|
||||
/* 표(가장 넓은 자식) 기준 폭 고정 → 접었다 펴도 폭이 변하지 않는다 */
|
||||
width: max-content;
|
||||
max-width: min(1040px, calc(100vw - var(--cad-panel-width) - 24px));
|
||||
border: 1px solid #465565;
|
||||
border-radius: 6px;
|
||||
background: #202b37f2;
|
||||
box-shadow: 0 4px 16px #0009;
|
||||
color: #dce6f2;
|
||||
}
|
||||
|
||||
.cad-qtable__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.cad-qtable__title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cad-qtable__title strong {
|
||||
color: #f7fbff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cad-qtable__title span {
|
||||
overflow: hidden;
|
||||
color: #91a2b5;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-qtable__status {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
.cad-qtable__status[data-confirmed="true"] {
|
||||
background: #1f4d33;
|
||||
color: #7fdca4;
|
||||
}
|
||||
.cad-qtable__status[data-confirmed="false"] {
|
||||
background: #4a3a1c;
|
||||
color: #e6c07a;
|
||||
}
|
||||
.cad-qtable__nav,
|
||||
.cad-qtable__collapse {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
border: 1px solid #40505f;
|
||||
border-radius: 4px;
|
||||
background: #2a3745;
|
||||
color: #cdd8e4;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cad-qtable__nav:hover:not(:disabled),
|
||||
.cad-qtable__collapse:hover {
|
||||
background: #31516b;
|
||||
}
|
||||
.cad-qtable__nav:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cad-qtable__body {
|
||||
padding: 0 8px 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* 접힘: 표는 DOM에 남겨 폭은 유지하되 세로로만 감춘다 */
|
||||
.cad-qtable[data-collapsed="true"] .cad-qtable__body {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
.cad-qtable table {
|
||||
margin: 0 auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.cad-qtable th,
|
||||
.cad-qtable td {
|
||||
padding: 0;
|
||||
border: 1px solid #40505f;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cad-qtable__label {
|
||||
padding: 2px 9px;
|
||||
color: #a9bccd;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: #26333f;
|
||||
}
|
||||
.cad-qtable__label--vertical {
|
||||
width: 18px;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cad-qtable__value input {
|
||||
width: 72px;
|
||||
padding: 3px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #eaf1f8;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.cad-qtable__value input:focus {
|
||||
outline: 2px solid #4ca6e8;
|
||||
outline-offset: -2px;
|
||||
background: #12324a;
|
||||
}
|
||||
.cad-qtable__value--derived input {
|
||||
color: #8fa3b5;
|
||||
background: #1a242e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root {
|
||||
--cad-panel-width: 200px;
|
||||
}
|
||||
.cad-file-state,
|
||||
.cad-statusbar__hint {
|
||||
display: none;
|
||||
}
|
||||
.cad-brand {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import './App.css';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { QuantityPanel } from './components/QuantityPanel.tsx';
|
||||
import { Toolbar } from './components/Toolbar.tsx';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="cad-app">
|
||||
<Toolbar />
|
||||
<QuantityPanel />
|
||||
<ToastContainer position="bottom-right" theme="light" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core';
|
||||
|
||||
export type Shape = Polygon | Segment | Point | Circle | Arc;
|
||||
|
||||
export enum SnapPointType {
|
||||
AngleGuide = 'AngleGuide',
|
||||
LineEndPoint = 'LineEndPoint',
|
||||
Intersection = 'Intersection',
|
||||
CircleCenter = 'CircleCenter',
|
||||
CircleCardinal = 'CircleCardinal',
|
||||
CircleTangent = 'CircleTangent',
|
||||
LineMidPoint = 'LineMidPoint',
|
||||
Point = 'Point',
|
||||
}
|
||||
|
||||
export interface SnapPoint {
|
||||
point: Point;
|
||||
type: SnapPointType;
|
||||
}
|
||||
|
||||
export type SnapPointConfig = Record<SnapPointType, boolean>;
|
||||
|
||||
export interface HoverPoint {
|
||||
snapPoint: SnapPoint;
|
||||
milliSecondsHovered: number;
|
||||
}
|
||||
|
||||
export enum MouseButton {
|
||||
Left = 0, // Main button pressed, usually the left button or the un-initialized state
|
||||
Middle = 1, // Auxiliary button pressed, usually the wheel button or the middle button (if present)
|
||||
Right = 2, // Secondary button pressed, usually the right button
|
||||
Back = 3, // Fourth button, typically the Browser Back button
|
||||
Forward = 4, // Fifth button, typically the Browser Forward button
|
||||
}
|
||||
|
||||
export enum HtmlEvent {
|
||||
UPDATE_STATE = 'UPDATE_STATE',
|
||||
DRAWING_CHANGED = 'DRAWING_CHANGED',
|
||||
}
|
||||
|
||||
/** 부모(B08 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */
|
||||
export interface DesignMeta {
|
||||
kind: 'cross' | 'longitudinal';
|
||||
/** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */
|
||||
title: string;
|
||||
/** 측점 부가 정보 (예: "STA.0+050.000") */
|
||||
info: string;
|
||||
confirmed: boolean;
|
||||
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
|
||||
quantityTable: Record<string, number | null> | null;
|
||||
/** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export interface StateMetaData {
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
name: string;
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export enum LOCAL_STORAGE_KEY {
|
||||
DRAWING = 'OPEN_WEB_CAD__DRAWING',
|
||||
DROPDOWN = 'OPEN_WEB_CAD__DROPDOWN',
|
||||
}
|
||||
|
||||
export interface StartAndEndpointEntity {
|
||||
getStartPoint(): Point;
|
||||
getEndPoint(): Point;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {noop} from 'es-toolkit';
|
||||
import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react';
|
||||
import {Icon, type IconName} from './Icon/Icon.tsx';
|
||||
|
||||
interface ButtonProps {
|
||||
label?: string;
|
||||
title?: string;
|
||||
iconName?: IconName;
|
||||
iconClassname?: string;
|
||||
iconComponent?: ReactNode;
|
||||
active?: boolean;
|
||||
onClick?: (evt: MouseEvent) => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
dataId?: string;
|
||||
size?: 'small' | 'regular';
|
||||
type?: 'regular' | 'transparent';
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}
|
||||
|
||||
export const Button: FC<ButtonProps> = ({
|
||||
label,
|
||||
title,
|
||||
iconName,
|
||||
iconClassname,
|
||||
iconComponent,
|
||||
onClick,
|
||||
active = false,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
dataId,
|
||||
type = 'regular',
|
||||
size = 'regular',
|
||||
left = null,
|
||||
right = null,
|
||||
}) => {
|
||||
const classParts = [
|
||||
'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center hover:bg-blue-500 hover:text-white hover:border-transparent',
|
||||
type === 'regular' ? 'bg-gray-950 text-blue-500' : '',
|
||||
type === 'transparent' ? 'bg-transparent text-blue-500' : '',
|
||||
active ? 'bg-blue-500 text-white border-transparent hover:bg-blue-400' : '',
|
||||
size === 'regular' ? 'pl-2 pr-2 gap-2' : '',
|
||||
size === 'small' ? 'pl-2 pr-2 gap-0' : '',
|
||||
className || '',
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classParts.join(' ')}
|
||||
style={style}
|
||||
data-active={active}
|
||||
data-size={size}
|
||||
data-type={type}
|
||||
onClick={onClick || noop}
|
||||
onKeyUp={(evt) => {
|
||||
if (evt.key === 'Enter' || evt.key === ' ') {
|
||||
onClick?.(evt as unknown as MouseEvent);
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
data-id={dataId}
|
||||
type="button"
|
||||
>
|
||||
{iconComponent ||
|
||||
(iconName && (
|
||||
<Icon
|
||||
name={iconName}
|
||||
className={`${iconClassname} text-blue-700 ${active ? 'text-white' : ''} ${size === 'small' ? 'w-4' : 'w-5'}`}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-row flex-nowrap">{left}</div>
|
||||
{label && <span className="text-nowrap flex-grow text-left">{label}</span>}
|
||||
{children}
|
||||
<div className="flex flex-row flex-nowrap">{right}</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {CSSProperties, FC, ReactNode} from 'react';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
import {LOCAL_STORAGE_KEY} from '../App.types.ts';
|
||||
import {keyboardHandler} from '../helpers/keyboard-handler.ts';
|
||||
import {Button} from './Button.tsx';
|
||||
import {Icon, IconName} from './Icon/Icon.tsx';
|
||||
|
||||
interface DropdownButtonProps {
|
||||
label?: string;
|
||||
title?: string;
|
||||
iconName?: IconName;
|
||||
iconComponent?: ReactNode;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
buttonStyle?: CSSProperties;
|
||||
dataId: string;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownButton: FC<DropdownButtonProps> = ({
|
||||
label,
|
||||
title,
|
||||
iconName,
|
||||
iconComponent,
|
||||
className,
|
||||
style,
|
||||
buttonStyle,
|
||||
dataId,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(
|
||||
`${LOCAL_STORAGE_KEY.DROPDOWN}___${dataId}`,
|
||||
{ defaultValue: defaultOpen }
|
||||
);
|
||||
|
||||
const classParts = [
|
||||
'flex flex-col gap-2 relative',
|
||||
className || '',
|
||||
isOpen ? ' bg-slate-900' : '',
|
||||
];
|
||||
return (
|
||||
<div className={classParts.join(' ')} style={style} data-id={dataId}>
|
||||
<Button
|
||||
iconName={iconName}
|
||||
iconComponent={iconComponent}
|
||||
label={label}
|
||||
title={title}
|
||||
active={isOpen}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={buttonStyle}
|
||||
className={'w-full data-[active=true]:bg-blue-950 data-[active=true]:text-white'}
|
||||
/>
|
||||
<Icon name={IconName.SolidDownSmall} className={'absolute top-2.5 right-1 text-blue-700'} />
|
||||
{isOpen && (
|
||||
<div
|
||||
className="flex flex-row flex-wrap max-w-72 gap-1 pl-1 pb-6"
|
||||
onClick={() => setIsOpen(false)}
|
||||
onKeyUp={keyboardHandler(() => setIsOpen(false))}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {FC, MouseEvent} from 'react';
|
||||
import type {Layer} from '../App.types.ts';
|
||||
import {getNewLayer} from '../helpers/get-new-layer.ts';
|
||||
import {getActiveLayerId, getEntities, getLayers, getSelectedEntities, setEntities, setSelectedEntityIds,} from '../state.ts';
|
||||
import {Button} from './Button';
|
||||
import {IconName} from './Icon/Icon.tsx';
|
||||
|
||||
interface LayerManagerProps {
|
||||
layers: Layer[];
|
||||
setLayers: (layers: Layer[]) => void;
|
||||
activeLayerId: string;
|
||||
setActiveLayerId: (layerId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LayerManager: FC<LayerManagerProps> = ({
|
||||
layers,
|
||||
setLayers,
|
||||
activeLayerId,
|
||||
setActiveLayerId,
|
||||
className,
|
||||
}) => {
|
||||
const handleLayerClick = (evt: MouseEvent, layerId: string) => {
|
||||
evt.stopPropagation();
|
||||
setActiveLayerId(layerId);
|
||||
};
|
||||
|
||||
const handleSelectEntitiesOnLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const entitiesOnLayer = getEntities().filter((entity) => entity.layerId === layerId);
|
||||
setSelectedEntityIds(entitiesOnLayer.map((entity) => entity.id));
|
||||
};
|
||||
|
||||
const handleAssignSelectionToLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const selectedEntities = getSelectedEntities();
|
||||
for (const entity of selectedEntities) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
console.info(`Assigned ${selectedEntities.length} entities to layer`);
|
||||
};
|
||||
|
||||
const handleDeleteLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const entitiesNotOnLayer = getEntities().filter((entity) => entity.layerId !== layerId);
|
||||
setEntities(entitiesNotOnLayer);
|
||||
setLayers(getLayers().filter((layer) => layer.id !== layerId));
|
||||
if (getActiveLayerId() === layerId) {
|
||||
setActiveLayerId(getLayers()[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowHideLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const layer: Layer | undefined = layers.find((layer) => layer.id === layerId);
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
layer.isVisible = !layer.isVisible;
|
||||
setLayers([...layers]);
|
||||
if (getActiveLayerId() === layerId && !layer.isVisible) {
|
||||
setActiveLayerId(getLayers()[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLockUnlockLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const layer: Layer | undefined = layers.find((layer) => layer.id === layerId);
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
layer.isLocked = !layer.isLocked;
|
||||
setLayers([...layers]);
|
||||
if (getActiveLayerId() === layerId && layer.isLocked) {
|
||||
setActiveLayerId(getLayers()[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewLayer = (evt: MouseEvent): void => {
|
||||
evt.stopPropagation();
|
||||
const newLayer: Layer = getNewLayer();
|
||||
setLayers([...getLayers(), newLayer]);
|
||||
setActiveLayerId(newLayer.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layer-manager flex flex-col ${className}`}>
|
||||
<div className="layers-wrapper flex flex-col gap-2">
|
||||
{layers.map((layer) => (
|
||||
<div className="layer flex flex-row relative" key={`layer-${layer.id}`}>
|
||||
<Button
|
||||
label={layer.name}
|
||||
title="Set this layer as active"
|
||||
active={activeLayerId === layer.id}
|
||||
onClick={(evt) => handleLayerClick(evt, layer.id)}
|
||||
className="data-[active=true]:text-white flex-grow"
|
||||
left={
|
||||
<>
|
||||
<Button
|
||||
iconName={layer.isVisible ? IconName.Eye : IconName.EyeClosed}
|
||||
title="Show/hide layer content"
|
||||
onClick={(evt) => handleShowHideLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 hover:bg-blue-300 -ml-1"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
<Button
|
||||
iconName={layer.isLocked ? IconName.Lock : IconName.Unlock}
|
||||
title="Lock/Unlock layer content"
|
||||
onClick={(evt) => handleLockUnlockLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 hover:bg-blue-300"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<Button
|
||||
iconName={IconName.Direction}
|
||||
title="Select entities on this layer"
|
||||
onClick={(evt) => handleSelectEntitiesOnLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 hover:bg-blue-300"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
<Button
|
||||
iconName={IconName.Download}
|
||||
title="Assign current selection to layer"
|
||||
onClick={(evt) => handleAssignSelectionToLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 hover:bg-blue-300"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
<Button
|
||||
iconName={IconName.FolderX}
|
||||
title="delete layer and content"
|
||||
onClick={(evt) => handleDeleteLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 hover:bg-blue-300"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
label="New layer"
|
||||
iconName={IconName.FolderPlus}
|
||||
title="Create a new layer"
|
||||
onClick={(evt) => handleCreateNewLayer(evt)}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type FC, useCallback, useEffect, useState } from 'react';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { requestDrawingNavigation } from '../integration/aislo-drawing-bridge';
|
||||
import { getDesignMeta } from '../state';
|
||||
|
||||
/**
|
||||
* 도면 내비게이션 바 (CAD 화면 하단 중심).
|
||||
* 수량 산출표는 HTML 테이블 대신 도면 자체의 CAD 테이블 레이어(b08-cross-table)로
|
||||
* 렌더되므로, 여기서는 제목·측점정보·확정상태·이전/다음 이동만 담당한다.
|
||||
*/
|
||||
export const QuantityPanel: FC = () => {
|
||||
const [meta, setMeta] = useState(getDesignMeta());
|
||||
|
||||
const refresh = useCallback(() => setMeta(getDesignMeta()), []);
|
||||
useEffect(() => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<section className="cad-qtable controls" data-collapsed="true">
|
||||
<header className="cad-qtable__header">
|
||||
<button
|
||||
type="button"
|
||||
className="cad-qtable__nav"
|
||||
disabled={!meta.hasPrev}
|
||||
title="이전 도면"
|
||||
onClick={() => requestDrawingNavigation('prev')}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="cad-qtable__title">
|
||||
<strong>{meta.title}</strong>
|
||||
{meta.info && <span>{meta.info}</span>}
|
||||
<em
|
||||
className="cad-qtable__status"
|
||||
data-confirmed={meta.confirmed}
|
||||
title={meta.confirmed ? '설계 확정됨' : '미확정'}
|
||||
>
|
||||
{meta.confirmed ? '확정' : '미확정'}
|
||||
</em>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cad-qtable__nav"
|
||||
disabled={!meta.hasNext}
|
||||
title="다음 도면"
|
||||
onClick={() => requestDrawingNavigation('next')}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</header>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,483 @@
|
||||
import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent, type Layer } from '../App.types';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { EntityName } from '../entities/Entity';
|
||||
import { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
getActiveToolActor,
|
||||
getAngleStep,
|
||||
getEntities,
|
||||
getGridEnabled,
|
||||
getInputController,
|
||||
getLastStateInstructions,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
getSnapEnabled,
|
||||
redo,
|
||||
setActiveLayerId,
|
||||
setActiveLineColor,
|
||||
setActiveLineDash,
|
||||
setActiveLineWidth,
|
||||
setActiveTextStyle,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
setEntities,
|
||||
setGridEnabled,
|
||||
setLayers,
|
||||
setSnapEnabled,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
|
||||
import { LayerManager } from './LayerManager';
|
||||
|
||||
interface RibbonTool {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
tool?: Tool;
|
||||
glyph: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [
|
||||
{
|
||||
label: '그리기',
|
||||
tools: [
|
||||
{ label: '선', shortcut: 'L', tool: Tool.LINE, glyph: '╱' },
|
||||
{ label: '폴리선', shortcut: 'PE', tool: Tool.PEDIT, glyph: '⌁' },
|
||||
{ label: '원', shortcut: 'C', tool: Tool.CIRCLE, glyph: '○' },
|
||||
{ label: '사각형', shortcut: 'R', tool: Tool.RECTANGLE, glyph: '□' },
|
||||
{ label: '호', glyph: '◜', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '수정',
|
||||
tools: [
|
||||
{ label: '선택', shortcut: 'S', tool: Tool.SELECT, glyph: '↖' },
|
||||
{ label: '이동', tool: Tool.MOVE, glyph: '✥' },
|
||||
{ label: '복사', tool: Tool.COPY, glyph: '▣' },
|
||||
{ label: '회전', tool: Tool.ROTATE, glyph: '↻' },
|
||||
{ label: '자르기', tool: Tool.ERASER, glyph: '⌫' },
|
||||
{ label: '간격띄우기', glyph: '⇶', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '주석',
|
||||
tools: [
|
||||
{ label: '치수', tool: Tool.MEASUREMENT, glyph: '↔' },
|
||||
{ label: '문자', glyph: 'A', disabled: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const COMMANDS = Object.values(Tool);
|
||||
|
||||
const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
|
||||
{ value: 'solid', label: '실선', dash: undefined },
|
||||
{ value: 'dashed', label: '파선', dash: [10, 5] },
|
||||
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
|
||||
{ value: 'dotted', label: '점선', dash: [2, 4] },
|
||||
];
|
||||
|
||||
const LINE_WIDTHS = [1, 2, 3, 4, 5];
|
||||
|
||||
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
|
||||
|
||||
const dashToLineType = (dash: number[] | undefined): string =>
|
||||
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
|
||||
|
||||
export const Toolbar: FC = () => {
|
||||
const [activeTool, setActiveTool] = useState<Tool>(Tool.LINE);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [layers, setLayersLocal] = useState<Layer[]>(getLayers());
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState(getActiveLayerId());
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [selectedType, setSelectedType] = useState('선택 없음');
|
||||
const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.');
|
||||
const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('layers');
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [snap, setSnap] = useState(getSnapEnabled());
|
||||
const [grid, setGrid] = useState(getGridEnabled());
|
||||
const [ortho, setOrtho] = useState(getAngleStep() === 90);
|
||||
const [command, setCommand] = useState('');
|
||||
const [commandLog, setCommandLog] = useState('준비');
|
||||
const [lineColor, setLineColorLocal] = useState(getActiveLineColor());
|
||||
const [lineWidth, setLineWidthLocal] = useState(getActiveLineWidth());
|
||||
const [lineType, setLineTypeLocal] = useState(dashToLineType(getActiveLineDash()));
|
||||
const [textStyle, setTextStyleLocal] = useState(getActiveTextStyle());
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE);
|
||||
setZoom(getScreenCanvasDrawController().getScreenScale());
|
||||
setLayersLocal([...getLayers()]);
|
||||
setActiveLayerIdLocal(getActiveLayerId());
|
||||
const selected = getSelectedEntities();
|
||||
setSelectedCount(selected.length);
|
||||
setSelectedType(
|
||||
selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음'
|
||||
);
|
||||
setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.');
|
||||
setSnap(getSnapEnabled());
|
||||
setGrid(getGridEnabled());
|
||||
setOrtho(getAngleStep() === 90);
|
||||
setLineColorLocal(getActiveLineColor());
|
||||
setLineWidthLocal(getActiveLineWidth());
|
||||
setLineTypeLocal(dashToLineType(getActiveLineDash()));
|
||||
setTextStyleLocal({ ...getActiveTextStyle() });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-panel-width',
|
||||
panelCollapsed ? '0px' : '248px'
|
||||
);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed]);
|
||||
|
||||
const activateTool = useCallback((tool: Tool) => {
|
||||
const actor = new Actor(TOOL_STATE_MACHINES[tool]);
|
||||
setActiveToolActor(actor);
|
||||
setActiveTool(tool);
|
||||
setCommandLog(`${tool} 명령 실행`);
|
||||
}, []);
|
||||
|
||||
const handleCommand = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = command.trim();
|
||||
if (!value) return;
|
||||
getInputController().submitText(value);
|
||||
setCommandLog(`명령: ${value.toUpperCase()}`);
|
||||
setCommand('');
|
||||
};
|
||||
|
||||
const changeZoom = (factor: number) => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor));
|
||||
setZoom(controller.getScreenScale());
|
||||
};
|
||||
|
||||
/** 선택 객체가 있으면 스타일을 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
|
||||
const applyToSelection = useCallback((mutate: (entity: Entity) => void): boolean => {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return false;
|
||||
for (const entity of selected) {
|
||||
mutate(entity);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleLineColor = (color: string) => {
|
||||
setActiveLineColor(color);
|
||||
setLineColorLocal(color);
|
||||
if (applyToSelection((entity) => (entity.lineColor = color))) {
|
||||
setCommandLog('선택 객체 색상 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineWidth = (width: number) => {
|
||||
setActiveLineWidth(width);
|
||||
setLineWidthLocal(width);
|
||||
if (applyToSelection((entity) => (entity.lineWidth = width))) {
|
||||
setCommandLog('선택 객체 선굵기 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineType = (value: string) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === value)?.dash;
|
||||
setActiveLineDash(dash ? [...dash] : undefined);
|
||||
setLineTypeLocal(value);
|
||||
if (applyToSelection((entity) => (entity.lineDash = dash ? [...dash] : undefined))) {
|
||||
setCommandLog('선택 객체 선종류 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStyle = (patch: Partial<typeof textStyle>) => {
|
||||
setActiveTextStyle(patch);
|
||||
setTextStyleLocal((previous) => ({ ...previous, ...patch }));
|
||||
const applied = applyToSelection((entity) => {
|
||||
if (entity.getType() === EntityName.Text) {
|
||||
(entity as TextEntity).setTextOptions(patch);
|
||||
}
|
||||
});
|
||||
if (applied) {
|
||||
setCommandLog('선택 문자 스타일 변경');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B08 상세 설계</span>
|
||||
</div>
|
||||
<div className="cad-file-state">
|
||||
<span className="cad-file-state__dot" />
|
||||
현재 도면 · 저장됨
|
||||
</div>
|
||||
<div className="cad-title-actions">
|
||||
<button type="button" onClick={() => undo()} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => redo()} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await exportEntitiesToLocalStorage();
|
||||
toast.success('도면을 저장했습니다.');
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button type="button" onClick={() => exportEntitiesToJsonFile()}>
|
||||
내보내기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 도구 리본">
|
||||
{RIBBON_GROUPS.map((group) => (
|
||||
<section className="cad-ribbon-group" key={group.label}>
|
||||
<div className="cad-ribbon-tools">
|
||||
{group.tools.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.label}
|
||||
className="cad-tool"
|
||||
data-active={item.tool === activeTool}
|
||||
disabled={item.disabled}
|
||||
title={
|
||||
item.disabled
|
||||
? `${item.label} 도구는 후속 구현 예정입니다.`
|
||||
: `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`
|
||||
}
|
||||
onClick={() => item.tool && activateTool(item.tool)}
|
||||
>
|
||||
<span className="cad-tool__glyph">{item.glyph}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">{group.label}</span>
|
||||
</section>
|
||||
))}
|
||||
<section className="cad-ribbon-group">
|
||||
<div className="cad-ribbon-tools cad-ribbon-props">
|
||||
<label className="cad-prop" title="선 색상">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={lineColor}
|
||||
onChange={(event) => handleLineColor(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="선 굵기">
|
||||
<span>굵기</span>
|
||||
<select
|
||||
value={lineWidth}
|
||||
onChange={(event) => handleLineWidth(Number(event.target.value))}
|
||||
>
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="선 종류">
|
||||
<span>선종류</span>
|
||||
<select value={lineType} onChange={(event) => handleLineType(event.target.value)}>
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">특성</span>
|
||||
</section>
|
||||
<section className="cad-ribbon-group">
|
||||
<div className="cad-ribbon-tools cad-ribbon-props">
|
||||
<label className="cad-prop" title="폰트">
|
||||
<span>폰트</span>
|
||||
<select
|
||||
value={textStyle.fontFamily}
|
||||
onChange={(event) => handleTextStyle({ fontFamily: event.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{family}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 크기">
|
||||
<span>크기</span>
|
||||
<input
|
||||
type="number"
|
||||
min={4}
|
||||
max={120}
|
||||
value={textStyle.fontSize}
|
||||
onChange={(event) => {
|
||||
const size = Number(event.target.value);
|
||||
if (Number.isFinite(size) && size > 0) {
|
||||
handleTextStyle({ fontSize: size });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 색상">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={textStyle.textColor}
|
||||
onChange={(event) => handleTextStyle({ textColor: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">문자</span>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<aside className="cad-inspector controls" data-collapsed={panelCollapsed}>
|
||||
<button
|
||||
className="cad-inspector__collapse"
|
||||
type="button"
|
||||
onClick={() => setPanelCollapsed((value) => !value)}
|
||||
title={panelCollapsed ? '패널 펼치기' : '패널 접기'}
|
||||
>
|
||||
{panelCollapsed ? '›' : '‹'}
|
||||
</button>
|
||||
{!panelCollapsed && (
|
||||
<>
|
||||
<div className="cad-inspector-tabs">
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'properties'}
|
||||
onClick={() => setPanelTab('properties')}
|
||||
>
|
||||
특성
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'layers'}
|
||||
onClick={() => setPanelTab('layers')}
|
||||
>
|
||||
도면층
|
||||
</button>
|
||||
</div>
|
||||
{panelTab === 'properties' ? (
|
||||
<div className="cad-properties">
|
||||
<h2>{selectedType}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>선택 객체</dt>
|
||||
<dd>{selectedCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도구</dt>
|
||||
<dd>{activeTool}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도면층</dt>
|
||||
<dd>{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<LayerManager
|
||||
className="cad-layer-manager"
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
setLayers={(next) => {
|
||||
setLayersLocal(next);
|
||||
setLayers(next);
|
||||
}}
|
||||
setActiveLayerId={(id) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
setActiveLayerId(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="cad-view-controls controls">
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
onClick={() => {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
refresh();
|
||||
}}
|
||||
title="전체 보기 (도면을 화면 중심에 맞춤)"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
|
||||
+
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => changeZoom(0.8)} title="축소">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="cad-command-area controls">
|
||||
<div className="cad-command-prompt">
|
||||
<span>{commandLog}</span>
|
||||
<strong>{instruction}</strong>
|
||||
</div>
|
||||
<form onSubmit={handleCommand}>
|
||||
<label htmlFor="cad-command">명령:</label>
|
||||
<input
|
||||
id="cad-command"
|
||||
list="cad-command-list"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id="cad-command-list">
|
||||
{COMMANDS.map((item) => (
|
||||
<option value={item} key={item} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="cad-statusbar controls">
|
||||
<button type="button" data-active={snap} onClick={() => setSnapEnabled(!snap)}>
|
||||
OSNAP
|
||||
</button>
|
||||
<button type="button" data-active={ortho} onClick={() => setAngleStep(ortho ? 45 : 90)}>
|
||||
직교
|
||||
</button>
|
||||
<button type="button" data-active={grid} onClick={() => setGridEnabled(!grid)}>
|
||||
그리드
|
||||
</button>
|
||||
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 팬 · Esc: 취소</span>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { CANVAS_INPUT_FIELD_FONT_SIZE } from '../App.consts.ts';
|
||||
|
||||
export interface DrawController {
|
||||
getCanvasSize(): Point;
|
||||
getScreenScale(): number;
|
||||
getScreenOffset(): Point;
|
||||
|
||||
worldToTarget(worldCoordinate: Point): Point;
|
||||
worldsToTargets(worldCoordinates: Point[]): Point[];
|
||||
targetToWorld(screenCoordinate: Point): Point;
|
||||
targetsToWorlds(screenCoordinates: Point[]): Point[];
|
||||
|
||||
setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash?: number[],
|
||||
): void;
|
||||
setFillStyles(fillColor: string): void;
|
||||
clear(): void;
|
||||
drawLine(startPoint: Point, endPoint: Point): void;
|
||||
drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockwise: boolean,
|
||||
): void;
|
||||
drawText(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}>,
|
||||
): void;
|
||||
drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
): void;
|
||||
fillPolygon(...points: Point[]): void;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEXT_OPTIONS = {
|
||||
textDirection: new Vector(1, 0),
|
||||
textAlign: 'center' as const,
|
||||
textColor: '#FFF',
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
fontFamily: 'sans-serif',
|
||||
};
|
||||
@@ -0,0 +1,655 @@
|
||||
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:
|
||||
* 0, 0 X
|
||||
* +---------->
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* Y v
|
||||
*
|
||||
*
|
||||
* World coordinate system:
|
||||
* Y ^
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* +---------->
|
||||
* 0, 0 X
|
||||
*
|
||||
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
|
||||
*/
|
||||
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
|
||||
const BATCH_LOD_PX = 0.5;
|
||||
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
|
||||
const BATCH_MIN_TEXT_PX = 2;
|
||||
|
||||
export class ScreenCanvasDrawController implements DrawController {
|
||||
private screenOffset: Point = new Point(0, 0);
|
||||
private screenScale = 1;
|
||||
private screenMouseLocation: Point;
|
||||
private canvasSize: Point = new Point(100, 100);
|
||||
|
||||
// Style-run batching (static scene rendering): consecutive stroke calls
|
||||
// with the same style are collected into one Path2D and stroked once.
|
||||
private batching = false;
|
||||
private batchPath: Path2D | null = null;
|
||||
private batchKey: string | null = null;
|
||||
private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null;
|
||||
private batchLastX = Number.NaN;
|
||||
private batchLastY = Number.NaN;
|
||||
|
||||
constructor(private context: CanvasRenderingContext2D) {
|
||||
this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2);
|
||||
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
|
||||
}
|
||||
|
||||
public getCanvasSize() {
|
||||
return this.canvasSize;
|
||||
}
|
||||
|
||||
public setCanvasSize(newCanvasSize: Point) {
|
||||
this.canvasSize = newCanvasSize;
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
return this.screenScale;
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
console.log(`set screen scale: ${newScreenScale}`);
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
public getScreenOffset() {
|
||||
return this.screenOffset;
|
||||
}
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
public setScreenMouseLocation(newScreenMouseLocation: Point): void {
|
||||
this.screenMouseLocation = newScreenMouseLocation;
|
||||
triggerReactUpdate(StateVariable.screenMouseLocation);
|
||||
}
|
||||
|
||||
public getWorldMouseLocation(): Point {
|
||||
return this.targetToWorld(this.screenMouseLocation);
|
||||
}
|
||||
|
||||
public getScreenMouseLocation(): Point {
|
||||
return this.screenMouseLocation;
|
||||
}
|
||||
|
||||
public panScreen(screenOffsetX: number, screenOffsetY: number) {
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x - screenOffsetX / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes the deltaY from the mouse wheel event and zooms the screen in or out
|
||||
* The location of the mouse in world space is preserved
|
||||
* @param deltaY
|
||||
*/
|
||||
public zoomScreen(deltaY: number) {
|
||||
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
|
||||
const oldScreenScale = this.getScreenScale();
|
||||
|
||||
const newScreenScale =
|
||||
oldScreenScale * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
|
||||
this.setScreenScale(newScreenScale);
|
||||
|
||||
// now get the location of the cursor in world space again
|
||||
// It will have changed because the scale has changed,
|
||||
// but we can offset our world now to fix the zoom location in screen space,
|
||||
// because we know how much it changed laterally between the two spatial scales.
|
||||
const worldMouseLocationAfterZoom = this.getWorldMouseLocation();
|
||||
|
||||
const offsetAdjustment = new Point(
|
||||
worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x,
|
||||
worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y
|
||||
);
|
||||
|
||||
// Adjust the screen offset to maintain the cursor position
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x + offsetAdjustment.x,
|
||||
this.screenOffset.y + offsetAdjustment.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 도면(모든 엔티티)을 화면 중심에 여백을 두고 배치한다.
|
||||
* 가로/세로 중 더 제약이 큰 축에 맞춰 배율을 정하고, 도면 중심이 화면 중심에
|
||||
* 오도록 screenOffset(월드 좌표)을 역산한다. (기존 구현은 화면 픽셀 여백값을
|
||||
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
|
||||
*/
|
||||
public zoomToFitScreen() {
|
||||
const entities = getEntities();
|
||||
if (!entities.length) return;
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from World Space --> Screen Space
|
||||
*/
|
||||
public worldToTarget(worldCoordinate: Point): Point {
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
worldCoordinate.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.x
|
||||
),
|
||||
mapNumberRange(
|
||||
worldCoordinate.y,
|
||||
this.screenOffset.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.y
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
||||
return worldCoordinates.map(this.worldToTarget.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from Screen Space --> World Space
|
||||
* (0, 0) (1920, 0)
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* convert to
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* (0, 0) (1920, 0)
|
||||
*/
|
||||
public targetToWorld(screenCoordinate: Point): Point {
|
||||
// map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale()
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
screenCoordinate.x,
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale
|
||||
),
|
||||
mapNumberRange(
|
||||
screenCoordinate.y,
|
||||
0,
|
||||
this.canvasSize.y,
|
||||
this.screenOffset.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
||||
return screenCoordinates.map(this.targetToWorld.bind(this));
|
||||
}
|
||||
|
||||
public setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash: number[] = []
|
||||
) {
|
||||
if (this.batching) {
|
||||
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
|
||||
const effectiveDash = isSelected ? [5, 5] : dash;
|
||||
const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`;
|
||||
if (key !== this.batchKey) {
|
||||
this.flushBatch();
|
||||
this.batchKey = key;
|
||||
this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.strokeStyle = color;
|
||||
this.context.lineWidth = lineWidth;
|
||||
this.context.setLineDash(dash);
|
||||
|
||||
if (isHighlighted) {
|
||||
this.context.lineWidth = lineWidth + 1;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
this.context.setLineDash([5, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start style-run batching: consecutive stroke calls sharing a style are
|
||||
* accumulated into a single Path2D and stroked once (with sub-pixel
|
||||
* segment decimation). Used while rendering the static scene cache.
|
||||
*/
|
||||
public beginBatch() {
|
||||
this.flushBatch();
|
||||
this.batching = true;
|
||||
this.batchKey = null;
|
||||
this.batchStyle = null;
|
||||
}
|
||||
|
||||
public endBatch() {
|
||||
this.flushBatch();
|
||||
this.batching = false;
|
||||
this.batchKey = null;
|
||||
this.batchStyle = null;
|
||||
}
|
||||
|
||||
private flushBatch() {
|
||||
if (this.batchPath && this.batchStyle) {
|
||||
this.context.strokeStyle = this.batchStyle.color;
|
||||
this.context.lineWidth = this.batchStyle.lineWidth;
|
||||
this.context.setLineDash(this.batchStyle.dash);
|
||||
// Round caps/joins replace the per-segment endpoint dots drawn in
|
||||
// the unbatched path (see _drawRoundedEndpoint)
|
||||
this.context.lineCap = 'round';
|
||||
this.context.lineJoin = 'round';
|
||||
this.context.stroke(this.batchPath);
|
||||
this.context.lineCap = 'butt';
|
||||
this.context.lineJoin = 'miter';
|
||||
}
|
||||
this.batchPath = null;
|
||||
this.batchLastX = Number.NaN;
|
||||
this.batchLastY = Number.NaN;
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
this.context.fillStyle = fillColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily redirect all draw calls to another 2d context (eg an
|
||||
* offscreen canvas used as static scene cache), reusing the current
|
||||
* offset/scale/canvasSize without touching state or react triggers.
|
||||
*/
|
||||
public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) {
|
||||
const originalContext = this.context;
|
||||
this.context = temporaryContext;
|
||||
try {
|
||||
renderFunction();
|
||||
} finally {
|
||||
this.context = originalContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a pre-rendered bitmap (static scene cache) onto the canvas at a
|
||||
* pixel offset. Used while panning to avoid re-stroking every entity.
|
||||
*/
|
||||
public blitImage(source: CanvasImageSource, dx: number, dy: number) {
|
||||
this.context.drawImage(source, dx, dy);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.canvasSize === null) return;
|
||||
|
||||
if (!this.context) return;
|
||||
if (this.batching) this.flushBatch();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line from startPoint to endPoint and auto converts to screen space first
|
||||
* @param worldStartPoint
|
||||
* @param worldEndPoint
|
||||
*/
|
||||
public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {
|
||||
const [screenStartPoint, screenEndPoint] = this.worldsToTargets([
|
||||
worldStartPoint,
|
||||
worldEndPoint,
|
||||
]);
|
||||
|
||||
this.drawLineScreen(screenStartPoint, screenEndPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs to be public to draw UI that is zoom independent, like snap point indicators
|
||||
* @param screenStartPoint
|
||||
* @param screenEndPoint
|
||||
*/
|
||||
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {
|
||||
if (this.batching) {
|
||||
const startX = screenStartPoint.x;
|
||||
const startY = this.canvasSize.y - screenStartPoint.y;
|
||||
const endX = screenEndPoint.x;
|
||||
const endY = this.canvasSize.y - screenEndPoint.y;
|
||||
if (!this.batchPath) this.batchPath = new Path2D();
|
||||
// Chain break: start is not where the previous segment ended
|
||||
const chainBroken =
|
||||
Math.abs(startX - this.batchLastX) > BATCH_LOD_PX ||
|
||||
Math.abs(startY - this.batchLastY) > BATCH_LOD_PX;
|
||||
if (chainBroken || Number.isNaN(this.batchLastX)) {
|
||||
this.batchPath.moveTo(startX, startY);
|
||||
this.batchLastX = startX;
|
||||
this.batchLastY = startY;
|
||||
}
|
||||
const isTinyStep =
|
||||
Math.abs(endX - this.batchLastX) < BATCH_LOD_PX &&
|
||||
Math.abs(endY - this.batchLastY) < BATCH_LOD_PX;
|
||||
// Decimate sub-pixel steps inside a chain; isolated segments always draw
|
||||
if (!isTinyStep || chainBroken) {
|
||||
this.batchPath.lineTo(endX, endY);
|
||||
this.batchLastX = endX;
|
||||
this.batchLastY = endY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y);
|
||||
this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y);
|
||||
this.context.stroke();
|
||||
|
||||
const lineWidth = this.context.lineWidth;
|
||||
const style = this.context.strokeStyle as string;
|
||||
this._drawRoundedEndpoint(screenStartPoint, lineWidth, style);
|
||||
this._drawRoundedEndpoint(screenEndPoint, lineWidth, style);
|
||||
}
|
||||
|
||||
private _drawRoundedEndpoint(screenPoint: Point, lineWidth: number, style: string): void {
|
||||
this.context.fillStyle = style;
|
||||
this.context.beginPath();
|
||||
this.context.arc(
|
||||
screenPoint.x,
|
||||
this.canvasSize.y - screenPoint.y,
|
||||
lineWidth / 2,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
this.context.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI
|
||||
* @param centerPoint
|
||||
* @param radius
|
||||
* @param startAngle
|
||||
* @param endAngle
|
||||
* @param counterClockWise
|
||||
*/
|
||||
public drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean
|
||||
) {
|
||||
const screenCenterPoint = this.worldToTarget(centerPoint);
|
||||
const screenRadius = radius * this.screenScale;
|
||||
// Flip angles over the x-axis, because we go from world to screen coordinates which flips the y-axis direction
|
||||
this.drawArcScreen(screenCenterPoint, screenRadius, -startAngle, -endAngle, counterClockWise);
|
||||
}
|
||||
|
||||
public drawArcScreen(
|
||||
screenCenterPoint: Point,
|
||||
screenRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean
|
||||
) {
|
||||
if (this.batching) {
|
||||
if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom
|
||||
if (!this.batchPath) this.batchPath = new Path2D();
|
||||
const centerX = screenCenterPoint.x;
|
||||
const centerY = this.canvasSize.y - screenCenterPoint.y;
|
||||
this.batchPath.moveTo(
|
||||
centerX + screenRadius * Math.cos(startAngle),
|
||||
centerY + screenRadius * Math.sin(startAngle)
|
||||
);
|
||||
this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise);
|
||||
// Arc end becomes the new chain tail
|
||||
this.batchLastX = centerX + screenRadius * Math.cos(endAngle);
|
||||
this.batchLastY = centerY + screenRadius * Math.sin(endAngle);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.arc(
|
||||
screenCenterPoint.x,
|
||||
this.canvasSize.y - screenCenterPoint.y,
|
||||
screenRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockWise
|
||||
);
|
||||
this.context.stroke();
|
||||
|
||||
const lineWidth = this.context.lineWidth;
|
||||
const style = this.context.strokeStyle as string;
|
||||
|
||||
// Calculate arc endpoints
|
||||
const startScreenX = screenCenterPoint.x + screenRadius * Math.cos(startAngle);
|
||||
// Y is inverted in canvas, but also for the arc angles, so we subtract from canvasSize.y and then add sin
|
||||
const startScreenY =
|
||||
this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(startAngle);
|
||||
const endScreenX = screenCenterPoint.x + screenRadius * Math.cos(endAngle);
|
||||
const endScreenY = this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(endAngle);
|
||||
|
||||
// Convert back to Point objects, note that _drawRoundedEndpoint expects y to be from top of canvas
|
||||
const arcStartPoint = new Point(startScreenX, this.canvasSize.y - startScreenY);
|
||||
const arcEndPoint = new Point(endScreenX, this.canvasSize.y - endScreenY);
|
||||
|
||||
this._drawRoundedEndpoint(arcStartPoint, lineWidth, style);
|
||||
this._drawRoundedEndpoint(arcEndPoint, lineWidth, style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawText(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {}
|
||||
): void {
|
||||
const screenBasePoint = this.worldToTarget(basePoint);
|
||||
this.drawTextScreen(label, screenBasePoint, {
|
||||
...options,
|
||||
fontSize: options.fontSize ? options.fontSize * this.screenScale : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawTextScreen(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {}
|
||||
): void {
|
||||
const opts = {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
if (this.batching) {
|
||||
if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom
|
||||
this.flushBatch(); // keep draw order: strokes so far go under this text
|
||||
}
|
||||
this.context.save();
|
||||
this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y);
|
||||
const angle = getAngleWithXAxis(
|
||||
new Point(0, 0),
|
||||
new Point(opts.textDirection.x, -opts.textDirection.y)
|
||||
);
|
||||
this.context.rotate(angle);
|
||||
this.context.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
||||
this.context.textAlign = opts.textAlign;
|
||||
this.context.fillStyle = opts.textColor;
|
||||
this.context.textBaseline = 'middle';
|
||||
this.context.fillText(label, 0, 0);
|
||||
this.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an image to the canvas using world coordinates
|
||||
* @param imageElement
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param angle
|
||||
*/
|
||||
public drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number
|
||||
): void {
|
||||
if (this.batching) this.flushBatch();
|
||||
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
|
||||
new Point(xMin, yMin),
|
||||
new Point(width, height),
|
||||
]);
|
||||
const screenXMin = screenBasePoint.x;
|
||||
const screenYMin = screenBasePoint.y;
|
||||
const screenWidth = screenDimensions.x;
|
||||
const screenHeight = screenDimensions.y;
|
||||
const screenCenterX = screenXMin + screenWidth / 2;
|
||||
const screenCenterY = screenYMin + screenHeight / 2;
|
||||
|
||||
// Rotate and translate context
|
||||
this.context.translate(screenCenterX, screenCenterY);
|
||||
this.context.rotate(angle);
|
||||
|
||||
// Draw image
|
||||
this.context.drawImage(
|
||||
imageElement,
|
||||
-screenWidth / 2,
|
||||
-screenHeight / 2,
|
||||
screenWidth,
|
||||
screenHeight
|
||||
);
|
||||
|
||||
// Reset context
|
||||
this.context.rotate(-angle);
|
||||
this.context.translate(-screenCenterX, -screenCenterY);
|
||||
}
|
||||
|
||||
public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
const screenMinPoint = this.worldToTarget(new Point(xMin, yMin));
|
||||
|
||||
this.fillRectScreen(
|
||||
screenMinPoint.x,
|
||||
screenMinPoint.y,
|
||||
width * this.screenScale,
|
||||
height * this.screenScale,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill rectangle with color, but interpret the provided coordinates as screen coordinates
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param color
|
||||
*/
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
if (this.batching) this.flushBatch();
|
||||
// TODO see if we need to replace this with a call to fillPolygon
|
||||
this.context.fillStyle = color;
|
||||
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill polygon with color
|
||||
* @param points
|
||||
*/
|
||||
public fillPolygon(...points: Point[]) {
|
||||
if (this.batching) this.flushBatch();
|
||||
const screenPoints = points.map(this.worldToTarget.bind(this));
|
||||
this.context.beginPath();
|
||||
screenPoints.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
this.context.moveTo(screenPoint.x, this.canvasSize.y - screenPoint.y);
|
||||
} else {
|
||||
this.context.lineTo(screenPoint.x, this.canvasSize.y - screenPoint.y);
|
||||
}
|
||||
});
|
||||
this.context.closePath();
|
||||
this.context.fill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {Point, Vector} from '@flatten-js/core';
|
||||
import {toast} from 'react-toastify';
|
||||
import {SVG_MARGIN, TO_DEGREES} from '../App.consts.ts';
|
||||
import type {TextOptions} from '../entities/TextEntity.ts';
|
||||
import {isLengthEqual} from '../helpers/is-length-equal.ts';
|
||||
import {StateVariable} from '../helpers/undo-stack.ts';
|
||||
import {triggerReactUpdate} from '../state.ts';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
|
||||
|
||||
export class SvgDrawController implements DrawController {
|
||||
private lineColor = '#000';
|
||||
private lineWidth = 1;
|
||||
private lineDash: number[] = [];
|
||||
private svgStrings: string[] = [];
|
||||
private fillColor = '#000';
|
||||
private screenScale = 1;
|
||||
private screenOffset = new Point(0, 0);
|
||||
|
||||
constructor(
|
||||
private boundingBoxMinX: number,
|
||||
private boundingBoxMinY: number,
|
||||
private boundingBoxMaxX: number,
|
||||
private boundingBoxMaxY: number
|
||||
) {
|
||||
this.setScreenOffset(new Point(boundingBoxMinX - SVG_MARGIN, boundingBoxMinY + SVG_MARGIN));
|
||||
}
|
||||
|
||||
getCanvasSize(): Point {
|
||||
return new Point(
|
||||
this.boundingBoxMaxX - this.boundingBoxMinX,
|
||||
this.boundingBoxMaxY - this.boundingBoxMinY
|
||||
);
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
return this.screenScale;
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
public getScreenOffset() {
|
||||
return this.screenOffset;
|
||||
}
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from World Space --> Screen Space
|
||||
*/
|
||||
public worldToTarget(worldCoordinate: Point): Point {
|
||||
return new Point(
|
||||
(worldCoordinate.x - this.screenOffset.x) * this.screenScale,
|
||||
-1 * ((worldCoordinate.y - this.screenOffset.y) * this.screenScale - this.getCanvasSize().y)
|
||||
);
|
||||
}
|
||||
|
||||
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
||||
return worldCoordinates.map(this.worldToTarget.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from Screen Space --> World Space
|
||||
* (0, 0) (1920, 0)
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* convert to
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* (0, 0) (1920, 0)
|
||||
*/
|
||||
public targetToWorld(screenCoordinate: Point): Point {
|
||||
return new Point(
|
||||
screenCoordinate.x / this.screenScale + this.screenOffset.x,
|
||||
this.getCanvasSize().y - screenCoordinate.y / this.screenScale + this.screenOffset.y
|
||||
);
|
||||
}
|
||||
|
||||
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
||||
return screenCoordinates.map(this.targetToWorld.bind(this));
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.svgStrings = [];
|
||||
}
|
||||
|
||||
public setLineStyles(
|
||||
_isHighlighted: boolean,
|
||||
_isSelected: boolean,
|
||||
lineColor: string,
|
||||
lineWidth: number,
|
||||
lineDash: number[] = []
|
||||
) {
|
||||
if (
|
||||
lineColor.toLowerCase() === '#fff' ||
|
||||
lineColor.toLowerCase() === '#ffffff' ||
|
||||
lineColor === 'white'
|
||||
) {
|
||||
this.lineColor = '#000';
|
||||
} else if (
|
||||
lineColor.toLowerCase() === '#000' ||
|
||||
lineColor.toLowerCase() === '#000000' ||
|
||||
lineColor === 'black'
|
||||
) {
|
||||
this.lineColor = '#FFF';
|
||||
} else {
|
||||
this.lineColor = lineColor;
|
||||
}
|
||||
this.lineWidth = lineWidth;
|
||||
this.lineDash = lineDash;
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
if (
|
||||
fillColor.toLowerCase() === '#fff' ||
|
||||
fillColor.toLowerCase() === '#ffffff' ||
|
||||
fillColor === 'white'
|
||||
) {
|
||||
this.fillColor = '#000';
|
||||
} else if (
|
||||
fillColor.toLowerCase() === '#000' ||
|
||||
fillColor.toLowerCase() === '#000000' ||
|
||||
fillColor === 'black'
|
||||
) {
|
||||
this.fillColor = '#FFF';
|
||||
} else {
|
||||
this.fillColor = fillColor;
|
||||
}
|
||||
}
|
||||
|
||||
public export() {
|
||||
const boundingBoxWidth = Math.ceil(
|
||||
this.boundingBoxMaxX - this.boundingBoxMinX + SVG_MARGIN * 2
|
||||
);
|
||||
const boundingBoxHeight = Math.ceil(
|
||||
this.boundingBoxMaxY - this.boundingBoxMinY + SVG_MARGIN * 2
|
||||
);
|
||||
|
||||
const svgLines = [
|
||||
`<svg width="${boundingBoxWidth}" height="${boundingBoxHeight}" viewBox="0 0 ${boundingBoxWidth} ${boundingBoxHeight}" xmlns="http://www.w3.org/2000/svg">\n`,
|
||||
` <rect x="0" y="0" width="${boundingBoxWidth}" height="${boundingBoxHeight}" fill="#FFF" />\n`,
|
||||
...this.svgStrings.map((svgString) => `\t${svgString}\n`),
|
||||
'</svg>',
|
||||
];
|
||||
|
||||
return {
|
||||
svgLines,
|
||||
width: boundingBoxWidth,
|
||||
height: boundingBoxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
public drawLine(startPoint: Point, endPoint: Point): void {
|
||||
const [canvasStartPoint, canvasEndPoint] = this.worldsToTargets([startPoint, endPoint]);
|
||||
this.svgStrings.push(
|
||||
`<line x1="${canvasStartPoint.x}" y1="${canvasStartPoint.y}" x2="${canvasEndPoint.x}" y2="${canvasEndPoint.y}" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round" />`
|
||||
);
|
||||
}
|
||||
|
||||
public drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockwise: boolean
|
||||
) {
|
||||
const canvasCenterPoint = this.worldToTarget(centerPoint);
|
||||
const canvasRadius = radius * this.screenScale;
|
||||
|
||||
// Calculate start and end points of the arc
|
||||
let startPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
||||
startPoint = startPoint.rotate(startAngle, canvasCenterPoint);
|
||||
let endPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
||||
endPoint = endPoint.rotate(endAngle, canvasCenterPoint);
|
||||
|
||||
// Normalize the sweep angle to be between 0 and 2π
|
||||
let sweep = endAngle - startAngle;
|
||||
if (counterClockwise && sweep > 0) {
|
||||
sweep -= 2 * Math.PI;
|
||||
} else if (!counterClockwise && sweep < 0) {
|
||||
sweep += 2 * Math.PI;
|
||||
}
|
||||
|
||||
const largeArcFlag = Math.abs(sweep) > Math.PI ? '1' : '0';
|
||||
const sweepFlag = counterClockwise ? '0' : '1'; // SVG: 0 = CCW, 1 = CW
|
||||
|
||||
const attributes = `fill="none" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round"`;
|
||||
let svgPath: string;
|
||||
if (isLengthEqual(sweep, 2 * Math.PI)) {
|
||||
svgPath = `<circle cx="${canvasCenterPoint.x}" cy="${canvasCenterPoint.y}" r="${canvasRadius}" ${attributes} />`;
|
||||
} else {
|
||||
svgPath = `<path d="M${startPoint.x},${startPoint.y} A${canvasRadius},${canvasRadius} 0 ${largeArcFlag},${sweepFlag} ${endPoint.x},${endPoint.y}" ${attributes} />`;
|
||||
}
|
||||
|
||||
// Push the SVG path data string to the svgStrings array
|
||||
this.svgStrings.push(svgPath);
|
||||
}
|
||||
|
||||
public drawText(label: string, basePoint: Point, options?: Partial<TextOptions>): void {
|
||||
const canvasBasePoint = this.worldToTarget(basePoint);
|
||||
|
||||
const textOptions = {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
|
||||
let finalTextColor = textOptions.textColor;
|
||||
const lowerCaseTextColor = textOptions.textColor.toLowerCase();
|
||||
if (
|
||||
lowerCaseTextColor === '#fff' ||
|
||||
lowerCaseTextColor === '#ffffff' ||
|
||||
lowerCaseTextColor === 'white'
|
||||
) {
|
||||
finalTextColor = '#000'; // Change to black if current color is white
|
||||
}
|
||||
// No need to handle black to white, as SVG background is white.
|
||||
// Other colors will remain as they are.
|
||||
|
||||
let transformAttribute = '';
|
||||
if (textOptions.textDirection) {
|
||||
const angle = textOptions.textDirection.angleTo(new Vector(1, 0)) * TO_DEGREES;
|
||||
transformAttribute = `transform="rotate(${angle}, ${canvasBasePoint.x}, ${canvasBasePoint.y})"`;
|
||||
}
|
||||
|
||||
let textAnchorAttribute = '';
|
||||
if (textOptions.textAlign === 'center') {
|
||||
textAnchorAttribute = 'text-anchor="middle"';
|
||||
}
|
||||
|
||||
this.svgStrings.push(
|
||||
// Use finalTextColor here
|
||||
`<text x="${canvasBasePoint.x}" y="${canvasBasePoint.y}" fill="${finalTextColor}" font-size="${textOptions.fontSize}" font-family="${textOptions.fontFamily}" ${transformAttribute} ${textAnchorAttribute}>${label}</text>`
|
||||
);
|
||||
}
|
||||
|
||||
public drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number
|
||||
): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageElement.width;
|
||||
canvas.height = imageElement.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
toast.warn('Failed to create canvas context');
|
||||
console.warn('Failed to create canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(imageElement, 0, 0);
|
||||
const dataUri = canvas.toDataURL(); // Convert the image to Base64
|
||||
|
||||
const svgWidth = width * this.getScreenScale();
|
||||
const svgHeight = height * this.getScreenScale();
|
||||
|
||||
const worldCenterX = xMin + width / 2;
|
||||
const worldCenterY = yMin + height / 2;
|
||||
|
||||
const targetCenter = this.worldToTarget(new Point(worldCenterX, worldCenterY));
|
||||
|
||||
const svgX = targetCenter.x - svgWidth / 2;
|
||||
const svgY = targetCenter.y - svgHeight / 2;
|
||||
|
||||
let transformAttribute = '';
|
||||
if (angle !== 0) {
|
||||
const svgAngleDegrees = angle * (180 / Math.PI);
|
||||
transformAttribute = `transform="rotate(${svgAngleDegrees}, ${targetCenter.x}, ${targetCenter.y})"`;
|
||||
}
|
||||
|
||||
// noinspection HtmlUnknownAttribute
|
||||
this.svgStrings.push(
|
||||
`<image href="${dataUri}" x="${svgX}" y="${svgY}" width="${svgWidth}" height="${svgHeight}" ${transformAttribute} />`
|
||||
);
|
||||
}
|
||||
|
||||
public fillPolygon(...points: Point[]) {
|
||||
if (points.length < 3) return; // Polygon needs at least 3 points
|
||||
const canvasPoints = this.worldsToTargets(points);
|
||||
|
||||
const pointsString = canvasPoints.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
this.svgStrings.push(`<polygon points="${pointsString}" fill="${this.fillColor}" />`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {type Arc, Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {EPSILON} from "../App.consts.ts";
|
||||
import {ArcEntity} from './ArcEntity.ts';
|
||||
|
||||
describe('ArcEntity.distanceTo', () => {
|
||||
/**
|
||||
* ---X---
|
||||
* ----- -----
|
||||
* -- --
|
||||
*/
|
||||
it('distance to point on arc', () => {
|
||||
const arc = new ArcEntity('layer1', new Point(0, 0), 1, Math.PI / 4, (3 * Math.PI) / 4, true);
|
||||
const point = (arc.getShape() as Arc).pointAtLength(
|
||||
(arc.getShape() as Arc).length / 2
|
||||
) as Point;
|
||||
const distanceInfo = arc.distanceTo(point);
|
||||
expect(distanceInfo).toBeDefined();
|
||||
if (!distanceInfo) return;
|
||||
expect(distanceInfo[0]).to.equal(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* ------- X
|
||||
* ----- -----
|
||||
* -- --
|
||||
*/
|
||||
it('distance to point outside arc', () => {
|
||||
const arc = new ArcEntity('layer1', new Point(0, 0), 1, Math.PI / 4, (3 * Math.PI) / 4, true);
|
||||
const point = new Point(2, 2);
|
||||
const distanceInfo = arc.distanceTo(point);
|
||||
expect(distanceInfo).toBeDefined();
|
||||
if (!distanceInfo) return;
|
||||
expect(distanceInfo[0]).to.be.closeTo(Math.sqrt(2 * 2 + 2 * 2) - 1, EPSILON);
|
||||
});
|
||||
|
||||
/**
|
||||
* -------
|
||||
* ----- -----
|
||||
* -- --
|
||||
* - X -
|
||||
*/
|
||||
it('distance to point inside arc', () => {
|
||||
const arc = new ArcEntity('layer1', new Point(0, 0), 1, Math.PI / 4, (3 * Math.PI) / 4, true);
|
||||
const point = new Point(0.5, 0.5);
|
||||
const distanceInfo = arc.distanceTo(point);
|
||||
expect(distanceInfo).toBeDefined();
|
||||
if (!distanceInfo) return;
|
||||
expect(distanceInfo[0]).to.be.closeTo(1 - Math.sqrt(0.5 * 0.5 + 0.5 * 0.5), EPSILON);
|
||||
});
|
||||
|
||||
it('should return distance from a point to an arc', () => {
|
||||
const arc = new ArcEntity('layer1', new Point(20, 20), 20, 0, 2 * Math.PI * 0.75, true);
|
||||
const distanceInfo = arc.distanceTo(new Point(20 - 14.14, 20 + 14.14));
|
||||
expect(distanceInfo).toBeDefined();
|
||||
if (!distanceInfo) return;
|
||||
expect(distanceInfo[0]).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
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();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private arc: Arc;
|
||||
|
||||
public static getAngle(centerPoint: Point, pointOnArc: Point): number {
|
||||
return new Line(centerPoint, pointOnArc).slope;
|
||||
}
|
||||
|
||||
constructor(
|
||||
layerId: string,
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockwise = true
|
||||
) {
|
||||
this.layerId = layerId;
|
||||
this.arc = new Arc(centerPoint, radius, startAngle, endAngle, counterClockwise);
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawArc(
|
||||
this.arc.center,
|
||||
this.arc.r.valueOf(),
|
||||
this.arc?.startAngle || 0,
|
||||
this.arc?.endAngle || 2 * Math.PI,
|
||||
this.arc.counterClockwise
|
||||
);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.arc = this.arc.translate(x, y);
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
const center = scalePoint(this.arc.center, scaleOrigin, scaleFactor);
|
||||
this.arc = new Arc(
|
||||
center,
|
||||
this.arc.r.valueOf() * scaleFactor,
|
||||
this.arc.startAngle,
|
||||
this.arc.endAngle,
|
||||
this.arc.counterClockwise
|
||||
);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.arc = this.arc.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
const mirroredCenter = mirrorPointOverAxis(this.arc.center, mirrorAxis);
|
||||
mirrorAxis.getAngle();
|
||||
this.arc = new Arc(
|
||||
mirroredCenter,
|
||||
this.arc.r.valueOf(),
|
||||
-this.arc.startAngle,
|
||||
-this.arc.endAngle,
|
||||
!this.arc.counterClockwise
|
||||
);
|
||||
}
|
||||
|
||||
public clone(): Entity {
|
||||
if (this.arc) {
|
||||
const { center, r, startAngle, endAngle, counterClockwise } = this.arc;
|
||||
return new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
center,
|
||||
r.valueOf(),
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockwise
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
return this.arc.intersect(box).length > 0;
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
return box.contains(this.arc);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return this.arc.box;
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.arc;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return [
|
||||
{
|
||||
point: this.arc.center,
|
||||
type: SnapPointType.CircleCenter,
|
||||
},
|
||||
{
|
||||
point: this.arc.start,
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: this.arc.end,
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
// TODO add cardinal points if they lay on the arc
|
||||
// TODO add tangent points from mouse location to circle
|
||||
];
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
const otherShape = entity.getShape();
|
||||
if (!otherShape) {
|
||||
return [];
|
||||
}
|
||||
return this.arc.intersect(otherShape);
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.arc.center;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
return this.arc.distanceTo(shape);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return (
|
||||
this.arc.svg({
|
||||
strokeWidth: this.lineWidth,
|
||||
stroke: getExportColor(this.lineColor),
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Arc;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
if (!this.arc) {
|
||||
return false;
|
||||
}
|
||||
return this.arc.contains(point);
|
||||
}
|
||||
|
||||
public cutAtPoints(pointsOnShape: Point[]): ArcEntity[] {
|
||||
const points = uniqWith([this.arc.start, this.arc.end, ...pointsOnShape], isPointEqual);
|
||||
|
||||
const sortedPoints = sortPointsOnArc(points, this.arc.center, this.arc.start);
|
||||
|
||||
const segmentArcs: ArcEntity[] = [];
|
||||
for (let i = 0; i < sortedPoints.length - 1; i++) {
|
||||
const point1 = sortedPoints[i];
|
||||
const point2 = sortedPoints[i + 1];
|
||||
|
||||
const startAngle = ArcEntity.getAngle(this.arc.center, point1);
|
||||
const endAngle = ArcEntity.getAngle(this.arc.center, point2);
|
||||
|
||||
const newArc = new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
this.arc.center,
|
||||
Number(this.arc.r),
|
||||
startAngle,
|
||||
endAngle,
|
||||
this.arc.counterClockwise
|
||||
);
|
||||
newArc.lineColor = this.lineColor;
|
||||
newArc.lineWidth = this.lineWidth;
|
||||
segmentArcs.push(newArc);
|
||||
}
|
||||
return segmentArcs;
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<ArcJsonData> | null> {
|
||||
if (!this.arc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
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 },
|
||||
radius: this.arc.r.valueOf(),
|
||||
startAngle: this.arc.startAngle,
|
||||
endAngle: this.arc.endAngle,
|
||||
counterClockwise: this.arc.counterClockwise,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<ArcJsonData>): Promise<ArcEntity> {
|
||||
if (jsonEntity.type !== EntityName.Arc) {
|
||||
throw new Error('Invalid Entity type in JSON');
|
||||
}
|
||||
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Arc: missing shapeData');
|
||||
}
|
||||
|
||||
const center = new Point(jsonEntity.shapeData.center.x, jsonEntity.shapeData.center.y);
|
||||
const radius = jsonEntity.shapeData.radius;
|
||||
const startAngle = jsonEntity.shapeData.startAngle;
|
||||
const endAngle = jsonEntity.shapeData.endAngle;
|
||||
const counterClockwise = jsonEntity.shapeData.counterClockwise;
|
||||
|
||||
const arcEntity = new ArcEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
center,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockwise
|
||||
);
|
||||
arcEntity.id = jsonEntity.id;
|
||||
arcEntity.lineColor = jsonEntity.lineColor;
|
||||
arcEntity.lineWidth = jsonEntity.lineWidth;
|
||||
arcEntity.lineDash = jsonEntity.lineDash;
|
||||
return arcEntity;
|
||||
}
|
||||
|
||||
public getStartPoint(): Point {
|
||||
return this.arc.start;
|
||||
}
|
||||
|
||||
public getEndPoint(): Point {
|
||||
return this.arc.end;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArcJsonData {
|
||||
center: { x: number; y: number };
|
||||
radius: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
counterClockwise: boolean;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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();
|
||||
public fillColor = '#fff';
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] = [];
|
||||
public layerId: string;
|
||||
|
||||
// 3 corners of the arrow head
|
||||
constructor(
|
||||
layerId: string,
|
||||
private p1: Point, // Tip of the arrow
|
||||
private p2: Point,
|
||||
private p3: Point
|
||||
) {
|
||||
this.layerId = layerId;
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? false,
|
||||
parentSelected ?? false,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawLine(this.p1, this.p2);
|
||||
drawController.drawLine(this.p2, this.p3);
|
||||
drawController.drawLine(this.p3, this.p1);
|
||||
|
||||
drawController.setFillStyles(this.fillColor);
|
||||
drawController.fillPolygon(this.p1, this.p2, this.p3);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.p1 = this.p1.translate(x, y);
|
||||
this.p2 = this.p2.translate(x, y);
|
||||
this.p3 = this.p3.translate(x, y);
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
this.p1 = scalePoint(this.p1, scaleOrigin, scaleFactor);
|
||||
this.p2 = scalePoint(this.p2, scaleOrigin, scaleFactor);
|
||||
this.p3 = scalePoint(this.p3, scaleOrigin, scaleFactor);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.p1 = this.p1.rotate(angle, rotateOrigin);
|
||||
this.p2 = this.p2.rotate(angle, rotateOrigin);
|
||||
this.p3 = this.p3.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
this.p1 = mirrorPointOverAxis(this.p1, mirrorAxis);
|
||||
this.p2 = mirrorPointOverAxis(this.p2, mirrorAxis);
|
||||
this.p3 = mirrorPointOverAxis(this.p3, mirrorAxis);
|
||||
}
|
||||
|
||||
public clone(): ArrowHeadEntity {
|
||||
return new ArrowHeadEntity(this.layerId, this.p1.clone(), this.p2.clone(), this.p3.clone());
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
return (
|
||||
new Segment(this.p1, this.p2).intersect(box).length > 0 ||
|
||||
new Segment(this.p2, this.p3).intersect(box).length > 0 ||
|
||||
new Segment(this.p3, this.p1).intersect(box).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
return box.contains(this.p1) || box.contains(this.p2) || box.contains(this.p3);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return new Box(
|
||||
min([this.p1.x, this.p2.x, this.p3.x]),
|
||||
min([this.p1.y, this.p2.y, this.p3.y]),
|
||||
max([this.p1.x, this.p2.x, this.p3.x]),
|
||||
max([this.p1.y, this.p2.y, this.p3.y])
|
||||
);
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return null; // TODO see why we need to get the shape out of an entity
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public getIntersections(_entity: Entity): Point[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.p1;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
return this.p1.distanceTo(shape);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.ArrowHead;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
return (
|
||||
new Segment(this.p1, this.p2).contains(point) ||
|
||||
new Segment(this.p2, this.p3).contains(point) ||
|
||||
new Segment(this.p3, this.p1).contains(point)
|
||||
);
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<ArrowHeadJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
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 },
|
||||
p2: { x: this.p2.x, y: this.p2.y },
|
||||
p3: { x: this.p3.x, y: this.p3.y },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(
|
||||
jsonEntity: JsonEntity<ArrowHeadJsonData>
|
||||
): Promise<ArrowHeadEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Arrow: missing shapeData');
|
||||
}
|
||||
const p1 = new Point(jsonEntity.shapeData.p1.x, jsonEntity.shapeData.p1.y);
|
||||
const p2 = new Point(jsonEntity.shapeData.p2.x, jsonEntity.shapeData.p2.y);
|
||||
const p3 = new Point(jsonEntity.shapeData.p3.x, jsonEntity.shapeData.p3.y);
|
||||
const lineEntity = new ArrowHeadEntity(jsonEntity.layerId || getActiveLayerId(), p1, p2, p3);
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash ?? [];
|
||||
return lineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArrowHeadJsonData {
|
||||
p1: { x: number; y: number };
|
||||
p2: { x: number; y: number };
|
||||
p3: { x: number; y: number };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private circle: Circle;
|
||||
|
||||
constructor(layerId: string, centerPointOrCircle?: Point | Circle, radius?: number) {
|
||||
this.layerId = layerId;
|
||||
if (centerPointOrCircle instanceof Circle) {
|
||||
this.circle = centerPointOrCircle as Circle;
|
||||
} else {
|
||||
this.circle = new Circle(centerPointOrCircle as Point, radius as number);
|
||||
}
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawArc(this.circle.center, this.circle.r, 0, 2 * Math.PI, false);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
if (this.circle) {
|
||||
this.circle = this.circle?.translate(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
const center = scalePoint(this.circle.center, scaleOrigin, scaleFactor);
|
||||
this.circle = new Circle(center, this.circle.r.valueOf() * scaleFactor);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.circle = this.circle.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
const mirroredCenter = mirrorPointOverAxis(this.circle.center, mirrorAxis);
|
||||
mirrorAxis.getAngle();
|
||||
this.circle = new Circle(mirroredCenter, this.circle.r.valueOf());
|
||||
}
|
||||
|
||||
public clone(): Entity {
|
||||
if (this.circle) {
|
||||
return new CircleEntity(getActiveLayerId(), this.circle.clone());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
if (!this.circle) {
|
||||
return false;
|
||||
}
|
||||
return this.circle.intersect(box).length > 0;
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
if (!this.circle) {
|
||||
return false;
|
||||
}
|
||||
return box.contains(this.circle);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return this.circle.box;
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.circle;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
if (!this.circle?.center) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
point: this.circle.center,
|
||||
type: SnapPointType.CircleCenter,
|
||||
},
|
||||
{
|
||||
point: new Point(this.circle.center.x + this.circle.r, this.circle.center.y),
|
||||
type: SnapPointType.CircleCardinal,
|
||||
},
|
||||
{
|
||||
point: new Point(this.circle.center.x - this.circle.r, this.circle.center.y),
|
||||
type: SnapPointType.CircleCardinal,
|
||||
},
|
||||
{
|
||||
point: new Point(this.circle.center.x, this.circle.center.y + this.circle.r),
|
||||
type: SnapPointType.CircleCardinal,
|
||||
},
|
||||
{
|
||||
point: new Point(this.circle.center.x, this.circle.center.y - this.circle.r),
|
||||
type: SnapPointType.CircleCardinal,
|
||||
},
|
||||
// TODO add tangent points from mouse location to circle
|
||||
];
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
const otherShape = entity.getShape();
|
||||
if (!this.circle || !otherShape) {
|
||||
return [];
|
||||
}
|
||||
return this.circle.intersect(otherShape);
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.circle.center;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
if (!this.circle) {
|
||||
return null;
|
||||
}
|
||||
return this.circle.distanceTo(shape);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return (
|
||||
this.circle.svg({
|
||||
strokeWidth: this.lineWidth,
|
||||
stroke: getExportColor(this.lineColor),
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Circle;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
if (!this.circle) {
|
||||
return false;
|
||||
}
|
||||
return this.circle.contains(point);
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<CircleJsonData> | null> {
|
||||
if (!this.circle) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
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 },
|
||||
radius: this.circle?.r,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<CircleJsonData>): Promise<CircleEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Circle: missing shapeData');
|
||||
}
|
||||
const center = new Point(jsonEntity.shapeData.center.x, jsonEntity.shapeData.center.y);
|
||||
const radius = jsonEntity.shapeData.radius;
|
||||
const circleEntity = new CircleEntity(jsonEntity.layerId || getActiveLayerId(), center, radius);
|
||||
circleEntity.id = jsonEntity.id;
|
||||
circleEntity.lineColor = jsonEntity.lineColor;
|
||||
circleEntity.lineWidth = jsonEntity.lineWidth;
|
||||
circleEntity.lineDash = jsonEntity.lineDash;
|
||||
return circleEntity;
|
||||
}
|
||||
|
||||
public getRadius(): number {
|
||||
return this.circle?.r ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CircleJsonData {
|
||||
center: { x: number; y: number };
|
||||
radius: number;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
// Used for comparing entities
|
||||
id: string;
|
||||
lineColor: string;
|
||||
lineWidth: number;
|
||||
lineDash: number[] | undefined;
|
||||
layerId: string;
|
||||
draw(drawController: DrawController, highlighted?: boolean, selected?: boolean): void;
|
||||
|
||||
/**
|
||||
* Translate an entity by x and y amount
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
move(x: number, y: number): void;
|
||||
scale(scaleOrigin: Point, scaleFactor: number): void;
|
||||
rotate(rotateOrigin: Point, angle: number): void;
|
||||
mirror(mirrorAxis: LineEntity): void;
|
||||
clone(): Entity;
|
||||
getBoundingBox(): Box;
|
||||
intersectsWithBox(box: Box): boolean;
|
||||
isContainedInBox(box: Box): boolean;
|
||||
getBoundingBox(): Box;
|
||||
getFirstPoint(): Point | null;
|
||||
getShape(): Shape | null;
|
||||
getSnapPoints(): SnapPoint[];
|
||||
getIntersections(entity: Entity): Point[];
|
||||
distanceTo(shape: Shape): [number, Segment] | null;
|
||||
getSvgString(): string | null;
|
||||
getType(): EntityName;
|
||||
containsPointOnShape(point: Point): boolean;
|
||||
toJson(): Promise<JsonEntity | null>;
|
||||
// static fromJson(jsonEntity: JsonEntity): Promise<Entity | null>;
|
||||
}
|
||||
|
||||
export enum EntityName {
|
||||
Line = 'Line',
|
||||
Circle = 'Circle',
|
||||
Arc = 'Arc',
|
||||
Rectangle = 'Rectangle',
|
||||
Point = 'Point',
|
||||
Image = 'Image',
|
||||
Measurement = 'Measurement',
|
||||
ArrowHead = 'ArrowHead',
|
||||
Text = 'Text',
|
||||
PolyLine = 'PolyLine',
|
||||
}
|
||||
|
||||
export type ShapeJsonData =
|
||||
| RectangleJsonData
|
||||
| CircleJsonData
|
||||
| ArcJsonData
|
||||
| LineJsonData
|
||||
| PointJsonData
|
||||
| ImageJsonData
|
||||
| ArrowHeadJsonData
|
||||
| TextJsonData;
|
||||
|
||||
export interface JsonEntity<TShapeJsonData = ShapeJsonData> {
|
||||
id: string;
|
||||
type: EntityName;
|
||||
lineColor: string;
|
||||
lineWidth: number;
|
||||
lineDash?: number[];
|
||||
layerId: string;
|
||||
shapeData: TShapeJsonData | null;
|
||||
children?: JsonEntity<ShapeJsonData>[];
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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';
|
||||
|
||||
export class ImageEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private imageElement: HTMLImageElement;
|
||||
private polygon: Polygon;
|
||||
private angle: number;
|
||||
|
||||
constructor(
|
||||
layerId: string,
|
||||
imgData: HTMLImageElement,
|
||||
startPointOrPolygon?: Point | Polygon,
|
||||
endPointOrAngle?: Point | number,
|
||||
angle = 0
|
||||
) {
|
||||
this.layerId = layerId;
|
||||
this.imageElement = imgData;
|
||||
if (startPointOrPolygon instanceof Polygon) {
|
||||
this.polygon = startPointOrPolygon as Polygon;
|
||||
} else {
|
||||
this.polygon = twoPointBoxToPolygon(startPointOrPolygon as Point, endPointOrAngle as Point);
|
||||
}
|
||||
if (endPointOrAngle instanceof Point) {
|
||||
this.angle = angle;
|
||||
} else {
|
||||
this.angle = endPointOrAngle as number;
|
||||
}
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
for (const edge of polygonToSegments(this.polygon)) {
|
||||
drawController.drawLine(edge.start, edge.end);
|
||||
}
|
||||
|
||||
const width = this.polygon.box.width;
|
||||
const height = this.polygon.box.height;
|
||||
|
||||
// Draw image
|
||||
drawController.drawImage(
|
||||
this.imageElement,
|
||||
this.polygon.box.xmin,
|
||||
this.polygon.box.ymin,
|
||||
width,
|
||||
height,
|
||||
this.angle
|
||||
);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.polygon = this.polygon.translate(new Vector(x, y));
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
const center = this.polygon.box.center;
|
||||
const newCenter = scalePoint(center, scaleOrigin, scaleFactor);
|
||||
this.polygon = this.polygon.translate(
|
||||
new Vector(newCenter.x - center.x, newCenter.y - center.y)
|
||||
);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.polygon = this.polygon.rotate(angle, rotateOrigin);
|
||||
this.angle += angle; // Need to keep track of the angle for drawing the image
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
const mirroredVertices = this.polygon.vertices.map((p) => mirrorPointOverAxis(p, mirrorAxis));
|
||||
const mirroredAngle = mirrorAngleOverAxis(this.angle, mirrorAxis);
|
||||
// TODO mirror image pixels
|
||||
// this.imageElement = new HTMLImageElement(
|
||||
// this.imageElement.
|
||||
// )
|
||||
this.polygon = new Polygon(mirroredVertices);
|
||||
this.angle = mirroredAngle;
|
||||
}
|
||||
|
||||
public clone(): ImageEntity {
|
||||
const clonedImage = document.createElement('img');
|
||||
clonedImage.src = this.imageElement.src;
|
||||
return new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone());
|
||||
}
|
||||
|
||||
// TODO add destroy method to cleanup this.imageElement.src
|
||||
|
||||
public intersectsWithBox(selectionBox: Box): boolean {
|
||||
return Relations.relate(this.polygon, selectionBox).B2B.length > 0;
|
||||
}
|
||||
|
||||
public isContainedInBox(selectionBox: Box): boolean {
|
||||
return selectionBox.contains(this.polygon);
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
const distanceInfos: [number, Segment][] = polygonToSegments(this.polygon).map((segment) =>
|
||||
segment.distanceTo(shape)
|
||||
);
|
||||
let shortestDistanceInfo: [number, Segment | null] = [Number.MAX_SAFE_INTEGER, null];
|
||||
for (const distanceInfo of distanceInfos) {
|
||||
if (distanceInfo[0] < shortestDistanceInfo[0]) {
|
||||
shortestDistanceInfo = distanceInfo;
|
||||
}
|
||||
}
|
||||
return shortestDistanceInfo as [number, Segment];
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return this.polygon.box;
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.polygon;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
const corners = this.polygon.vertices;
|
||||
const edges = polygonToSegments(this.polygon);
|
||||
return [
|
||||
{
|
||||
point: corners[0],
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: corners[1],
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: corners[2],
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: corners[3],
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: edges[0].middle(),
|
||||
type: SnapPointType.LineMidPoint,
|
||||
},
|
||||
{
|
||||
point: edges[1].middle(),
|
||||
type: SnapPointType.LineMidPoint,
|
||||
},
|
||||
{
|
||||
point: edges[2].middle(),
|
||||
type: SnapPointType.LineMidPoint,
|
||||
},
|
||||
{
|
||||
point: edges[3].middle(),
|
||||
type: SnapPointType.LineMidPoint,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
const otherShape = entity.getShape();
|
||||
if (!otherShape) {
|
||||
return [];
|
||||
}
|
||||
return polygonToSegments(this.polygon).flatMap((segment) => {
|
||||
return segment.intersect(otherShape);
|
||||
});
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.polygon?.vertices[0] || null;
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return this.polygon.svg({
|
||||
strokeWidth: this.lineWidth,
|
||||
stroke: getExportColor(this.lineColor),
|
||||
});
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Image;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Flatten.Point): boolean {
|
||||
return polygonToSegments(this.polygon).some((segment) => segment.contains(point));
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<ImageJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
type: EntityName.Image,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
points: this.polygon.vertices.map((vertex) => ({
|
||||
x: vertex.x,
|
||||
y: vertex.y,
|
||||
})),
|
||||
imageData: this.imageElement.currentSrc,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<ImageJsonData>): Promise<ImageEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Image: missing shapeData');
|
||||
}
|
||||
const rectangle = new Polygon(
|
||||
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y))
|
||||
);
|
||||
const image = new Image();
|
||||
image.src = jsonEntity.shapeData.imageData;
|
||||
const rectangleEntity = new ImageEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
image,
|
||||
rectangle
|
||||
);
|
||||
rectangleEntity.id = jsonEntity.id;
|
||||
rectangleEntity.lineColor = jsonEntity.lineColor;
|
||||
rectangleEntity.lineWidth = jsonEntity.lineWidth;
|
||||
rectangleEntity.lineDash = jsonEntity.lineDash;
|
||||
return rectangleEntity;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageJsonData {
|
||||
points: { x: number; y: number }[];
|
||||
imageData: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {LineEntity} from "./LineEntity.ts";
|
||||
import {TO_DEGREES} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
|
||||
describe('getAngle', () => {
|
||||
it('should return 0 for a horizontal line', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(1, 0);
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2);
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('should return 90 degrees for a vertical line', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(0, 1);
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2);
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('should return 45 degrees for a slope of 1', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(1, 1);
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2);
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it('should return 135 degrees for a slope of -1', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(-1, 1);
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2);
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(135);
|
||||
});
|
||||
|
||||
it('should return 30 degrees for a slope of √3/3', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(1, Math.tan(Math.PI / 6)); // tan(30°) = 1/√3 ≈ 0.577
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2);
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('should handle NaN slope gracefully', () => {
|
||||
const point1 = new Point(0, 0);
|
||||
const point2 = new Point(0, 0);
|
||||
const lineEntity = new LineEntity(getActiveLayerId(), point1, point2); // zero-length segment
|
||||
expect(lineEntity.getAngle() * TO_DEGREES).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private segment: Segment;
|
||||
|
||||
constructor(layerId: string, p1?: Point | Segment, p2?: Point) {
|
||||
this.layerId = layerId;
|
||||
if (p1 instanceof Segment) {
|
||||
this.segment = p1;
|
||||
} else {
|
||||
this.segment = new Segment(p1, p2);
|
||||
}
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
const startPoint = new Point(this.segment.start.x, this.segment.start.y);
|
||||
const endPoint = new Point(this.segment.end.x, this.segment.end.y);
|
||||
drawController.drawLine(startPoint, endPoint);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.segment = this.segment.translate(x, y);
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
const newStart = scalePoint(this.segment.start, scaleOrigin, scaleFactor);
|
||||
const newEnd = scalePoint(this.segment.end, scaleOrigin, scaleFactor);
|
||||
this.segment = new Segment(newStart, newEnd);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.segment = this.segment.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
const mirroredStart = mirrorPointOverAxis(this.segment.start, mirrorAxis);
|
||||
const mirroredEnd = mirrorPointOverAxis(this.segment.end, mirrorAxis);
|
||||
this.segment = new Segment(mirroredStart, mirroredEnd);
|
||||
}
|
||||
|
||||
public clone(): LineEntity {
|
||||
return new LineEntity(getActiveLayerId(), this.segment.clone());
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
return this.segment.intersect(box).length > 0;
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
return box.contains(this.segment);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return this.segment.box;
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.segment;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return [
|
||||
{
|
||||
point: this.segment.start,
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: this.segment.end,
|
||||
type: SnapPointType.LineEndPoint,
|
||||
},
|
||||
{
|
||||
point: this.segment.middle(),
|
||||
type: SnapPointType.LineMidPoint,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
const otherShape = entity.getShape();
|
||||
if (!otherShape) {
|
||||
return [];
|
||||
}
|
||||
return this.segment.intersect(otherShape);
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.segment.start;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
return this.segment.distanceTo(shape);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return (
|
||||
this.segment.svg({
|
||||
strokeWidth: this.lineWidth,
|
||||
stroke: getExportColor(this.lineColor),
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Line;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
return this.segment.contains(point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns angle of the line with the x-axis in radians
|
||||
*/
|
||||
public getAngle(): number {
|
||||
return getAngleWithXAxis(this.segment.start, this.segment.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts the line at the given points and returns a list of new lines in order from the start point of the original line
|
||||
* @param pointsOnShape
|
||||
*/
|
||||
public cutAtPoints(pointsOnShape: Point[]): Entity[] {
|
||||
const points = uniqWith([this.segment.start, this.segment.end, ...pointsOnShape], isPointEqual);
|
||||
const sortLinesByDistanceToStartPoint = sortBy(points, [
|
||||
(point: Point): number => pointDistance(this.segment.start, point),
|
||||
]);
|
||||
|
||||
// Convert the points back into line segments
|
||||
const lineSegments: Entity[] = [];
|
||||
// Until length - 2, so we can combine start points with endpoints
|
||||
for (let i = 0; i < sortLinesByDistanceToStartPoint.length - 1; i++) {
|
||||
lineSegments.push(
|
||||
new LineEntity(
|
||||
getActiveLayerId(),
|
||||
sortLinesByDistanceToStartPoint[i],
|
||||
sortLinesByDistanceToStartPoint[i + 1]
|
||||
)
|
||||
);
|
||||
}
|
||||
return lineSegments;
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<LineJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
type: EntityName.Line,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
startPoint: {
|
||||
x: this.segment.start.x,
|
||||
y: this.segment.start.y,
|
||||
},
|
||||
endPoint: { x: this.segment.end.x, y: this.segment.end.y },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<LineJsonData>): Promise<LineEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Line: missing shapeData');
|
||||
}
|
||||
const startPoint = new Point(
|
||||
jsonEntity.shapeData.startPoint.x,
|
||||
jsonEntity.shapeData.startPoint.y
|
||||
);
|
||||
const endPoint = new Point(jsonEntity.shapeData.endPoint.x, jsonEntity.shapeData.endPoint.y);
|
||||
const lineEntity = new LineEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
startPoint,
|
||||
endPoint
|
||||
);
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash;
|
||||
return lineEntity;
|
||||
}
|
||||
|
||||
public getStartPoint(): Point {
|
||||
return this.segment.start;
|
||||
}
|
||||
|
||||
public getEndPoint(): Point {
|
||||
return this.segment.end;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LineJsonData {
|
||||
startPoint: { x: number; y: number };
|
||||
endPoint: { x: number; y: number };
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import {type Box, Line, Point, Vector} from '@flatten-js/core'; // Added Box, Segment for completeness
|
||||
import {round} from 'es-toolkit'; // 1. Mocking for ../state.ts
|
||||
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest';
|
||||
import {EPSILON, MEASUREMENT_DECIMAL_PLACES, MEASUREMENT_FONT_SIZE, MEASUREMENT_LABEL_OFFSET,} from '../App.consts';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
|
||||
import {isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {MeasurementEntity} from './MeasurementEntity';
|
||||
|
||||
// 1. Mocking for ../state.ts
|
||||
vi.mock('../state.ts', () => ({
|
||||
getActiveLayerId: () => 'mockLayerIdGlobal',
|
||||
isEntityHighlighted: vi.fn(),
|
||||
isEntitySelected: vi.fn(),
|
||||
}));
|
||||
|
||||
// 2. Helper function for point comparison
|
||||
function expectPointToBeCloseTo(
|
||||
actualPoint: Point | undefined,
|
||||
expectedPoint: Point,
|
||||
precision = 3
|
||||
) {
|
||||
expect(actualPoint).toBeDefined();
|
||||
if (!actualPoint) return;
|
||||
expect(actualPoint.x).toBeCloseTo(expectedPoint.x, precision);
|
||||
expect(actualPoint.y).toBeCloseTo(expectedPoint.y, precision);
|
||||
}
|
||||
|
||||
// 3. Test Suite: 'MeasurementEntity text orientation in draw() method'
|
||||
describe('MeasurementEntity text orientation in draw() method', () => {
|
||||
const mockDrawController = {
|
||||
drawText: vi.fn(),
|
||||
setLineStyles: vi.fn(),
|
||||
setFillStyles: vi.fn(),
|
||||
drawLine: vi.fn(),
|
||||
fillPolygon: vi.fn(),
|
||||
getScreenScale: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockDrawController.drawText.mockClear();
|
||||
mockDrawController.setLineStyles.mockClear();
|
||||
mockDrawController.setFillStyles.mockClear();
|
||||
mockDrawController.drawLine.mockClear();
|
||||
mockDrawController.fillPolygon.mockClear();
|
||||
mockDrawController.getScreenScale.mockClear().mockReturnValue(1);
|
||||
|
||||
(isEntitySelected as Mock).mockReturnValue(false);
|
||||
(isEntityHighlighted as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
const runTextOrientationTest = (
|
||||
startPoint: Point,
|
||||
endPoint: Point,
|
||||
offsetPoint: Point,
|
||||
expectedDirectionX: number,
|
||||
expectedDirectionY: number
|
||||
) => {
|
||||
const measurement = new MeasurementEntity(
|
||||
'mockLayerIdGlobal',
|
||||
startPoint,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
measurement.lineColor = '#fff';
|
||||
measurement.draw(mockDrawController as unknown as DrawController);
|
||||
|
||||
// Check if drawText was called (it shouldn't be if points are equal)
|
||||
if (startPoint.equalTo(endPoint)) {
|
||||
expect(mockDrawController.drawText).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockDrawController.drawText).toHaveBeenCalledOnce();
|
||||
const callArgs = mockDrawController.drawText.mock.calls[0];
|
||||
const textOptions = callArgs[2];
|
||||
const actualDirection = textOptions.textDirection as Vector;
|
||||
const epsilon = 1e-5;
|
||||
|
||||
expect(actualDirection.x).toBeCloseTo(expectedDirectionX, epsilon);
|
||||
expect(actualDirection.y).toBeCloseTo(expectedDirectionY, epsilon);
|
||||
expect(textOptions.textAlign).toBe('center');
|
||||
expect(textOptions.fontSize).toBe(MEASUREMENT_FONT_SIZE);
|
||||
expect(textOptions.textColor).toBe('#fff');
|
||||
};
|
||||
|
||||
it('should orient text left-to-right for horizontal line, text below', () => {
|
||||
runTextOrientationTest(new Point(0, 0), new Point(10, 0), new Point(5, -5), 1, 0);
|
||||
});
|
||||
it('should orient text left-to-right for horizontal line, text above', () => {
|
||||
runTextOrientationTest(new Point(0, 0), new Point(10, 0), new Point(5, 5), 1, 0);
|
||||
});
|
||||
it('should orient text bottom-to-top for vertical line, text right', () => {
|
||||
runTextOrientationTest(new Point(0, 0), new Point(0, 10), new Point(5, 5), 0, -1);
|
||||
});
|
||||
it('should orient text bottom-to-top for vertical line, text left (flips from top-to-bottom)', () => {
|
||||
runTextOrientationTest(new Point(0, 0), new Point(0, 10), new Point(-5, 5), 0, -1);
|
||||
});
|
||||
it('should orient text correctly for a 45 degree line, offset "below-right"', () => {
|
||||
runTextOrientationTest(
|
||||
new Point(0, 0),
|
||||
new Point(10, 10),
|
||||
new Point(10, 0),
|
||||
Math.sqrt(2) / 2,
|
||||
Math.sqrt(2) / 2
|
||||
);
|
||||
});
|
||||
it('should orient text correctly for a -45 degree line, offset "above-right"', () => {
|
||||
runTextOrientationTest(
|
||||
new Point(0, 0),
|
||||
new Point(10, -10),
|
||||
new Point(10, 0),
|
||||
Math.sqrt(2) / 2,
|
||||
-Math.sqrt(2) / 2
|
||||
);
|
||||
});
|
||||
it('should not draw text if start and end points are the same', () => {
|
||||
runTextOrientationTest(new Point(0, 0), new Point(0, 0), new Point(5, 5), 0, 0); // Expected directions are dummy here
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeasurementEntity.getBoundingBox', () => {
|
||||
const layerId = 'test-layer'; // Changed to specified layerId
|
||||
|
||||
it('should return a bounding box that includes the text label', () => {
|
||||
const startPoint = new Point(0, 0);
|
||||
const endPoint = new Point(100, 0); // Distance = 100
|
||||
const offsetPoint = new Point(50, 50); // Text above the line
|
||||
|
||||
const entity = new MeasurementEntity(layerId, startPoint, endPoint, offsetPoint);
|
||||
const actualBoundingBox: Box = entity.getBoundingBox();
|
||||
|
||||
// --- Start: Recalculate expected text properties (similar to getDrawPoints and draw) ---
|
||||
|
||||
const lineStartToEnd = new Line(startPoint, endPoint); // Corrected Line creation
|
||||
const [, segmentToOffset] = offsetPoint.distanceTo(lineStartToEnd);
|
||||
const closestPointToOffsetOnLine = segmentToOffset.end;
|
||||
|
||||
let vectorPerpendicularFromLineTowardsOffsetPoint: Vector; // Correct type
|
||||
if (closestPointToOffsetOnLine.equalTo(offsetPoint)) {
|
||||
// This case implies offsetPoint is on the line, so norm might be ambiguous.
|
||||
// For this specific test (50,50) and line (0,0)-(100,0), closestPointToOffsetOnLine is (50,0).
|
||||
// So the 'else' branch will be taken.
|
||||
// If offsetPoint was, for example, (50,0), then norm would be (0,1) or (0,-1)
|
||||
// The original implementation of getDrawPoints uses lineStartToEnd.norm in this case.
|
||||
// Let's assume standard orientation for norm (e.g. points "up" or "left" from segment direction)
|
||||
vectorPerpendicularFromLineTowardsOffsetPoint = lineStartToEnd.norm.clone();
|
||||
// Check if the offsetPoint is "on the other side" of the norm
|
||||
// For horizontal line (0,0) to (100,0), norm is (0,1)
|
||||
// If offsetPoint was (50, -1), it's on the other side, so norm should be (0,-1)
|
||||
// This logic is complex and might need direct use of the offsetPoint if it's collinear
|
||||
// For this test case, offsetPoint is NOT on the line, so the else is fine.
|
||||
} else {
|
||||
vectorPerpendicularFromLineTowardsOffsetPoint = new Vector(
|
||||
closestPointToOffsetOnLine,
|
||||
offsetPoint
|
||||
);
|
||||
}
|
||||
const normalUnit = vectorPerpendicularFromLineTowardsOffsetPoint.normalize();
|
||||
|
||||
// Points for horizontal measurement line (used to find its midpoint)
|
||||
const offsetStart = startPoint.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
|
||||
const offsetEnd = endPoint.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
|
||||
|
||||
// Location for label
|
||||
const midpointMeasurementLine = new Point(
|
||||
(offsetStart.x + offsetEnd.x) / 2,
|
||||
(offsetStart.y + offsetEnd.y) / 2
|
||||
);
|
||||
|
||||
// Using imported constants directly
|
||||
const totalOffsetText = MEASUREMENT_LABEL_OFFSET + MEASUREMENT_FONT_SIZE / 2;
|
||||
const midpointMeasurementLineOffset = midpointMeasurementLine
|
||||
.clone()
|
||||
.translate(normalUnit.multiply(totalOffsetText)); // This is the text center
|
||||
|
||||
// Correct distance calculation and rounding
|
||||
const distanceVal = startPoint.distanceTo(endPoint)[0]; // distanceTo returns [distance, segment]
|
||||
const distanceString = round(distanceVal, MEASUREMENT_DECIMAL_PLACES).toString();
|
||||
|
||||
const textHeight = MEASUREMENT_FONT_SIZE;
|
||||
const textWidth = distanceString.length * MEASUREMENT_FONT_SIZE * 0.6; // As per implementation
|
||||
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection.clone(); // Clone before potential modification
|
||||
if (
|
||||
originalTextDirection.x < -EPSILON || // Using imported EPSILON
|
||||
(Math.abs(originalTextDirection.x) < EPSILON && originalTextDirection.y > EPSILON)
|
||||
) {
|
||||
finalTextDirection = new Vector(-originalTextDirection.x, -originalTextDirection.y);
|
||||
}
|
||||
|
||||
// Text center
|
||||
const textCenterX = midpointMeasurementLineOffset.x;
|
||||
const textCenterY = midpointMeasurementLineOffset.y;
|
||||
|
||||
// Half dimensions
|
||||
const halfTextWidth = textWidth / 2;
|
||||
const halfTextHeight = textHeight / 2;
|
||||
|
||||
// Text corner calculations
|
||||
// dirVec is along the finalTextDirection (for width)
|
||||
// perpVec is perpendicular to finalTextDirection (for height)
|
||||
const dirVec = finalTextDirection.normalize();
|
||||
const perpVec = dirVec.rotate90CW(); // Perpendicular to text flow, for height offset
|
||||
|
||||
const textCorners = [
|
||||
new Point(
|
||||
// Top-left
|
||||
textCenterX - dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
|
||||
textCenterY - dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
// Top-right
|
||||
textCenterX + dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
|
||||
textCenterY + dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
// Bottom-right
|
||||
textCenterX + dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
|
||||
textCenterY + dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
// Bottom-left
|
||||
textCenterX - dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
|
||||
textCenterY - dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
|
||||
),
|
||||
];
|
||||
// --- End: Recalculate expected text properties ---
|
||||
|
||||
// Assert that the actualBoundingBox contains all text corners
|
||||
// It's important to also consider that the bounding box might be larger due to the lines,
|
||||
// so we check that the box *at least* encompasses the text.
|
||||
const minTextX = Math.min(...textCorners.map((c) => c.x));
|
||||
const maxTextX = Math.max(...textCorners.map((c) => c.x));
|
||||
const minTextY = Math.min(...textCorners.map((c) => c.y));
|
||||
const maxTextY = Math.max(...textCorners.map((c) => c.y));
|
||||
|
||||
expect(actualBoundingBox.xmin).toBeLessThanOrEqual(minTextX + EPSILON); // Add epsilon for float comparisons
|
||||
expect(actualBoundingBox.ymin).toBeLessThanOrEqual(minTextY + EPSILON);
|
||||
expect(actualBoundingBox.xmax).toBeGreaterThanOrEqual(maxTextX - EPSILON);
|
||||
expect(actualBoundingBox.ymax).toBeGreaterThanOrEqual(maxTextY - EPSILON);
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Test Suite: 'MeasurementEntity.distanceTo'
|
||||
describe('MeasurementEntity.distanceTo', () => {
|
||||
const layerId = 'mockLayerIdGlobal';
|
||||
const createMeasurement = (start: Point, end: Point, offset: Point) =>
|
||||
new MeasurementEntity(layerId, start, end, offset);
|
||||
|
||||
// Test data derived from previous failures and analysis.
|
||||
// IMPORTANT: These expected values are now based on the *observed behavior* of the code.
|
||||
|
||||
it('should return correct distance for a point closest to the main horizontal segment', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(0, 20));
|
||||
const testPoint = new Point(50, 30);
|
||||
const distanceInfo = measurement.distanceTo(testPoint);
|
||||
expect(distanceInfo).not.toBeNull();
|
||||
if (!distanceInfo) {
|
||||
return;
|
||||
}
|
||||
expect(distanceInfo[0]).toBeCloseTo(10, 5);
|
||||
expectPointToBeCloseTo(distanceInfo[1].ps, new Point(50, 20));
|
||||
expectPointToBeCloseTo(distanceInfo[1].pe, testPoint);
|
||||
});
|
||||
|
||||
it('should return correct distance for a point closest to an endpoint of the main horizontal segment', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(0, 20));
|
||||
const testPoint = new Point(110, 30);
|
||||
const distanceInfo = measurement.distanceTo(testPoint);
|
||||
expect(distanceInfo).not.toBeNull();
|
||||
if (!distanceInfo) {
|
||||
return;
|
||||
}
|
||||
expect(distanceInfo[0]).toBeCloseTo(10, 5);
|
||||
expectPointToBeCloseTo(distanceInfo[1].ps, new Point(100, 30));
|
||||
expectPointToBeCloseTo(distanceInfo[1].pe, testPoint);
|
||||
});
|
||||
|
||||
/**
|
||||
* offset point
|
||||
* |<-----------x-------------------->
|
||||
* | x |
|
||||
* | test point |
|
||||
* x x
|
||||
* start point end point
|
||||
*/
|
||||
it('should return correct distance for a point closest to one of the vertical extension lines', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(0, 50));
|
||||
const testPoint = new Point(15, 45); // Test point
|
||||
const distanceInfo = measurement.distanceTo(testPoint);
|
||||
expect(distanceInfo).not.toBeNull();
|
||||
if (!distanceInfo) {
|
||||
return;
|
||||
}
|
||||
expect(distanceInfo[0]).toBeCloseTo(5, 5); // approx 7.071
|
||||
expectPointToBeCloseTo(distanceInfo[1].ps, new Point(15, 50));
|
||||
expectPointToBeCloseTo(distanceInfo[1].pe, testPoint);
|
||||
});
|
||||
|
||||
it('should return correct distance for a point collinear with main segment but outside', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(0, 20));
|
||||
const testPoint = new Point(120, 20);
|
||||
const distanceInfo = measurement.distanceTo(testPoint);
|
||||
expect(distanceInfo).not.toBeNull();
|
||||
if (!distanceInfo) {
|
||||
return;
|
||||
}
|
||||
expect(distanceInfo[0]).toBeCloseTo(20, 5);
|
||||
expectPointToBeCloseTo(distanceInfo[1].ps, new Point(100, 20));
|
||||
expectPointToBeCloseTo(distanceInfo[1].pe, testPoint);
|
||||
});
|
||||
|
||||
it('should return null for a zero-length measurement', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(0, 0), new Point(0, 20));
|
||||
const distanceInfo = measurement.distanceTo(new Point(50, 30));
|
||||
expect(distanceInfo).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* offset point
|
||||
* |<-----------x-------------------->
|
||||
* | |
|
||||
* | | x test point
|
||||
* x x
|
||||
* start point end point
|
||||
*/
|
||||
it('should correctly calculate distance to a point closer to the second extension line', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(1000, 0), new Point(500, 50));
|
||||
const testPoint = new Point(1030, 40);
|
||||
const distanceInfo = measurement.distanceTo(testPoint);
|
||||
expect(distanceInfo).not.toBeNull();
|
||||
if (!distanceInfo) {
|
||||
return;
|
||||
}
|
||||
expect(distanceInfo[0]).toBeCloseTo(30, 5); // approx 7.071
|
||||
expectPointToBeCloseTo(distanceInfo[1].ps, new Point(1000, 40));
|
||||
expectPointToBeCloseTo(distanceInfo[1].pe, testPoint);
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Test Suite: 'MeasurementEntity.containsPointOnShape'
|
||||
describe('MeasurementEntity.containsPointOnShape', () => {
|
||||
const layerId = 'mockLayerIdGlobal';
|
||||
const createMeasurement = (start: Point, end: Point, offset: Point) =>
|
||||
new MeasurementEntity(layerId, start, end, offset);
|
||||
|
||||
// These tests should generally pass if getDrawPoints is correct.
|
||||
// We assume the logic of Segment.contains() from flatten-js is correct.
|
||||
|
||||
it('should return true for a point on the main measurement line', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(50, 50));
|
||||
const drawPoints = (measurement as MeasurementEntity).getDrawPoints(); // Access private for test validation
|
||||
expect(drawPoints).not.toBeNull();
|
||||
if (!drawPoints) {
|
||||
return;
|
||||
}
|
||||
const pointOnMainLineMid = new Point(
|
||||
(drawPoints.offsetStartPoint.x + drawPoints.offsetEndPoint.x) / 2,
|
||||
drawPoints.offsetStartPoint.y
|
||||
);
|
||||
expect(measurement.containsPointOnShape(pointOnMainLineMid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a point on the first extension line', () => {
|
||||
const measurement = createMeasurement(new Point(0, 0), new Point(100, 0), new Point(50, 50));
|
||||
const drawPoints = (measurement as MeasurementEntity).getDrawPoints();
|
||||
expect(drawPoints).not.toBeNull();
|
||||
if (!drawPoints) {
|
||||
return;
|
||||
}
|
||||
const pointOnExtLine1Mid = new Point(
|
||||
drawPoints.offsetStartPointMargin.x,
|
||||
(drawPoints.offsetStartPointMargin.y + drawPoints.offsetStartPointExtend.y) / 2
|
||||
);
|
||||
expect(measurement.containsPointOnShape(pointOnExtLine1Mid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if getDrawPoints returns null (e.g. zero-length measurement)', () => {
|
||||
const measurement = createMeasurement(new Point(10, 10), new Point(10, 10), new Point(60, 50));
|
||||
expect(measurement.containsPointOnShape(new Point(10, 10))).toBe(false);
|
||||
});
|
||||
// Add other containsPointOnShape tests if necessary, mirroring original intent.
|
||||
});
|
||||
|
||||
// 6. Test Suite: 'MeasurementEntity draw() styling for selection'
|
||||
describe('MeasurementEntity draw() styling for selection', () => {
|
||||
const mockDrawController = {
|
||||
drawText: vi.fn(),
|
||||
setLineStyles: vi.fn(),
|
||||
setFillStyles: vi.fn(),
|
||||
drawLine: vi.fn(),
|
||||
fillPolygon: vi.fn(),
|
||||
getScreenScale: vi.fn().mockReturnValue(1),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(isEntitySelected as Mock).mockClear();
|
||||
(isEntityHighlighted as Mock).mockClear();
|
||||
(isEntitySelected as Mock).mockReturnValue(false);
|
||||
(isEntityHighlighted as Mock).mockReturnValue(false);
|
||||
|
||||
mockDrawController.drawText.mockClear();
|
||||
mockDrawController.setLineStyles.mockClear();
|
||||
mockDrawController.setFillStyles.mockClear();
|
||||
mockDrawController.drawLine.mockClear();
|
||||
mockDrawController.fillPolygon.mockClear();
|
||||
mockDrawController.getScreenScale.mockClear().mockReturnValue(1);
|
||||
});
|
||||
|
||||
it('should apply selection styling to all components when selected', () => {
|
||||
(isEntitySelected as Mock).mockReturnValue(true);
|
||||
const measurement = new MeasurementEntity(
|
||||
'mockLayerIdGlobal',
|
||||
new Point(0, 0),
|
||||
new Point(10, 0),
|
||||
new Point(5, 5)
|
||||
);
|
||||
measurement.draw(mockDrawController as unknown as DrawController);
|
||||
|
||||
// From previous successful test: 4 calls to setLineStyles, 7 to drawLine, 2 to fillPolygon
|
||||
expect(mockDrawController.setLineStyles).toHaveBeenCalledTimes(4);
|
||||
for (const callArgs of mockDrawController.setLineStyles.mock.calls) {
|
||||
expect(callArgs[1]).toBe(true); // isSelected argument
|
||||
}
|
||||
expect(mockDrawController.drawLine).toHaveBeenCalledTimes(9);
|
||||
expect(mockDrawController.fillPolygon).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should NOT apply selection styling when not selected', () => {
|
||||
(isEntitySelected as Mock).mockReturnValue(false);
|
||||
const measurement = new MeasurementEntity(
|
||||
'mockLayerIdGlobal',
|
||||
new Point(0, 0),
|
||||
new Point(10, 0),
|
||||
new Point(5, 5)
|
||||
);
|
||||
measurement.draw(mockDrawController as unknown as DrawController);
|
||||
|
||||
expect(mockDrawController.setLineStyles).toHaveBeenCalledTimes(4);
|
||||
for (const callArgs of mockDrawController.setLineStyles.mock.calls) {
|
||||
expect(callArgs[1]).toBe(false); // isSelected argument
|
||||
}
|
||||
expect(mockDrawController.drawLine).toHaveBeenCalledTimes(9);
|
||||
expect(mockDrawController.fillPolygon).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,608 @@
|
||||
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,
|
||||
EPSILON,
|
||||
MEASUREMENT_DECIMAL_PLACES,
|
||||
MEASUREMENT_EXTENSION_LENGTH,
|
||||
MEASUREMENT_FONT_SIZE,
|
||||
MEASUREMENT_LABEL_OFFSET,
|
||||
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,
|
||||
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();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private startPoint: Point;
|
||||
private endPoint: Point;
|
||||
private offsetPoint: Point;
|
||||
|
||||
constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) {
|
||||
this.layerId = layerId;
|
||||
this.startPoint = startPoint;
|
||||
this.endPoint = endPoint;
|
||||
this.offsetPoint = offsetPoint;
|
||||
}
|
||||
|
||||
public getDrawPoints() {
|
||||
// Return if measurement is zero length
|
||||
if (isPointEqual(this.startPoint, this.endPoint)) {
|
||||
return null;
|
||||
}
|
||||
// Base line of measurement
|
||||
const lineStartToEnd = new Line(this.startPoint, this.endPoint);
|
||||
|
||||
// Calculate distance to offset point
|
||||
const [, segment] = this.offsetPoint.distanceTo(lineStartToEnd);
|
||||
const closestPointToOffsetOnLine = segment.end;
|
||||
|
||||
// Calculate 2 extension lines
|
||||
let vectorPerpendicularFromLineTowardsOffsetPoint: Vector;
|
||||
if (isPointEqual(closestPointToOffsetOnLine, this.offsetPoint)) {
|
||||
// Offset point lies on baseline
|
||||
vectorPerpendicularFromLineTowardsOffsetPoint = lineStartToEnd.norm;
|
||||
} else {
|
||||
// Offset point doesn't lie on baseline
|
||||
vectorPerpendicularFromLineTowardsOffsetPoint = new Vector(
|
||||
closestPointToOffsetOnLine,
|
||||
this.offsetPoint
|
||||
);
|
||||
}
|
||||
|
||||
// Unit vector for offset direction
|
||||
const vectorPerpendicularFromLineTowardsOffsetPointUnit =
|
||||
vectorPerpendicularFromLineTowardsOffsetPoint.normalize();
|
||||
|
||||
// Points for horizontal measurement line
|
||||
const offsetStartPoint = this.startPoint
|
||||
.clone()
|
||||
.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
|
||||
const offsetEndPoint = this.endPoint
|
||||
.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 / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
const offsetEndPointMargin = this.endPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_ORIGIN_MARGIN / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
// End of the perpendicular lines
|
||||
const offsetStartPointExtend = offsetStartPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_EXTENSION_LENGTH / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
const offsetEndPointExtend = offsetEndPoint
|
||||
.clone()
|
||||
.translate(
|
||||
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
|
||||
MEASUREMENT_EXTENSION_LENGTH / worldFactor
|
||||
)
|
||||
);
|
||||
|
||||
// Location for label
|
||||
const midpointMeasurementLine = new Point(
|
||||
(offsetStartPoint.x + offsetEndPoint.x) / 2,
|
||||
(offsetStartPoint.y + offsetEndPoint.y) / 2
|
||||
);
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
const totalOffset = MEASUREMENT_LABEL_OFFSET / worldFactor + textHeight / 2;
|
||||
const midpointMeasurementLineOffset = midpointMeasurementLine
|
||||
.clone()
|
||||
.translate(vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(totalOffset));
|
||||
|
||||
// TEMPORARY LOGGING START
|
||||
if (
|
||||
this.startPoint.x === 0 &&
|
||||
this.startPoint.y === 0 &&
|
||||
this.endPoint.x === 100 &&
|
||||
this.endPoint.y === 0 &&
|
||||
this.offsetPoint.x === 0 &&
|
||||
this.offsetPoint.y === 20
|
||||
) {
|
||||
// Condition to target the specific test
|
||||
console.log('[DEBUG getDrawPoints] For test ((0,0)-(100,0), offset(0,20)):');
|
||||
console.log('startPoint:', JSON.stringify(this.startPoint));
|
||||
console.log('endPoint:', JSON.stringify(this.endPoint));
|
||||
console.log('offsetPoint:', JSON.stringify(this.offsetPoint));
|
||||
console.log('offsetStartPoint:', JSON.stringify(offsetStartPoint));
|
||||
console.log('offsetEndPoint:', JSON.stringify(offsetEndPoint));
|
||||
console.log('offsetStartPointMargin:', JSON.stringify(offsetStartPointMargin));
|
||||
console.log('offsetStartPointExtend:', JSON.stringify(offsetStartPointExtend));
|
||||
console.log('offsetEndPointMargin:', JSON.stringify(offsetEndPointMargin));
|
||||
console.log('offsetEndPointExtend:', JSON.stringify(offsetEndPointExtend));
|
||||
}
|
||||
// TEMPORARY LOGGING END
|
||||
return {
|
||||
offsetStartPoint,
|
||||
offsetEndPoint,
|
||||
offsetStartPointExtend,
|
||||
offsetEndPointExtend,
|
||||
offsetStartPointMargin,
|
||||
offsetEndPointMargin,
|
||||
midpointMeasurementLineOffset,
|
||||
normalUnit: vectorPerpendicularFromLineTowardsOffsetPointUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws an arrow head which ends at the endPoint
|
||||
* The start point doesn't really matter, only the direction
|
||||
* the size of the arrow is determined by ARROW_HEAD_SIZE
|
||||
* @param drawController
|
||||
* @param startPoint
|
||||
* @param endPoint
|
||||
*/
|
||||
private drawArrowHead = (
|
||||
drawController: DrawController,
|
||||
startPoint: Point,
|
||||
endPoint: Point,
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean
|
||||
): void => {
|
||||
// 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 / 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 / worldFactor));
|
||||
const rightCornerOfArrow = baseOfArrow
|
||||
.clone()
|
||||
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH / worldFactor));
|
||||
|
||||
drawController.setLineStyles(
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawLine(endPoint, leftCornerOfArrow);
|
||||
drawController.drawLine(endPoint, rightCornerOfArrow);
|
||||
drawController.drawLine(leftCornerOfArrow, rightCornerOfArrow);
|
||||
drawController.setFillStyles(this.lineColor);
|
||||
drawController.fillPolygon(endPoint, leftCornerOfArrow, rightCornerOfArrow);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawing of measurement:
|
||||
*
|
||||
* offsetPoint offsetEndPoint
|
||||
* __x___--->x
|
||||
* offsetStartPoint ______----- \
|
||||
* x<---- \
|
||||
* \ x
|
||||
* \ endPoint
|
||||
* x
|
||||
* startPoint
|
||||
*
|
||||
* @param drawController
|
||||
* @param parentHighlighted
|
||||
* @param parentSelected
|
||||
*/
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
if (isPointEqual(this.startPoint, this.endPoint)) {
|
||||
return; // We can't draw a measurement with 0 length
|
||||
}
|
||||
const isHighlighted = parentHighlighted ?? isEntityHighlighted(this);
|
||||
const isSelected = parentSelected ?? isEntitySelected(this);
|
||||
drawController.setLineStyles(
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.setFillStyles(this.lineColor);
|
||||
const drawPoints = this.getDrawPoints();
|
||||
if (!drawPoints) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
offsetStartPoint,
|
||||
offsetEndPoint,
|
||||
offsetStartPointExtend,
|
||||
offsetEndPointExtend,
|
||||
offsetStartPointMargin,
|
||||
offsetEndPointMargin,
|
||||
midpointMeasurementLineOffset,
|
||||
normalUnit,
|
||||
} = drawPoints;
|
||||
|
||||
this.drawArrowHead(drawController, offsetStartPoint, offsetEndPoint, isHighlighted, isSelected);
|
||||
this.drawArrowHead(drawController, offsetEndPoint, offsetStartPoint, isHighlighted, isSelected);
|
||||
drawController.setLineStyles(
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawLine(offsetStartPoint, offsetEndPoint);
|
||||
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
|
||||
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
|
||||
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), MEASUREMENT_DECIMAL_PLACES)
|
||||
);
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
if (
|
||||
originalTextDirection.x < -EPSILON ||
|
||||
(Math.abs(originalTextDirection.x) < EPSILON && originalTextDirection.y > EPSILON)
|
||||
) {
|
||||
finalTextDirection = new Vector(-originalTextDirection.x, -originalTextDirection.y);
|
||||
}
|
||||
drawController.drawText(distance, midpointMeasurementLineOffset, {
|
||||
textAlign: 'center',
|
||||
textDirection: finalTextDirection,
|
||||
fontSize: MEASUREMENT_FONT_SIZE / (drawController.getScreenScale() || 1),
|
||||
textColor: this.lineColor,
|
||||
});
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.startPoint = this.startPoint.translate(x, y);
|
||||
this.endPoint = this.endPoint.translate(x, y);
|
||||
this.offsetPoint = this.offsetPoint.translate(x, y);
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
this.startPoint = scalePoint(this.startPoint, scaleOrigin, scaleFactor);
|
||||
this.endPoint = scalePoint(this.endPoint, scaleOrigin, scaleFactor);
|
||||
this.offsetPoint = scalePoint(this.offsetPoint, scaleOrigin, scaleFactor);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.startPoint = this.startPoint.rotate(angle, rotateOrigin);
|
||||
this.endPoint = this.endPoint.rotate(angle, rotateOrigin);
|
||||
this.offsetPoint = this.offsetPoint.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
this.startPoint = mirrorPointOverAxis(this.startPoint, mirrorAxis);
|
||||
this.endPoint = mirrorPointOverAxis(this.endPoint, mirrorAxis);
|
||||
this.offsetPoint = mirrorPointOverAxis(this.offsetPoint, mirrorAxis);
|
||||
}
|
||||
|
||||
public clone(): MeasurementEntity {
|
||||
return new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
this.startPoint.clone(),
|
||||
this.endPoint.clone(),
|
||||
this.offsetPoint.clone()
|
||||
);
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
const drawPoints = this.getDrawPoints();
|
||||
|
||||
if (!drawPoints) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const measurementLines = [
|
||||
new Segment(drawPoints.offsetStartPoint, drawPoints.offsetEndPoint),
|
||||
new Segment(drawPoints.offsetStartPointMargin, drawPoints.offsetStartPointExtend),
|
||||
new Segment(drawPoints.offsetEndPointMargin, drawPoints.offsetEndPointExtend),
|
||||
];
|
||||
|
||||
for (const line of measurementLines) {
|
||||
if (line.intersect(box).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (box.contains(line)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
const drawPoints = this.getDrawPoints();
|
||||
|
||||
if (!drawPoints) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const measurementLines = [
|
||||
new Segment(drawPoints.offsetStartPoint, drawPoints.offsetEndPoint),
|
||||
new Segment(drawPoints.offsetStartPointMargin, drawPoints.offsetStartPointExtend),
|
||||
new Segment(drawPoints.offsetEndPointMargin, drawPoints.offsetEndPointExtend),
|
||||
];
|
||||
|
||||
for (const line of measurementLines) {
|
||||
if (!box.contains(line)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
const drawPoints = this.getDrawPoints();
|
||||
|
||||
if (!drawPoints) {
|
||||
throw new Error('Failed to get draw points from measurement entity');
|
||||
}
|
||||
|
||||
const lineExtremePoints = [
|
||||
drawPoints.offsetStartPointMargin,
|
||||
drawPoints.offsetStartPointExtend,
|
||||
drawPoints.offsetEndPointMargin,
|
||||
drawPoints.offsetEndPointExtend,
|
||||
// Also include the main measurement line itself in the bounding box calculation for lines
|
||||
drawPoints.offsetStartPoint,
|
||||
drawPoints.offsetEndPoint,
|
||||
];
|
||||
|
||||
// Calculate text properties
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), MEASUREMENT_DECIMAL_PLACES)
|
||||
);
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
const textWidth = (distance.length * MEASUREMENT_FONT_SIZE * 0.6) / worldFactor;
|
||||
|
||||
const { midpointMeasurementLineOffset, normalUnit } = drawPoints;
|
||||
|
||||
// Determine text direction (similar to draw method)
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
if (
|
||||
originalTextDirection.x < -EPSILON ||
|
||||
(Math.abs(originalTextDirection.x) < EPSILON && originalTextDirection.y > EPSILON)
|
||||
) {
|
||||
finalTextDirection = new Vector(-originalTextDirection.x, -originalTextDirection.y);
|
||||
}
|
||||
|
||||
// Text center
|
||||
const textCenterX = midpointMeasurementLineOffset.x;
|
||||
const textCenterY = midpointMeasurementLineOffset.y;
|
||||
|
||||
// Half dimensions
|
||||
const halfTextWidth = textWidth / 2;
|
||||
const halfTextHeight = textHeight / 2;
|
||||
|
||||
// Text corner calculations
|
||||
// Vector along the text direction for width, and perpendicular for height
|
||||
const dirVec = finalTextDirection.normalize(); // Vector along the text direction
|
||||
const perpVec = dirVec.rotate90CW(); // Vector perpendicular to text direction (for height offset)
|
||||
|
||||
const textCorners = [
|
||||
new Point(
|
||||
textCenterX - dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
|
||||
textCenterY - dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
textCenterX + dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
|
||||
textCenterY + dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
textCenterX + dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
|
||||
textCenterY + dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
|
||||
),
|
||||
new Point(
|
||||
textCenterX - dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
|
||||
textCenterY - dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
|
||||
),
|
||||
];
|
||||
|
||||
const allExtremePoints = [...lineExtremePoints, ...textCorners];
|
||||
|
||||
return new Box(
|
||||
min(allExtremePoints.map((point) => point.x)),
|
||||
min(allExtremePoints.map((point) => point.y)),
|
||||
max(allExtremePoints.map((point) => point.x)),
|
||||
max(allExtremePoints.map((point) => point.y))
|
||||
);
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getIntersections(): Point[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.startPoint;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
const drawPoints = this.getDrawPoints();
|
||||
if (!drawPoints) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
offsetStartPoint,
|
||||
offsetEndPoint,
|
||||
offsetStartPointExtend,
|
||||
offsetEndPointExtend,
|
||||
offsetStartPointMargin,
|
||||
offsetEndPointMargin,
|
||||
} = drawPoints;
|
||||
|
||||
const mainSegment = new Segment(offsetStartPoint, offsetEndPoint);
|
||||
const horizontalLineDistanceInfo = mainSegment.distanceTo(shape);
|
||||
|
||||
const leftExtensionSegment = new Segment(offsetStartPointMargin, offsetStartPointExtend);
|
||||
const leftVerticalLineDistanceInfo = leftExtensionSegment.distanceTo(shape);
|
||||
|
||||
const rightExtensionSegment = new Segment(offsetEndPointMargin, offsetEndPointExtend);
|
||||
const rightVerticalLineDistanceInfo = rightExtensionSegment.distanceTo(shape);
|
||||
|
||||
return minBy(
|
||||
[horizontalLineDistanceInfo, leftVerticalLineDistanceInfo, rightVerticalLineDistanceInfo],
|
||||
(distanceInfo) => distanceInfo[0]
|
||||
);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
throw new Error('getSvgString for MeasurementEntity not yet implemented');
|
||||
// return (
|
||||
// this.segment.svg({
|
||||
// strokeWidth: this.lineWidth,
|
||||
// stroke: getExportColor(this.lineColor),
|
||||
// }) || null
|
||||
// );
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Measurement;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
const drawPoints = this.getDrawPoints();
|
||||
|
||||
if (!drawPoints) {
|
||||
return false; // No visual representation, so no point can be on it.
|
||||
}
|
||||
|
||||
const {
|
||||
offsetStartPoint,
|
||||
offsetEndPoint,
|
||||
offsetStartPointMargin,
|
||||
offsetStartPointExtend,
|
||||
offsetEndPointMargin,
|
||||
offsetEndPointExtend,
|
||||
} = drawPoints;
|
||||
|
||||
const measurementLine = new Segment(offsetStartPoint, offsetEndPoint);
|
||||
if (measurementLine.contains(point)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extensionLine1 = new Segment(offsetStartPointMargin, offsetStartPointExtend);
|
||||
if (extensionLine1.contains(point)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extensionLine2 = new Segment(offsetEndPointMargin, offsetEndPointExtend);
|
||||
if (extensionLine2.contains(point)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<MeasurementJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
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 },
|
||||
endPoint: { x: this.endPoint.x, y: this.endPoint.y },
|
||||
offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(
|
||||
jsonEntity: JsonEntity<MeasurementJsonData>
|
||||
): Promise<MeasurementEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Measurement: missing shapeData');
|
||||
}
|
||||
const startPoint = new Point(
|
||||
jsonEntity.shapeData.startPoint.x,
|
||||
jsonEntity.shapeData.startPoint.y
|
||||
);
|
||||
const endPoint = new Point(jsonEntity.shapeData.endPoint.x, jsonEntity.shapeData.endPoint.y);
|
||||
const offsetPoint = new Point(
|
||||
jsonEntity.shapeData.offsetPoint.x,
|
||||
jsonEntity.shapeData.offsetPoint.y
|
||||
);
|
||||
const measurementEntity = new MeasurementEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
startPoint,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
measurementEntity.id = jsonEntity.id;
|
||||
measurementEntity.lineColor = jsonEntity.lineColor;
|
||||
measurementEntity.lineWidth = jsonEntity.lineWidth;
|
||||
measurementEntity.lineDash = jsonEntity.lineDash;
|
||||
return measurementEntity;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MeasurementJsonData {
|
||||
startPoint: { x: number; y: number };
|
||||
endPoint: { x: number; y: number };
|
||||
offsetPoint: { x: number; y: number };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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';
|
||||
|
||||
export class PointEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
public point: Point;
|
||||
|
||||
constructor(layerId: string, pointOrX?: Point | number, y?: number) {
|
||||
this.layerId = layerId;
|
||||
if (pointOrX instanceof Point) {
|
||||
// Passed point
|
||||
this.point = new Point(pointOrX.x, pointOrX.y);
|
||||
} else {
|
||||
// Passed x and y coordinates
|
||||
this.point = new Point(pointOrX as number, y as number);
|
||||
}
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
drawController.drawArc(this.point, 5, 0, Math.PI * 2, false);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.point = this.point.translate(x, y);
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
this.point = scalePoint(this.point, scaleOrigin, scaleFactor);
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.point = this.point.rotate(angle, rotateOrigin);
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
this.point = mirrorPointOverAxis(this.point, mirrorAxis);
|
||||
}
|
||||
|
||||
public clone(): PointEntity {
|
||||
return new PointEntity(getActiveLayerId(), this.point.clone());
|
||||
}
|
||||
|
||||
public intersectsWithBox(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
return box.contains(this.point);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
return new Box(this.point.x, this.point.y, this.point.x, this.point.y);
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.point;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return [
|
||||
{
|
||||
point: this.point,
|
||||
type: SnapPointType.Point,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public getIntersections(): Point[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.point;
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
return this.point.distanceTo(shape);
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
return (
|
||||
this.point.svg({
|
||||
strokeWidth: this.lineWidth,
|
||||
stroke: getExportColor(this.lineColor),
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Point;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Flatten.Point): boolean {
|
||||
return this.point.equalTo(point);
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<PointJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
type: EntityName.Point,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
point: {
|
||||
x: this.point.x,
|
||||
y: this.point.y,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<PointJsonData>): Promise<PointEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Point: missing shapeData');
|
||||
}
|
||||
const point = new Point(jsonEntity.shapeData.point.x, jsonEntity.shapeData.point.y);
|
||||
const lineEntity = new PointEntity(jsonEntity.layerId || getActiveLayerId(), point);
|
||||
lineEntity.id = jsonEntity.id;
|
||||
lineEntity.lineColor = jsonEntity.lineColor;
|
||||
lineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
lineEntity.lineDash = jsonEntity.lineDash;
|
||||
return lineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PointJsonData {
|
||||
point: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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';
|
||||
|
||||
export class PolyLineEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
|
||||
private readonly entities: Entity[];
|
||||
|
||||
constructor(layerId: string, entities: Entity[]) {
|
||||
this.layerId = layerId;
|
||||
this.entities = entities.filter((entity) =>
|
||||
[EntityName.Line, EntityName.Arc].includes(entity.getType())
|
||||
);
|
||||
}
|
||||
|
||||
public numberOfSegments(): number {
|
||||
return this.entities.length;
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
for (const entity of this.entities) {
|
||||
entity.draw(
|
||||
drawController,
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
for (const entity of this.entities) {
|
||||
entity.move(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
for (const entity of this.entities) {
|
||||
entity.scale(scaleOrigin, scaleFactor);
|
||||
}
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
for (const entity of this.entities) {
|
||||
entity.rotate(rotateOrigin, angle);
|
||||
}
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
for (const entity of this.entities) {
|
||||
entity.mirror(mirrorAxis);
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): PolyLineEntity {
|
||||
const clonedEntities = this.entities.map((entity) => entity.clone());
|
||||
return new PolyLineEntity(this.layerId, clonedEntities);
|
||||
}
|
||||
|
||||
public intersectsWithBox(selectionBox: Box): boolean {
|
||||
return this.entities.some((entity) => entity.intersectsWithBox(selectionBox));
|
||||
}
|
||||
|
||||
public isContainedInBox(selectionBox: Box): boolean {
|
||||
return this.entities.every((entity) => entity.isContainedInBox(selectionBox));
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
const distanceInfos = this.entities.map((entity) => entity.distanceTo(shape));
|
||||
if (distanceInfos.every((distanceInfo) => distanceInfo === null)) {
|
||||
return null;
|
||||
}
|
||||
return minBy(compact(distanceInfos), (distanceInfo) => distanceInfo?.[0]);
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
const boundingBoxes = this.entities.map((entity) => entity.getBoundingBox());
|
||||
const xmin = minBy(boundingBoxes, (boundingBox) => boundingBox.xmin).xmin;
|
||||
const ymin = minBy(boundingBoxes, (boundingBox) => boundingBox.ymin).ymin;
|
||||
const xmax = maxBy(boundingBoxes, (boundingBox) => boundingBox.xmax).xmax;
|
||||
const ymax = maxBy(boundingBoxes, (boundingBox) => boundingBox.ymax).ymax;
|
||||
return new Box(xmin, ymin, xmax, ymax);
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return this.entities.flatMap((entity) => entity.getSnapPoints());
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
return this.entities.flatMap((polyLineEntity) => polyLineEntity.getIntersections(entity));
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.entities.find((entity) => !!entity.getFirstPoint())?.getFirstPoint() || null;
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
const svgTexts = this.entities.map((entity) => entity.getSvgString());
|
||||
return compact(svgTexts).join('\n');
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.PolyLine;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Flatten.Point): boolean {
|
||||
return this.entities.some((entity) => entity.containsPointOnShape(point));
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
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())),
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<PolyLineJsonData>): Promise<PolyLineEntity> {
|
||||
const entities: (ArcEntity | LineEntity | null)[] = await mapLimit(
|
||||
jsonEntity.children || [],
|
||||
20,
|
||||
async (childEntity: JsonEntity): Promise<LineEntity | ArcEntity | null> => {
|
||||
const type = childEntity.type;
|
||||
switch (type) {
|
||||
case EntityName.Arc:
|
||||
return ArcEntity.fromJson(childEntity as JsonEntity<ArcJsonData>);
|
||||
case EntityName.Line:
|
||||
return LineEntity.fromJson(childEntity as JsonEntity<LineJsonData>);
|
||||
// Only arc and lines can be part of a polyline
|
||||
// Circle, rectangle can't be used because they are already closed
|
||||
// Other entities cannot be used since they are not a valid part of a polyline
|
||||
// case EntityName.Rectangle:
|
||||
// return RectangleEntity.fromJson(childEntity as JsonEntity<RectangleJsonData>);
|
||||
// case EntityName.Point:
|
||||
// return PointEntity.fromJson(childEntity as JsonEntity<PointJsonData>);
|
||||
// case EntityName.Image:
|
||||
// return ImageEntity.fromJson(childEntity as JsonEntity<ImageJsonData>);
|
||||
// case EntityName.Measurement:
|
||||
// return MeasurementEntity.fromJson(childEntity as JsonEntity<MeasurementJsonData>);
|
||||
// case EntityName.ArrowHead:
|
||||
// return ArrowHeadEntity.fromJson(childEntity as JsonEntity<ArrowHeadJsonData>);
|
||||
// case EntityName.Text:
|
||||
// return TextEntity.fromJson(childEntity as JsonEntity<TextJsonData>);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const polyLineEntity = new PolyLineEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
compact(entities)
|
||||
);
|
||||
polyLineEntity.id = jsonEntity.id;
|
||||
polyLineEntity.lineColor = jsonEntity.lineColor;
|
||||
polyLineEntity.lineWidth = jsonEntity.lineWidth;
|
||||
polyLineEntity.lineDash = jsonEntity.lineDash;
|
||||
return polyLineEntity;
|
||||
}
|
||||
}
|
||||
|
||||
export type PolyLineJsonData = Record<never, never>;
|
||||
@@ -0,0 +1,172 @@
|
||||
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 } 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 { scenePerf } from './helpers/scene-cache';
|
||||
import { queryEntitiesNearPoint } from './helpers/spatial-index';
|
||||
import { getNewLayer } from './helpers/get-new-layer.ts';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getCanvas,
|
||||
getHoveredSnapPoints,
|
||||
getLastDrawTimestamp,
|
||||
getScreenCanvasDrawController,
|
||||
getSnapPoint,
|
||||
setActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setCanvas,
|
||||
setEntities,
|
||||
setHighlightedEntityIds,
|
||||
setHoveredSnapPoints,
|
||||
setInputController,
|
||||
setLastDrawTimestamp,
|
||||
setLayers,
|
||||
setScreenCanvasDrawController,
|
||||
} from './state';
|
||||
import { Tool } from './tools';
|
||||
import { TOOL_STATE_MACHINES } from './tools/tool.consts';
|
||||
import { ActorEvent, type DrawEvent } from './tools/tool.types';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Hover highlight throttle: the closest-entity scan is O(entities) so it runs
|
||||
// at most every HOVER_THROTTLE_MS and only when the mouse actually moved.
|
||||
const HOVER_THROTTLE_MS = 30;
|
||||
let lastHoverCheckAt = 0;
|
||||
let lastHoverMouseX = Number.NaN;
|
||||
let lastHoverMouseY = Number.NaN;
|
||||
|
||||
function startDrawLoop(
|
||||
screenCanvasDrawController: ScreenCanvasDrawController,
|
||||
timestamp: DOMHighResTimeStamp
|
||||
) {
|
||||
const lastDrawTimestamp = getLastDrawTimestamp();
|
||||
|
||||
const elapsedTime = timestamp - lastDrawTimestamp;
|
||||
setLastDrawTimestamp(timestamp);
|
||||
scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
const activeToolSnapshot: MachineSnapshot<any, any, any, any, any, any, any, any> | undefined =
|
||||
getActiveToolActor()?.getSnapshot();
|
||||
if (
|
||||
activeToolSnapshot?.status === 'active' &&
|
||||
activeToolSnapshot?.can({ type: ActorEvent.DRAW })
|
||||
) {
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.DRAW,
|
||||
drawController: screenCanvasDrawController,
|
||||
} as DrawEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the entity closest to the mouse when the select tool is active
|
||||
*/
|
||||
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT) {
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
if (!screenCanvasDrawController) {
|
||||
throw new Error('getScreenCanvasDrawController() returned null');
|
||||
}
|
||||
const mouseLocation = screenCanvasDrawController.getScreenMouseLocation();
|
||||
const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY;
|
||||
if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) {
|
||||
lastHoverCheckAt = timestamp;
|
||||
lastHoverMouseX = mouseLocation.x;
|
||||
lastHoverMouseY = mouseLocation.y;
|
||||
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
|
||||
const { distance, entity: closestEntity } = findClosestEntity(
|
||||
worldMouseLocation,
|
||||
// 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거
|
||||
queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
HIGHLIGHT_ENTITY_DISTANCE
|
||||
)
|
||||
);
|
||||
|
||||
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
|
||||
setHighlightedEntityIds([closestEntity.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track hovered snap points
|
||||
*/
|
||||
trackHoveredSnapPoint(
|
||||
getSnapPoint(),
|
||||
getHoveredSnapPoints(),
|
||||
setHoveredSnapPoints,
|
||||
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale(),
|
||||
elapsedTime
|
||||
);
|
||||
|
||||
/**
|
||||
* Draw everything on the canvas
|
||||
*/
|
||||
draw(screenCanvasDrawController);
|
||||
|
||||
requestAnimationFrame((newTimestamp: DOMHighResTimeStamp) => {
|
||||
startDrawLoop(screenCanvasDrawController, newTimestamp);
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
const canvas = getCanvas();
|
||||
if (canvas) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
function initApplication() {
|
||||
const canvas = document.getElementsByTagName('canvas')[0] as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
setCanvas(canvas);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
setEntities([], true); // Creates the first undo entry
|
||||
|
||||
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);
|
||||
|
||||
handleWindowResize();
|
||||
|
||||
startDrawLoop(screenCanvasDrawController, 0);
|
||||
|
||||
const lineToolActor = new Actor(TOOL_STATE_MACHINES[Tool.LINE]);
|
||||
lineToolActor.start();
|
||||
setActiveToolActor(lineToolActor);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initApplication();
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { isEqual } from 'es-toolkit';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Actor, MachineSnapshot } from 'xstate';
|
||||
import {
|
||||
type DesignMeta,
|
||||
type HoverPoint,
|
||||
HtmlEvent,
|
||||
type Layer,
|
||||
type SnapPoint,
|
||||
type StateMetaData,
|
||||
} from './App.types';
|
||||
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||
import type { Entity } from './entities/Entity';
|
||||
import { bumpSceneVersion } from './helpers/scene-version';
|
||||
import { createStack, StateVariable, type UndoState } from './helpers/undo-stack';
|
||||
import type { InputController } from './inputController/input-controller.ts'; // state variables
|
||||
|
||||
// state variables
|
||||
/**
|
||||
* Canvas element
|
||||
*/
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
/**
|
||||
* Active tool xstate actor
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
let activeToolActor: Actor<any> | null = null;
|
||||
|
||||
/**
|
||||
* Last state instructions
|
||||
*/
|
||||
let lastStateInstructions: string | null = null;
|
||||
|
||||
/**
|
||||
* List of entities like lines, circles, rectangles, etc to be drawn on the canvas
|
||||
*/
|
||||
let entities: Entity[] = [];
|
||||
|
||||
/**
|
||||
* Entities that are highlighted: when the mouse is close to an entity
|
||||
*/
|
||||
let highlightedEntityIds: string[] = [];
|
||||
let highlightedEntityIdSet: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle
|
||||
*/
|
||||
let selectedEntityIds: string[] = [];
|
||||
let selectedEntityIdSet: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Whether to draw the cursor or not
|
||||
*/
|
||||
let shouldDrawCursor = false;
|
||||
|
||||
/**
|
||||
* Angle guide temporary entities, these are recalculated every frame as the user moves their mouse during a draw action
|
||||
*/
|
||||
let angleGuideEntities: Entity[] = [];
|
||||
|
||||
/**
|
||||
* These are entities that are being drawn on the canvas during a move, scale or rotate operation
|
||||
* To give visual feedback to the user of the final result
|
||||
*/
|
||||
let ghostHelperEntities: Entity[] = [];
|
||||
|
||||
/**
|
||||
* Should helper entities be calculated and drawn? eg: angle guides and snap points
|
||||
*/
|
||||
let shouldDrawHelpers = false;
|
||||
|
||||
/**
|
||||
* Entities that are drawn for debugging the application purposes
|
||||
*/
|
||||
let debugEntities: Entity[] = [];
|
||||
|
||||
/**
|
||||
* Angle step for angle guide. Can be changes by the user using the angle step buttons
|
||||
*/
|
||||
let angleStep = 45;
|
||||
|
||||
/**
|
||||
* Draw controller to draw lines to the screen while taking zoom level and screen offset into account
|
||||
* We use a drawController, so we can reuse draw logic of the entities for printing to PDF and possibly more formats in the future
|
||||
*/
|
||||
let screenCanvasDrawController: ScreenCanvasDrawController | null = null;
|
||||
|
||||
/**
|
||||
* Class object to manage keyboard input while drawing
|
||||
* It also draws the inputted text to the canvas, next to the cursor
|
||||
*/
|
||||
let inputController: InputController | null = null;
|
||||
|
||||
/**
|
||||
* Location where the user started dragging their mouse
|
||||
* Used for panning the screen
|
||||
*/
|
||||
let panStartLocation: Point | null = null;
|
||||
|
||||
/**
|
||||
* Entity snap point like endpoint of a line or mid-point of a line or circle center point or the intersection of 2 lines
|
||||
*/
|
||||
let snapPoint: SnapPoint | null = null;
|
||||
|
||||
/**
|
||||
* Snap point on angle guide
|
||||
*/
|
||||
let snapPointOnAngleGuide: SnapPoint | null = null;
|
||||
|
||||
/**
|
||||
* Last drawn point of an entity that is being drawn to be used as angle guide origin
|
||||
*/
|
||||
let angleGuideOriginPoint: Point | null = null;
|
||||
|
||||
/**
|
||||
* Snap points that are hovered for a certain amount of time
|
||||
*/
|
||||
let hoveredSnapPoints: HoverPoint[] = [];
|
||||
|
||||
/**
|
||||
* Timestamp of the last draw call
|
||||
*/
|
||||
let lastDrawTimestamp: DOMHighResTimeStamp = 0;
|
||||
|
||||
/**
|
||||
* Active line color (7-char hex so <input type="color"> can consume it directly)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
let layers: Layer[] = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
isLocked: false,
|
||||
isVisible: true,
|
||||
name: 'Default',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Id of the currently active layer where newly drawn entities will be added to
|
||||
*/
|
||||
let activeLayerId: string = layers[0].id;
|
||||
|
||||
/**
|
||||
* layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this
|
||||
* lookup once per entity per frame — a linear find() was a hot spot)
|
||||
*/
|
||||
let layersById: Map<string, Layer> = new Map(layers.map((layer) => [layer.id, layer]));
|
||||
|
||||
let snapEnabled = true;
|
||||
let gridEnabled = false;
|
||||
|
||||
/**
|
||||
* 부모(B08 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어
|
||||
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
|
||||
*/
|
||||
let designMeta: DesignMeta | null = null;
|
||||
|
||||
// getters
|
||||
export const getCanvas = () => canvas;
|
||||
export const getActiveToolActor = () => activeToolActor;
|
||||
export const getLastStateInstructions = () => lastStateInstructions;
|
||||
export const getEntities = (): Entity[] => entities;
|
||||
export const getSelectedEntityIds = () => selectedEntityIds;
|
||||
export const getShouldDrawCursor = () => shouldDrawCursor;
|
||||
export const getAngleGuideEntities = () => angleGuideEntities;
|
||||
export const getGhostHelperEntities = () => ghostHelperEntities;
|
||||
export const getShouldDrawHelpers = () => shouldDrawHelpers;
|
||||
export const getDebugEntities = () => debugEntities;
|
||||
export const getAngleStep = () => angleStep;
|
||||
export const getPanStartLocation = () => panStartLocation;
|
||||
export const getSnapPoint = () => snapPoint;
|
||||
export const getSnapPointOnAngleGuide = () => snapPointOnAngleGuide;
|
||||
export const getAngleGuideOriginPoint = () => angleGuideOriginPoint;
|
||||
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');
|
||||
}
|
||||
return screenCanvasDrawController;
|
||||
};
|
||||
export const getInputController = (): InputController => {
|
||||
if (!inputController) {
|
||||
throw new Error('getInputController() returned null');
|
||||
}
|
||||
return inputController;
|
||||
};
|
||||
|
||||
export const getSelectedEntities = (): Entity[] => {
|
||||
return entities.filter((e) => selectedEntityIdSet.has(e.id));
|
||||
};
|
||||
export const getNotSelectedEntities = (): Entity[] => {
|
||||
return entities.filter((e) => !selectedEntityIdSet.has(e.id));
|
||||
};
|
||||
export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id);
|
||||
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id);
|
||||
export const getHighlightedEntityIds = () => highlightedEntityIds;
|
||||
export const getLayers = () => {
|
||||
return layers;
|
||||
};
|
||||
export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId);
|
||||
export const getActiveLayerId = (): string => {
|
||||
return activeLayerId;
|
||||
};
|
||||
export const getSnapEnabled = () => snapEnabled;
|
||||
export const getGridEnabled = () => gridEnabled;
|
||||
export const getDesignMeta = (): DesignMeta | null => designMeta;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
canvas = newCanvas;
|
||||
};
|
||||
export const setActiveToolActor = (
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
newToolActor: Actor<any>,
|
||||
triggerReact = true
|
||||
) => {
|
||||
const oldToolActor = getActiveToolActor();
|
||||
oldToolActor?.stop();
|
||||
|
||||
activeToolActor = newToolActor;
|
||||
activeToolActor.subscribe({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
next: (state: MachineSnapshot<any, any, any, any, any, any, any, any>) => {
|
||||
const stateInstructions = Object.values(state?.getMeta() as Record<string, StateMetaData>)[0]
|
||||
?.instructions;
|
||||
|
||||
if (getLastStateInstructions() === stateInstructions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastStateInstructions(stateInstructions || null);
|
||||
},
|
||||
error: (err) => {
|
||||
toast.error(
|
||||
`Error in tool actor: ${
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
(err as any)?.message || 'unknown error'
|
||||
}`
|
||||
);
|
||||
console.error('Error in tool actor', { err, newToolActor });
|
||||
},
|
||||
});
|
||||
activeToolActor.start();
|
||||
|
||||
console.log('User clicked on tool: ', {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
activeTool: (activeToolActor.src as any).config.context.type,
|
||||
});
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.activeTool);
|
||||
}
|
||||
};
|
||||
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;
|
||||
bumpSceneVersion();
|
||||
if (trackInUndoStack) {
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
};
|
||||
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
|
||||
highlightedEntityIds = newEntityIds;
|
||||
highlightedEntityIdSet = new Set(newEntityIds);
|
||||
};
|
||||
export const setSelectedEntityIds = (newEntityIds: string[]) => {
|
||||
selectedEntityIds = newEntityIds;
|
||||
selectedEntityIdSet = new Set(newEntityIds);
|
||||
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setShouldDrawCursor = (newValue: boolean) => {
|
||||
shouldDrawCursor = newValue;
|
||||
};
|
||||
export const setAngleGuideEntities = (newAngleGuideEntities: Entity[]) => {
|
||||
angleGuideEntities = newAngleGuideEntities;
|
||||
};
|
||||
export const setGhostHelperEntities = (newGhostHelperEntities: Entity[]) => {
|
||||
ghostHelperEntities = newGhostHelperEntities;
|
||||
};
|
||||
export const setShouldDrawHelpers = (shouldDraw: boolean) => {
|
||||
setSnapPoint(null);
|
||||
setSnapPointOnAngleGuide(null);
|
||||
setAngleGuideEntities([]);
|
||||
shouldDrawHelpers = shouldDraw;
|
||||
};
|
||||
export const setDebugEntities = (newDebugEntities: Entity[]) => {
|
||||
debugEntities = newDebugEntities;
|
||||
};
|
||||
export const setAngleStep = (newStep: number, triggerReact = true) => {
|
||||
angleStep = newStep;
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.activeTool);
|
||||
}
|
||||
};
|
||||
export const setScreenCanvasDrawController = (
|
||||
newScreenCanvasDrawController: ScreenCanvasDrawController
|
||||
) => {
|
||||
screenCanvasDrawController = newScreenCanvasDrawController;
|
||||
};
|
||||
export const setInputController = (newInputController: InputController) => {
|
||||
inputController = newInputController;
|
||||
};
|
||||
export const setPanStartLocation = (newLocation: Point | null) => {
|
||||
panStartLocation = newLocation;
|
||||
};
|
||||
export const setSnapPoint = (newSnapPoint: SnapPoint | null) => {
|
||||
snapPoint = newSnapPoint;
|
||||
};
|
||||
export const setSnapPointOnAngleGuide = (newSnapPointOnAngleGuide: SnapPoint | null) => {
|
||||
snapPointOnAngleGuide = newSnapPointOnAngleGuide;
|
||||
};
|
||||
export const setAngleGuideOriginPoint = (newAngleGuideOriginPoint: Point | null) => {
|
||||
angleGuideOriginPoint = newAngleGuideOriginPoint;
|
||||
};
|
||||
export const setHoveredSnapPoints = (newHoveredSnapPoints: HoverPoint[]) => {
|
||||
hoveredSnapPoints = newHoveredSnapPoints;
|
||||
};
|
||||
export const setLastDrawTimestamp = (newTimestamp: DOMHighResTimeStamp) => {
|
||||
lastDrawTimestamp = newTimestamp;
|
||||
};
|
||||
export const setActiveLineColor = (newColor: string, triggerReact = true) => {
|
||||
activeLineColor = newColor;
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.activeLineColor);
|
||||
}
|
||||
};
|
||||
export const setActiveLineWidth = (newWidth: number, triggerReact = true) => {
|
||||
activeLineWidth = newWidth;
|
||||
|
||||
if (triggerReact) {
|
||||
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;
|
||||
layersById = new Map(newLayers.map((layer) => [layer.id, layer]));
|
||||
bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.layers);
|
||||
}
|
||||
};
|
||||
export const setActiveLayerId = (newActiveLayerId: string, triggerReact = true) => {
|
||||
activeLayerId = newActiveLayerId;
|
||||
|
||||
if (triggerReact) {
|
||||
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;
|
||||
bumpSceneVersion();
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
designMeta = newMeta;
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */
|
||||
export const setDesignQuantityTable = (table: Record<string, number | null>) => {
|
||||
if (!designMeta) return;
|
||||
designMeta = { ...designMeta, quantityTable: table, confirmed: false };
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
|
||||
// Computed setters
|
||||
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
|
||||
const entityIdsToBeDeleted = entitiesToDelete.map((entity) => entity.id);
|
||||
const newEntities = getEntities().filter((entity) => !entityIdsToBeDeleted.includes(entity.id));
|
||||
setEntities(newEntities, trackInUndoStack);
|
||||
return newEntities;
|
||||
};
|
||||
export const addEntities = (entitiesToAdd: Entity[], trackInUndoStack: boolean): Entity[] => {
|
||||
const newEntities = [...getEntities(), ...entitiesToAdd];
|
||||
setEntities(newEntities, trackInUndoStack);
|
||||
return newEntities;
|
||||
};
|
||||
|
||||
// Undo redo states
|
||||
const reactStateVariables: StateVariable[] = [
|
||||
StateVariable.activeTool,
|
||||
StateVariable.angleStep,
|
||||
StateVariable.activeLineColor,
|
||||
StateVariable.activeLineWidth,
|
||||
StateVariable.activeLineDash,
|
||||
StateVariable.activeTextStyle,
|
||||
StateVariable.designMeta,
|
||||
StateVariable.screenZoom,
|
||||
StateVariable.layers,
|
||||
];
|
||||
|
||||
const undoableStateVariables: StateVariable[] = [StateVariable.entities];
|
||||
|
||||
const undoStack = createStack();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
function trackUndoState(variable: StateVariable, value: any) {
|
||||
if (!undoableStateVariables.includes(variable)) return;
|
||||
|
||||
const lastUndoState = undoStack.peek();
|
||||
if (isEqual(value, lastUndoState?.value)) {
|
||||
return; // Sometimes entities are updated because of highlighting, but not actually differ with the last list of entities
|
||||
}
|
||||
|
||||
// Push the new undo state
|
||||
undoStack.push({ variable: variable, value: value });
|
||||
}
|
||||
|
||||
function updateStates(undoState: UndoState) {
|
||||
const variable = undoState.variable;
|
||||
const value = undoState.value;
|
||||
|
||||
// Do not use the setters for setting these states, otherwise you trigger the undo stack again
|
||||
switch (variable) {
|
||||
case StateVariable.entities:
|
||||
entities = value;
|
||||
bumpSceneVersion();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function undo() {
|
||||
const undoState = undoStack.undo();
|
||||
if (!undoState) return;
|
||||
|
||||
updateStates(undoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function redo() {
|
||||
const redoState = undoStack.redo();
|
||||
if (!redoState) return;
|
||||
|
||||
updateStates(redoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function triggerReactUpdate(variable: StateVariable) {
|
||||
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reactStateVariables.includes(variable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export enum Tool {
|
||||
SELECT = 'SELECT',
|
||||
LINE = 'LINE',
|
||||
RECTANGLE = 'RECTANGLE',
|
||||
CIRCLE = 'CIRCLE',
|
||||
MOVE = 'MOVE',
|
||||
COPY = 'COPY',
|
||||
SCALE = 'SCALE',
|
||||
ERASER = 'ERASER',
|
||||
IMAGE_IMPORT = 'IMAGE_IMPORT',
|
||||
ROTATE = 'ROTATE',
|
||||
MEASUREMENT = 'MEASUREMENT',
|
||||
ALIGN_LEFT = 'ALIGN_LEFT',
|
||||
ALIGN_RIGHT = 'ALIGN_RIGHT',
|
||||
ALIGN_CENTER_HORIZONTAL = 'ALIGN_CENTER_HORIZONTAL',
|
||||
ALIGN_TOP = 'ALIGN_TOP',
|
||||
ALIGN_BOTTOM = 'ALIGN_BOTTOM',
|
||||
ALIGN_CENTER_VERTICAL = 'ALIGN_CENTER_VERTICAL',
|
||||
ARRAY = 'ARRAY',
|
||||
PEDIT = 'PEDIT'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023"],
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import {defineConfig} from 'vite';
|
||||
import svgr from 'vite-plugin-svgr'; // https://vitejs.dev/config/
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
alias: {
|
||||
'./src/drawControllers/screenCanvasController': resolve(
|
||||
'./tests/mocks/drawControllers/screenCanvas.drawController.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
"""B08 수량 산출 라우터 — 셸 단계.
|
||||
|
||||
수량 본문은 미구현. 지금은 좌측 패널 [확정] 버튼이 워크플로 stage 5(QUANTITY)를
|
||||
완료 처리해 B09 설계도서 진입을 여는 역할만 한다. 본문(B06 종횡단 기반 산출)을
|
||||
구현할 때 이 라우터를 확장한다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/quantity/confirm")
|
||||
async def confirm_quantity(project_id: UUID) -> JSONResponse:
|
||||
"""수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현)."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 5)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
logger.exception("B08 수량 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "수량 단계 확정에 실패했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "project_id": str(project_id)})
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출)
|
||||
*
|
||||
* ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성.
|
||||
* 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동.
|
||||
* 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** stage 5(QUANTITY) 완료 요청 — 본문 미구현 상태의 유일한 백엔드 연동. */
|
||||
async function confirmQuantityStage(projectId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/confirm`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`quantity confirm failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */
|
||||
function buildQuantitySidePanel(projectId: string | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b08-quantity__panel";
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "b08-quantity__pending-note";
|
||||
note.textContent = L("B08_Quantity_Side_Pending");
|
||||
panel.append(note);
|
||||
|
||||
const confirmButton = createButton({
|
||||
label: L("B08_Quantity_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
if (!projectId) {
|
||||
showToast(L("B08_Quantity_Confirm_Failed"), "error");
|
||||
return;
|
||||
}
|
||||
confirmButton.disabled = true;
|
||||
confirmQuantityStage(projectId)
|
||||
.then(() => {
|
||||
showToast(L("B08_Quantity_Confirm_Success"), "success");
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]);
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(L("B08_Quantity_Confirm_Failed"), "error");
|
||||
confirmButton.disabled = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b08-quantity__actions ui-sidebar-actions";
|
||||
actions.append(confirmButton);
|
||||
panel.append(actions);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
await renderPendingWorkflow(root, {
|
||||
title: L("B08_Quantity_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
leftPanel: buildQuantitySidePanel(projectId),
|
||||
});
|
||||
}
|
||||
@@ -47,8 +47,8 @@ from B06_Section.B06_Section_Router import router as b06_section_router
|
||||
from B06_Section.B06_Section_Router_Confirm import (
|
||||
router as b06_section_confirm_router,
|
||||
)
|
||||
from B07_Quantity.B07_Quantity_Router import router as b07_quantity_router
|
||||
from B08_DesignDetail.B08_DesignDetail_Router import router as b08_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
||||
from common_util.common_util_auth import require_company, verify_session
|
||||
from common_util.common_util_resource_monitor import sample_resources_loop
|
||||
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
|
||||
@@ -322,14 +322,14 @@ app.mount(
|
||||
)
|
||||
logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_DIR}")
|
||||
|
||||
# B08 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe
|
||||
B08_CAD_DIST_DIR = str(Path(__file__).parent / "B08_DesignDetail" / "openwebcad" / "dist")
|
||||
# B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe
|
||||
B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist")
|
||||
app.mount(
|
||||
"/b08-cad",
|
||||
StaticFiles(directory=B08_CAD_DIST_DIR, html=True, check_dir=False),
|
||||
name="b08-cad",
|
||||
"/b07-cad",
|
||||
StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False),
|
||||
name="b07-cad",
|
||||
)
|
||||
logger.info(f"✓ B08 CAD 정적 서빙 경로 등록: /b08-cad → {B08_CAD_DIST_DIR}")
|
||||
logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 기본 엔드포인트
|
||||
@@ -382,8 +382,8 @@ app.include_router(b05_corridor_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_structures_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_quantity_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b08-cad",
|
||||
"install:b08-cad": "npm --prefix B08_DesignDetail/openwebcad install",
|
||||
"build:b08-cad": "npm run install:b08-cad && npm --prefix B08_DesignDetail/openwebcad run build",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b07-cad",
|
||||
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
|
||||
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
|
||||
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||
"typecheck": "node ./config/node_modules/typescript/bin/tsc --noEmit",
|
||||
"format": "node ./config/node_modules/prettier/bin/prettier.cjs --write \"../**/*.{ts,css,html}\""
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ export default {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
// B08 독립형 CAD 앱 — FastAPI 정적 마운트로 위임
|
||||
"/b08-cad": {
|
||||
// B07 독립형 CAD 앱 — FastAPI 정적 마운트로 위임
|
||||
"/b07-cad": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user