auto: 2026-08-29 16:13 (EOMSANGDON-HOME)

This commit is contained in:
2026-08-29 16:13:45 +09:00
parent 80abf38134
commit 1516710afe
63 changed files with 17225 additions and 15 deletions
@@ -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", []))
]
+602
View File
@@ -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);
}
+18
View File
@@ -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 },
],
},
}
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"name": "openwebcad"
}
+22
View File
@@ -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.
+149
View File
@@ -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.gif](readme%2Fdemo.gif)
## 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.
+40
View File
@@ -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"
}
}
}
+18
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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;
+577
View File
@@ -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;
}
}
+16
View File
@@ -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>;
+172
View File
@@ -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();
});
+514
View File
@@ -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));
}
+21
View File
@@ -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'
}
+2
View File
@@ -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"]
}
+11
View File
@@ -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.
+37
View File
@@ -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)})
+81
View File
@@ -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),
});
}
+10 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},