B07 openwebcad 적용

This commit is contained in:
2026-07-19 17:01:48 +09:00
parent 68d6fe91e6
commit 77ab857396
181 changed files with 22379 additions and 718 deletions
@@ -1,71 +0,0 @@
/* =============================================================================
* B07_wf4_DesignDetail_Api_Fetch.ts
* 4차 워크플로우(상세 설계) API 클라이언트 — WebCAD PoC 임시 계약
*
* 백엔드 계약 (B07_wf4_DesignDetail_Router.py):
* GET /api/b07/poc/sample-drawing → 샘플 종단·횡단 도면 기하 JSON
* GET /api/b07/poc/sample-drawing/dxf → 동일 도면 DXF 다운로드
*
* ⚠️ PoC 전용: Phase 2에서 실제 B06 산출 데이터 계약으로 대체된다.
* 규칙: 오류 응답 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface CadLayerJson {
name: string;
color_aci: number;
}
export interface CadEntityJson {
type: "LWPOLYLINE" | "LINE" | "TEXT";
layer: string;
/** LWPOLYLINE */
points?: [number, number][];
closed?: boolean;
/** LINE */
start?: [number, number];
end?: [number, number];
/** TEXT */
text?: string;
insert?: [number, number];
height?: number;
rotation?: number;
}
export interface CadDrawingJson {
layers: CadLayerJson[];
entities: CadEntityJson[];
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 쿠키 + 오류 응답 변환. */
async function requestJson<T>(path: string): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "GET",
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);
}
}
/** PoC 샘플 도면(종단·횡단 모사)의 기하 JSON을 조회한다. */
export async function fetchPocSampleDrawing(stations?: number): Promise<CadDrawingJson> {
const query = stations === undefined ? "" : `?stations=${stations}`;
return requestJson<CadDrawingJson>(`/b07/poc/sample-drawing${query}`);
}
/** PoC 샘플 도면 DXF 다운로드 URL (ezdxf 쓰기 왕복 검증용). */
export function getPocSampleDrawingDxfUrl(): string {
return `${API_BASE_URL}/b07/poc/sample-drawing/dxf`;
}
@@ -1,190 +0,0 @@
"""B07 상세 설계 FastAPI 라우터 — WebCAD PoC 임시 API.
⚠️ PoC 전용: DB·워크플로우 연동 없음. 종단·횡단도 형태를 모사한 샘플 도면을
ezdxf(MIT)로 생성하여 (1) 기하 JSON, (2) DXF 파일로 제공한다.
브라우저는 DXF를 파싱하지 않고 JSON만 렌더링한다 (GPL 배제 아키텍처).
Phase 2 본 구현 시 실제 B06 산출 데이터 기반으로 대체된다.
"""
import io
import logging
from typing import Any
import ezdxf
from fastapi import APIRouter, Query
from fastapi.responses import Response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/b07", tags=["B07 Design Detail"])
# PoC 샘플 도면 레이어 구성 (name, ACI 색번호)
_POC_LAYERS: list[tuple[str, int]] = [
("GRID", 8), # 격자 — 회색
("GROUND", 42), # 자연 지반선 — 갈색 계열
("DESIGN", 1), # 계획선 — 빨강
("CENTER", 4), # 중심선 — 하늘색
("LABEL", 7), # 문자 — 흰색/검정
]
_VERTICAL_EXAGGERATION = 2.0
_BASE_ELEVATION = 100.0
def _ground_elevation(station: float) -> float:
"""샘플 자연 지반고(측점 거리 기반 결정적 파형)."""
import math
return 102.0 + station * 0.05 + 1.8 * math.sin(station / 37.0) + 0.6 * math.sin(station / 11.0)
def _design_elevation(station: float) -> float:
"""샘플 계획고(직선 종단 기울기 4%)."""
return 102.5 + station * 0.04
def _profile_y(elevation: float) -> float:
"""표고 → 종단면도 Y 좌표(수직 과장 적용)."""
return (elevation - _BASE_ELEVATION) * _VERTICAL_EXAGGERATION
def _build_sample_doc(stations: int, interval: float) -> "ezdxf.document.Drawing":
"""종단면도 + 횡단면도(3개소)를 모사한 샘플 DXF 문서 생성."""
doc = ezdxf.new("R2010", setup=True)
for name, aci in _POC_LAYERS:
doc.layers.add(name, color=aci)
msp = doc.modelspace()
length = (stations - 1) * interval
ground = [(i * interval, _ground_elevation(i * interval)) for i in range(stations)]
design = [(i * interval, _design_elevation(i * interval)) for i in range(stations)]
top = max(_profile_y(e) for _, e in ground) + 8.0
# --- 종단면도: 격자(측점 세로선 + 기준 가로선) ---
msp.add_lwpolyline(
[(0, 0), (length, 0), (length, top), (0, top)],
close=True,
dxfattribs={"layer": "GRID"},
)
for x, _ in ground:
msp.add_line((x, 0), (x, top), dxfattribs={"layer": "GRID"})
# --- 지반선·계획선 ---
msp.add_lwpolyline([(x, _profile_y(e)) for x, e in ground], dxfattribs={"layer": "GROUND"})
msp.add_lwpolyline([(x, _profile_y(e)) for x, e in design], dxfattribs={"layer": "DESIGN"})
# --- 측점 라벨·표고 문자 ---
for index, (x, elevation) in enumerate(ground):
msp.add_text(
f"No.{index}",
height=1.6,
dxfattribs={"layer": "LABEL", "insert": (x - 2.0, -4.0)},
)
msp.add_text(
f"{elevation:.2f}",
height=1.2,
rotation=90,
dxfattribs={"layer": "LABEL", "insert": (x + 0.5, _profile_y(elevation) + 1.5)},
)
msp.add_text(
"종단면도 (PoC)",
height=3.0,
dxfattribs={"layer": "LABEL", "insert": (length / 2 - 12.0, top + 4.0)},
)
# --- 횡단면도 3개소 (종단면도 아래 배치) ---
section_base_y = -30.0
half_width = 10.0
road_half = 2.0
for section_no in range(3):
cx = section_no * 34.0 + 10.0
design_y = section_base_y + 4.0
points = []
for offset_step in range(-5, 6):
offset = offset_step * (half_width / 5.0)
rel = 0.12 * offset + 0.02 * offset * offset * (1 if section_no % 2 else -1)
points.append((cx + offset, design_y + rel + 1.2))
msp.add_lwpolyline(points, dxfattribs={"layer": "GROUND"})
msp.add_line(
(cx - road_half, design_y),
(cx + road_half, design_y),
dxfattribs={"layer": "DESIGN"},
)
msp.add_line(
(cx, design_y - 2.5),
(cx, design_y + 4.5),
dxfattribs={"layer": "CENTER"},
)
msp.add_text(
f"No.{section_no} 횡단",
height=1.4,
dxfattribs={"layer": "LABEL", "insert": (cx - 4.0, section_base_y - 3.0)},
)
return doc
def _doc_to_geometry_json(doc: "ezdxf.document.Drawing") -> dict[str, Any]:
"""DXF 문서 → 뷰어 렌더링용 기하 JSON (LWPOLYLINE/LINE/TEXT만 사용)."""
entities: list[dict[str, Any]] = []
for entity in doc.modelspace():
kind = entity.dxftype()
if kind == "LWPOLYLINE":
entities.append(
{
"type": "LWPOLYLINE",
"layer": entity.dxf.layer,
"points": [[round(x, 4), round(y, 4)] for x, y, *_ in entity.get_points()],
"closed": bool(entity.closed),
}
)
elif kind == "LINE":
entities.append(
{
"type": "LINE",
"layer": entity.dxf.layer,
"start": [round(entity.dxf.start.x, 4), round(entity.dxf.start.y, 4)],
"end": [round(entity.dxf.end.x, 4), round(entity.dxf.end.y, 4)],
}
)
elif kind == "TEXT":
entities.append(
{
"type": "TEXT",
"layer": entity.dxf.layer,
"text": entity.dxf.text,
"insert": [round(entity.dxf.insert.x, 4), round(entity.dxf.insert.y, 4)],
"height": entity.dxf.height,
"rotation": entity.dxf.rotation,
}
)
return {
"layers": [{"name": name, "color_aci": aci} for name, aci in _POC_LAYERS],
"entities": entities,
}
@router.get("/poc/sample-drawing")
async def get_poc_sample_drawing(
stations: int = Query(default=11, ge=2, le=2000),
interval: float = Query(default=20.0, gt=0, le=100.0),
) -> dict[str, Any]:
"""샘플 종단·횡단 도면의 기하 JSON을 반환한다 (stations 증가로 부하 테스트 가능)."""
doc = _build_sample_doc(stations, interval)
payload = _doc_to_geometry_json(doc)
logger.info("B07 PoC sample drawing generated: %d entities", len(payload["entities"]))
return payload
@router.get("/poc/sample-drawing/dxf")
async def download_poc_sample_drawing(
stations: int = Query(default=11, ge=2, le=2000),
interval: float = Query(default=20.0, gt=0, le=100.0),
) -> Response:
"""동일 샘플 도면을 DXF 파일로 반환한다 (ezdxf 쓰기 왕복 검증용)."""
doc = _build_sample_doc(stations, interval)
buffer = io.StringIO()
doc.write(buffer)
return Response(
content=buffer.getvalue().encode("utf-8"),
media_type="application/dxf",
headers={"Content-Disposition": 'attachment; filename="b07_poc_sample.dxf"'},
)
@@ -1,127 +0,0 @@
/* =============================================================================
* B07_wf4_DesignDetail_UI_CadViewer.ts
* WebCAD PoC 뷰어 — cad-simple-viewer(MIT) 부트스트랩 + 기하 JSON 렌더링
*
* 아키텍처 (PLAN.md WebCAD 합의):
* - 브라우저는 DXF/DWG를 파싱하지 않는다. GPL 컨버터는 vite 별칭 스텁으로 차단.
* - 서버(ezdxf)가 보낸 기하 JSON을 data-model(MIT) API로 도면 DB에 직접 구성.
* - AcApDocManager는 싱글톤이므로 뷰어 컨테이너를 모듈 수준에 유지하고
* 페이지 재진입 시 새 레이아웃에 재부착한다.
* ========================================================================== */
import { AcApDocManager } from "@mlightcad/cad-simple-viewer";
import {
acdbHostApplicationServices,
AcCmColor,
AcDbLayerTableRecord,
AcDbLine,
AcDbPolyline,
AcDbText,
AcGePoint2d,
AcGePoint3d,
} from "@mlightcad/data-model";
import type { CadDrawingJson, CadEntityJson } from "./B07_wf4_DesignDetail_Api_Fetch";
/** 뷰어 캔버스를 담는 영속 컨테이너 (SPA 라우팅 간 유지) */
let viewerHolder: HTMLDivElement | null = null;
/** 도면 JSON이 이미 DB에 적재되었는지 여부 (PoC: 1회 적재) */
let drawingLoaded = false;
function ensureViewerHolder(): HTMLDivElement {
if (!viewerHolder) {
viewerHolder = document.createElement("div");
viewerHolder.className = "b07-cad-canvas";
}
return viewerHolder;
}
function ensureDocManager(holder: HTMLDivElement): AcApDocManager {
try {
return AcApDocManager.instance;
} catch {
// 최초 1회 생성 — 워커 미배포 환경이므로 MTEXT는 메인 스레드 렌더링 사용.
AcApDocManager.createInstance({
container: holder,
autoResize: true,
useMainThreadDraw: true,
});
return AcApDocManager.instance;
}
}
function toEntity(json: CadEntityJson): AcDbLine | AcDbPolyline | AcDbText | null {
if (json.type === "LWPOLYLINE" && json.points && json.points.length >= 2) {
const polyline = new AcDbPolyline();
json.points.forEach(([x, y], index) => {
polyline.addVertexAt(index, new AcGePoint2d(x, y));
});
polyline.closed = json.closed ?? false;
polyline.layer = json.layer;
return polyline;
}
if (json.type === "LINE" && json.start && json.end) {
const line = new AcDbLine(
new AcGePoint3d(json.start[0], json.start[1], 0),
new AcGePoint3d(json.end[0], json.end[1], 0),
);
line.layer = json.layer;
return line;
}
if (json.type === "TEXT" && json.text && json.insert) {
const text = new AcDbText();
text.textString = json.text;
text.position = new AcGePoint3d(json.insert[0], json.insert[1], 0);
text.height = json.height ?? 2.5;
text.rotation = ((json.rotation ?? 0) * Math.PI) / 180;
text.layer = json.layer;
return text;
}
return null;
}
function loadDrawingIntoDatabase(manager: AcApDocManager, drawing: CadDrawingJson): number {
const db = manager.curDocument.database;
db.createDefaultData();
for (const layerJson of drawing.layers) {
const color = new AcCmColor();
color.colorIndex = layerJson.color_aci;
db.tables.layerTable.add(new AcDbLayerTableRecord({ name: layerJson.name, color }));
}
const modelSpace = db.tables.blockTable.modelSpace;
let appended = 0;
for (const entityJson of drawing.entities) {
const entity = toEntity(entityJson);
if (entity) {
modelSpace.appendEntity(entity);
appended += 1;
}
}
return appended;
}
/**
* PoC CAD 뷰어를 host에 부착하고 서버 기하 JSON을 렌더링한다.
* @returns 적재된 엔티티 수 (재진입 시 0 — 이미 적재됨)
*/
export function mountPocCadViewer(host: HTMLElement, drawing: CadDrawingJson): number {
const holder = ensureViewerHolder();
host.append(holder);
const manager = ensureDocManager(holder);
let appended = 0;
if (!drawingLoaded) {
appended = loadDrawingIntoDatabase(manager, drawing);
drawingLoaded = true;
// 파일 오픈 흐름이 아니므로 layoutSwitched 이벤트를 직접 발화해야
// 뷰가 레이아웃 뷰 생성·가시화·초기 줌을 수행한다 (미발화 시 빈 화면).
const db = manager.curDocument.database;
const modelSpaceBtrId = db.tables.blockTable.modelSpace.objectId;
manager.setActiveLayout();
acdbHostApplicationServices().layoutManager.setCurrentLayoutBtrId(modelSpaceBtrId, db);
manager.curView.zoomToFitDrawing();
}
return appended;
}
@@ -1,10 +1,10 @@
/* =============================================================================
* B07_wf4_DesignDetail_UI_Page.ts
* 로그인 후 07: 4차 워크플로우 (상세 설계) — WebCAD 전체 앱 임베드
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
*
* 방향 전환 (PLAN.md 2026-07-19): 코어 조립이 아니라 완성된 오픈소스 CAD 앱
* (external_program/cad-viewer-example, GPL 워커 제외 빌드)을 /cadviewer 정적
* 경로로 서빙하고 iframe으로 임베드한다. 데이터 연동은 후속 단계.
* B07_wf4_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
*
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
@@ -22,8 +22,8 @@ import {
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
/** WebCAD 전체 앱 정적 경로 (main.py /cadviewer 마운트, dev는 vite proxy 위임) */
const CAD_VIEWER_APP_URL = "/cadviewer/index.html";
/** 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 {
@@ -62,7 +62,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
cadHost.className = "b07-cad-host";
const frame = document.createElement("iframe");
frame.className = "b07-cad-frame";
frame.src = CAD_VIEWER_APP_URL;
frame.src = B07_CAD_APP_URL;
frame.title = L("B07_Design_Title");
cadHost.append(frame);
@@ -1,6 +1,6 @@
/* =============================================================================
* B07_wf4_DesignDetail_UI_Style.css
* 상세 설계(WebCAD PoC) 화면 스타일 — theme.css 변수만 사용
* 상세 설계(독립형 2D CAD) 화면 스타일 — theme.css 변수만 사용
* ========================================================================== */
/* 워크플로우 레이아웃 높이 (B06 패턴 준수) */
@@ -52,16 +52,7 @@
background-color: var(--color-surface);
}
.b07-cad-canvas {
position: absolute;
inset: 0;
}
.b07-cad-canvas canvas {
display: block;
}
/* WebCAD 전체 앱 임베드 (iframe) */
/* B07 독립형 CAD 앱 임베드 */
.b07-cad-frame {
position: absolute;
inset: 0;
@@ -69,15 +60,3 @@
height: 100%;
border: 0;
}
/* 로딩·오류 상태 메시지 */
.b07-cad-status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
pointer-events: none;
}
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
@@ -0,0 +1,46 @@
name: Test, Build, and Deploy
on:
push:
branches:
- master
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: ♻️ Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: 📦 Install dependencies
run: npm ci
- name: 🧪 Run tests
run: npm run test
- name: 🛠️ Build project
run: npm run build
- name: 🛑 Disable Jekyll
run: echo > dist/.nojekyll
- name: 🚀 Deploy
if: success() && github.ref == 'refs/heads/master'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,3 @@
{
"name": "openwebcad"
}
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2024 Bert Verhelst
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+149
View File
@@ -0,0 +1,149 @@
# Aislo B07 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 B07 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:b07: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.
@@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [".vscode", ".idea", "node_modules", "docs"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5"
}
},
"json": {
"formatter": {
"indentStyle": "space"
}
}
}
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ko" class="m-0 p-0 overflow-hidden w-full h-full">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<link rel="icon" type="image/png" href="./favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Aislo 2D Drawing</title>
</head>
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row bg-white">
<div id="root" data-id="root" class="bg-slate-950"></div>
<canvas data-id="canvas" class="block bg-black cursor-none"></canvas>
<div class="export-pdf-wrapper">
<!-- Used to append the svg before exporting it to pdf -->
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
{
"name": "aislo-b07-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",
"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 B07 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 @@
/**
* Width of the toolbar containing all the tools on the left side of the screen
*/
export const TOOLBAR_WIDTH = 320;
/**
* Very small number that will be used to compare floating point numbers on equality
* since javascript isn't always very accurate with floating point numbers
*/
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
*/
export const MEASUREMENT_EXTENSION_LENGTH = 20;
/**
* Distance that measurement lines stay away from the point of origin of the measurement
*/
export const MEASUREMENT_ORIGIN_MARGIN = 20;
/**
* Distance the measurement is drawn while drawing the start and endpoints of the measurements but before the user decides the offset point
*/
export const MEASUREMENT_DEFAULT_OFFSET = 200;
/**
* 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
*/
export const MEASUREMENT_LABEL_OFFSET = 20;
/**
* Size of the measurement labels containing the length of the measurements
*/
export const MEASUREMENT_FONT_SIZE = 40;
/**
* Colors for the selection rectangle
*/
export const SELECTION_RECTANGLE_COLOR_INTERSECTION = '#b6ff9a';
export const SELECTION_RECTANGLE_COLOR_CONTAINS = '#6899f3';
export const SELECTION_RECTANGLE_WIDTH = 1;
export const SELECTION_RECTANGLE_STYLE = [5, 5]; // Dashed line
/**
* Angle guides and move tool line styles
*/
export const GUIDE_LINE_COLOR = '#999';
export const GUIDE_LINE_WIDTH = 1;
export const GUIDE_LINE_STYLE = [5, 5]; // Dashed line
/**
* Mouse zoom multiplier. Higher zooms faster for each mouse scroll
*/
export const MOUSE_ZOOM_MULTIPLIER = 0.1;
/**
* Canvas input field offset to mouse location
*/
export const CANVAS_INPUT_FIELD_MOUSE_OFFSET = 20;
/**
* Canvas input field width
*/
export const CANVAS_INPUT_FIELD_WIDTH = 150;
/**
* Canvas input field height
*/
export const CANVAS_INPUT_FIELD_HEIGHT = 20;
/**
* Canvas input field background color
*/
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#161616';
/**
* Canvas input field text color
*/
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#FFF';
/**
* Canvas input field background color when text is selected
*/
export const CANVAS_INPUT_FIELD_SELECTION_BACKGROUND_COLOR = '#1e90ff';
/**
* Canvas input field text color when text is selected
*/
export const CANVAS_INPUT_FIELD_SELECTION_TEXT_COLOR = '#000';
/**
* Canvas input field text size in pixels
*/
export const CANVAS_INPUT_FIELD_FONT_SIZE = 16;
/**
* Canvas input field instruction text color
*/
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#999';
/**
* Multiplier to determine the pdf line width from the in application line width
* This seems to be needed since 1px line widths look quite fat in pdf
*/
export const PDF_LINE_WIDTH_FACTOR = 0.25;
export const COLOR_LIST = [
'#ffffff',
'#2f4f4f',
'#800000',
'#006400',
'#d2b48c',
'#ff0000',
'#00ced1',
'#ffa500',
'#ffff00',
'#00ff00',
'#0000ff',
'#ff00ff',
'#1e90ff',
'#dda0dd',
'#ff1493',
'#98fb98',
];
/**
* Number to multiply degrees with to end up with the equivalent radians
*/
export const TO_RADIANS = Math.PI / 180;
/**
* Number to multiply radians with to end up with the equivalent degrees
*/
export const TO_DEGREES = 180 / Math.PI;
@@ -0,0 +1 @@
@import "tailwindcss";
@@ -0,0 +1,32 @@
import './App.css';
import { ToastContainer } from 'react-toastify';
import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div
className="overflow-y-scroll h-lvh pb-6 w-80 bg-slate-950"
style={{ scrollbarWidth: 'none' }}
>
<header className="px-3 py-3 border-b border-slate-700 text-white">
<strong className="block text-sm">Aislo 2D Drawing</strong>
<span className="text-xs text-slate-400">B07 </span>
</header>
<Toolbar />
<footer className="px-3 py-3 border-t border-slate-700 text-xs text-slate-400">
Drawing engine based on OpenWebCAD ·{' '}
<a
className="underline hover:text-white"
href="./THIRD_PARTY_LICENSES.txt"
target="_blank"
rel="noreferrer"
>
MIT License
</a>
</footer>
<ToastContainer position="bottom-right" theme="light" />
</div>
);
}
export default App;
@@ -0,0 +1,59 @@
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',
}
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,191 @@
import type {FC} from 'react';
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
import AlignLeftIcon from 'teenyicons/outline/align-left.svg?react';
import AlignRightIcon from 'teenyicons/outline/align-right.svg?react';
import AlignTextJustifyIcon from 'teenyicons/outline/align-text-justify.svg?react';
import AlignTopIcon from 'teenyicons/outline/align-top.svg?react';
import AntiClockwiseIcon from 'teenyicons/outline/anti-clockwise.svg?react';
import ArrowLeftCircle from 'teenyicons/outline/arrow-left-circle.svg?react';
import ArrowRightCircle from 'teenyicons/outline/arrow-right-circle.svg?react';
import CircleIcon from 'teenyicons/outline/circle.svg?react';
import ClockwiseIcon from 'teenyicons/outline/clockwise.svg?react';
import CropIcon from 'teenyicons/outline/crop.svg?react';
import DirectionIcon from 'teenyicons/outline/direction.svg?react';
import DocumentsIcon from 'teenyicons/outline/documents.svg?react';
import DownloadIcon from 'teenyicons/outline/download.svg?react';
import EditIcon from 'teenyicons/outline/edit.svg?react';
import ExpandIcon from 'teenyicons/outline/expand.svg?react';
import EyeClosedIcon from 'teenyicons/outline/eye-closed.svg?react';
import EyeIcon from 'teenyicons/outline/eye.svg?react';
import FilePlusIcon from 'teenyicons/outline/file-plus.svg?react';
import FolderPlusIcon from 'teenyicons/outline/folder-plus.svg?react';
import FolderTickIcon from 'teenyicons/outline/folder-tick.svg?react';
import FolderXIcon from 'teenyicons/outline/folder-x.svg?react';
import FolderIcon from 'teenyicons/outline/folder.svg?react';
import GithubIcon from 'teenyicons/outline/github.svg?react';
import GridLayoutIcon from 'teenyicons/outline/grid-layout.svg?react';
import ImageIcon from 'teenyicons/outline/image.svg?react';
import JavascriptIcon from 'teenyicons/outline/javascript.svg?react';
import LayersDifferenceIcon from 'teenyicons/outline/layers-difference.svg?react';
import LineIcon from 'teenyicons/outline/line.svg?react';
import LockIcon from 'teenyicons/outline/lock.svg?react';
import PdfIcon from 'teenyicons/outline/pdf.svg?react';
import PngIcon from 'teenyicons/outline/png.svg?react';
import SaveIcon from 'teenyicons/outline/save.svg?react';
import SendDownIcon from 'teenyicons/outline/send-down.svg?react';
import SendUpIcon from 'teenyicons/outline/send-up.svg?react';
import SquareIcon from 'teenyicons/outline/square.svg?react';
import SvgIcon from 'teenyicons/outline/svg.svg?react';
import UnlockIcon from 'teenyicons/outline/unlock.svg?react';
import HomeAltIcon from 'teenyicons/outline/home-alt.svg?react';
import VectorDocumentIcon from 'teenyicons/outline/vector-document.svg?react';
import SolidDownSmallIcon from 'teenyicons/solid/down-small.svg?react';
import SolidDownIcon from 'teenyicons/solid/down.svg?react';
import ImageSoldIcon from 'teenyicons/solid/image.svg?react';
import JavascriptSolidIcon from 'teenyicons/solid/javascript.svg?react';
import PdfSolidIcon from 'teenyicons/solid/pdf.svg?react';
import SolidUpSmallIcon from 'teenyicons/solid/up-small.svg?react';
import SolidUpIcon from 'teenyicons/solid/up.svg?react';
import VectorDocumentSolidIcon from 'teenyicons/solid/vector-document.svg?react';
import MeasurementIcon from './custom-icons/measurement.svg?react';
import ScaleIcon from './custom-icons/scale.svg?react'; // https://icon-sets.iconify.design/teenyicons
// https://icon-sets.iconify.design/teenyicons
enum IconName {
// Outline icons
Line = 'Line',
Square = 'Square',
Circle = 'Circle',
Direction = 'Direction',
VectorDocument = 'VectorDocument',
VectorDocumentSolid = 'VectorDocumentSolid',
LayersDifference = 'LayersDifference',
AntiClockwise = 'AntiClockwise',
Clockwise = 'Clockwise',
Github = 'Github',
Crop = 'Crop',
Folder = 'Folder',
FolderTick = 'FolderTick',
Save = 'Save',
Svg = 'Svg',
Pdf = 'Pdf',
PdfSolid = 'PdfSolid',
Png = 'Png',
Javascript = 'Javascript',
JavascriptSolid = 'JavascriptSolid',
Expand = 'Expand',
Image = 'Image',
ImageSolid = 'ImageSolid',
ArrowLeftCircle = 'ArrowLeftCircle',
ArrowRightCircle = 'ArrowRightCircle',
Measurement = 'Measurement',
FilePlus = 'FilePlus',
Edit = 'Edit',
SendUp = 'SendUp',
SendDown = 'SendDown',
AlignLeft = 'AlignLeft',
AlignRight = 'AlignRight',
AlignCenterHorizontal = 'AlignCenterHorizontal',
AlignTop = 'AlignTop',
AlignBottom = 'AlignBottom',
AlignCenterVertical = 'AlignCenterVertical',
Documents = 'Documents',
Download = 'Download',
FolderX = 'FolderX',
FolderPlus = 'FolderPlus',
AlignTextJustify = 'AlignTextJustify',
Eye = 'Eye',
EyeClosed = 'EyeClosed',
Lock = 'Lock',
Unlock = 'Unlock',
GridLayout = 'GridLayout',
HomeAlt = 'HomeAlt',
// Solid icons
SolidDown = 'SolidDown',
SolidUp = 'SolidUp',
SolidUpSmall = 'SolidUpSmall',
SolidDownSmall = 'SolidDownSmall',
// Custom icons
Scale = 'Scale',
}
const icons: Record<IconName, FC> = {
// Outline icons
[IconName.Line]: LineIcon,
[IconName.Square]: SquareIcon,
[IconName.Circle]: CircleIcon,
[IconName.Direction]: DirectionIcon,
[IconName.VectorDocument]: VectorDocumentIcon,
[IconName.VectorDocumentSolid]: VectorDocumentSolidIcon,
[IconName.LayersDifference]: LayersDifferenceIcon,
[IconName.AntiClockwise]: AntiClockwiseIcon,
[IconName.Clockwise]: ClockwiseIcon,
[IconName.Github]: GithubIcon,
[IconName.Crop]: CropIcon,
[IconName.Folder]: FolderIcon,
[IconName.FolderTick]: FolderTickIcon,
[IconName.Save]: SaveIcon,
[IconName.Svg]: SvgIcon,
[IconName.Pdf]: PdfIcon,
[IconName.PdfSolid]: PdfSolidIcon,
[IconName.Png]: PngIcon,
[IconName.Javascript]: JavascriptIcon,
[IconName.JavascriptSolid]: JavascriptSolidIcon,
[IconName.Expand]: ExpandIcon,
[IconName.Image]: ImageIcon,
[IconName.ImageSolid]: ImageSoldIcon,
[IconName.ArrowLeftCircle]: ArrowLeftCircle,
[IconName.ArrowRightCircle]: ArrowRightCircle,
[IconName.Measurement]: MeasurementIcon,
[IconName.FilePlus]: FilePlusIcon,
[IconName.Edit]: EditIcon,
[IconName.SendUp]: SendUpIcon,
[IconName.SendDown]: SendDownIcon,
[IconName.AlignLeft]: AlignLeftIcon,
[IconName.AlignRight]: AlignRightIcon,
[IconName.AlignCenterHorizontal]: AlignCenterHorizontalIcon,
[IconName.AlignTop]: AlignTopIcon,
[IconName.AlignBottom]: AlignBottomIcon,
[IconName.AlignCenterVertical]: AlignCenterVerticalIcon,
[IconName.Documents]: DocumentsIcon,
[IconName.Download]: DownloadIcon,
[IconName.FolderX]: FolderXIcon,
[IconName.FolderPlus]: FolderPlusIcon,
[IconName.AlignTextJustify]: AlignTextJustifyIcon,
[IconName.Eye]: EyeIcon,
[IconName.EyeClosed]: EyeClosedIcon,
[IconName.Lock]: LockIcon,
[IconName.Unlock]: UnlockIcon,
[IconName.GridLayout]: GridLayoutIcon,
[IconName.HomeAlt]: HomeAltIcon,
// Solid icons
[IconName.SolidDown]: SolidDownIcon,
[IconName.SolidUp]: SolidUpIcon,
[IconName.SolidUpSmall]: SolidUpSmallIcon,
[IconName.SolidDownSmall]: SolidDownSmallIcon,
// Custom icons
[IconName.Scale]: ScaleIcon,
};
export { IconName };
interface IconProps {
name: IconName;
className?: string;
}
export const Icon: FC<IconProps> = ({ name, className }) => {
const CurrentIcon = icons[name];
return (
<div className={`icon w-5${className ? ` ${className}` : ''}`}>
<CurrentIcon />
</div>
);
};
@@ -0,0 +1,4 @@
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 7.5l-4 4m4-4l-4-4m4 4H3M1.5 1v13" stroke="currentColor"/>
<path d="M1.5 7.5l4-4m-4 4l4 4m-4-4H12m1.5 6.5V1" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

@@ -0,0 +1,5 @@
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5.5h-12a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1v-12a1 1 0 00-1-1z" stroke="currentColor"/>
<path d="M 9.159 5.175 L 1.166 5.175 C 0.799 5.175 0.5 5.472 0.5 5.841 L 0.5 13.834 C 0.5 14.203 0.799 14.5 1.166 14.5 L 9.159 14.5 C 9.528 14.5 9.825 14.203 9.825 13.834 L 9.825 5.841 C 9.825 5.472 9.528 5.175 9.159 5.175 Z"
stroke="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -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,624 @@
import { type FC, type MouseEvent, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { COLOR_LIST } from '../App.consts';
import { HtmlEvent, type Layer } from '../App.types';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage.ts';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { importEntitiesFromJsonFile } from '../helpers/import-export-handlers/import-entities-from-json';
import { importEntitiesFromSvgFile } from '../helpers/import-export-handlers/import-entities-from-svg.ts';
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
import { times } from '../helpers/times';
import {
getActiveLayerId,
getActiveLineColor,
getActiveLineWidth,
getActiveToolActor,
getAngleStep,
getLayers,
getScreenCanvasDrawController,
redo,
setActiveLayerId,
setActiveLineColor,
setActiveLineWidth,
setActiveToolActor,
setAngleStep,
setEntities,
setLayers,
undo,
} from '../state';
import { Tool } from '../tools';
import { imageImportToolStateMachine } from '../tools/image-import-tool';
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
import { ActorEvent } from '../tools/tool.types';
import { Button } from './Button.tsx';
import { DropdownButton } from './DropdownButton.tsx';
import { Icon, IconName } from './Icon/Icon.tsx';
import { LayerManager } from './LayerManager.tsx';
export const Toolbar: FC = () => {
const [activeToolLocal, setActiveToolLocal] = useState<Tool>(Tool.LINE);
const [angleStepLocal, setAngleStepLocal] = useState<number>(45);
const [activeLineColorLocal, setActiveLineColorLocal] = useState<string>('#FFF');
const [activeLineWidthLocal, setActiveLineWidthLocal] = useState<number>(1);
const [screenZoomLocal, setScreenZoomLocal] = useState<number>(1);
const [layersLocal, setLayersLocal] = useState<Layer[]>(getLayers());
const [activeLayerIdLocal, setActiveLayerIdLocal] = useState(getLayers()[0].id);
const fetchStateUpdatesFromOutside = useCallback(() => {
setActiveToolLocal(getActiveToolActor()?.getSnapshot()?.context.type);
setAngleStepLocal(getAngleStep());
setActiveLineColorLocal(getActiveLineColor());
setActiveLineWidthLocal(getActiveLineWidth());
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
setLayersLocal(getLayers());
setActiveLayerIdLocal(getActiveLayerId());
}, []);
const handleWheel = useCallback((event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
}
}, []);
useEffect(() => {
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
return () => {
window.removeEventListener('wheel', handleWheel);
window.removeEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
};
}, [fetchStateUpdatesFromOutside, handleWheel]);
const handleToolClick = useCallback((tool: Tool) => {
getActiveToolActor()?.stop();
const newToolActor = new Actor(TOOL_STATE_MACHINES[tool]);
setActiveToolActor(newToolActor, false);
setActiveToolLocal(tool);
}, []);
const handleAngleChanged = useCallback((angle: number) => {
setAngleStepLocal(angle);
setAngleStep(angle, false);
}, []);
const noopClickHandler = (evt: MouseEvent) => {
evt.stopPropagation();
};
const handleSetLayers = (newLayers: Layer[]) => {
setLayersLocal(newLayers);
setLayers(newLayers);
};
const handleSetActiveLayerId = (newActiveLayerId: string) => {
setActiveLayerIdLocal(newActiveLayerId);
setActiveLayerId(newActiveLayerId);
};
return (
<div className="controls top-0 left-0 flex flex-col gap-1 p-1 bg-slate-950 overscroll-y-auto">
<DropdownButton
label="Draw"
title={'Draw tools'}
iconName={IconName.Edit}
defaultOpen
dataId="dropdown-draw-tools"
>
<Button
className="w-full"
title="Select (s)"
dataId="select-button"
iconName={IconName.Direction}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SELECT);
}}
active={activeToolLocal === Tool.SELECT}
label="Select"
/>
<Button
className="w-full"
title="Line (l)"
dataId="line-button"
iconName={IconName.Line}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.LINE);
}}
active={activeToolLocal === Tool.LINE}
label="Line"
/>
<Button
className="w-full"
title="Rectangle (r)"
dataId="rectangle-button"
iconName={IconName.Square}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.RECTANGLE);
}}
active={activeToolLocal === Tool.RECTANGLE}
label="Rectangle"
/>
<Button
className="w-full"
title="Circle (c)"
dataId="circle-button"
iconName={IconName.Circle}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.CIRCLE);
}}
active={activeToolLocal === Tool.CIRCLE}
label="Circle"
/>
<Button
className="mt-2 w-full"
title="Move"
dataId="move-button"
iconName={IconName.Expand}
iconClassname={'transform rotate-45'}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MOVE);
}}
active={activeToolLocal === Tool.MOVE}
label="Move"
/>
<Button
className="w-full"
title="Copy"
dataId="copy-button"
iconName={IconName.Documents}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.COPY);
}}
active={activeToolLocal === Tool.COPY}
label="Copy"
/>
<Button
className="w-full"
title="Scale"
dataId="scale-button"
iconName={IconName.Scale}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.SCALE);
}}
active={activeToolLocal === Tool.SCALE}
label="Scale"
/>
<Button
className="w-full"
title="Rotate"
dataId="rotate-button"
iconName={IconName.Clockwise}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ROTATE);
}}
active={activeToolLocal === Tool.ROTATE}
label="Rotate"
/>
<Button
className="w-full"
title="Array"
dataId="array-button"
iconName={IconName.GridLayout}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ARRAY);
}}
active={activeToolLocal === Tool.ARRAY}
label="Array copy"
/>
<Button
className="w-full"
title="Create polyline lines and arcs"
dataId="pedit-button"
iconComponent={<Icon name={IconName.HomeAlt} className="rotate-270" />}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.PEDIT);
}}
active={activeToolLocal === Tool.PEDIT}
label="Polyline edit"
/>
<Button
className="mt-2 w-full"
title="Add measurements"
dataId="measurement-button"
iconName={IconName.Measurement}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.MEASUREMENT);
}}
active={activeToolLocal === Tool.MEASUREMENT}
label="Measurement"
/>
<Button
className="mt-2 w-full"
title="Delete segments"
dataId="delete-segment-button"
iconName={IconName.Crop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ERASER);
}}
active={activeToolLocal === Tool.ERASER}
label="Eraser"
/>
</DropdownButton>
<DropdownButton dataId="layers" label="Layers" iconName={IconName.AlignTextJustify}>
<LayerManager
className="w-full"
layers={layersLocal}
activeLayerId={activeLayerIdLocal}
setLayers={handleSetLayers}
setActiveLayerId={handleSetActiveLayerId}
/>
</DropdownButton>
<Button
className="mt-2"
title="Undo (ctrl + z)"
dataId="undo-button"
iconName={IconName.ArrowLeftCircle}
onClick={() => undo()}
label="Undo"
/>
<Button
title="Redo (ctrl + shift + z)"
dataId="redo-button"
iconName={IconName.ArrowRightCircle}
onClick={() => redo()}
label="Redo"
/>
<DropdownButton
className="mt-2"
title="Align"
dataId="align-button"
label="Align"
iconName={IconName.AlignCenterHorizontal}
>
<Button
className="w-full"
title="Align left"
dataId="align-left-button"
iconName={IconName.AlignLeft}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_LEFT);
}}
active={activeToolLocal === Tool.ALIGN_LEFT}
label="Left"
/>
<Button
className="w-full"
title="Align center horizontal"
dataId="align-center-horizontal-button"
iconName={IconName.AlignCenterHorizontal}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_HORIZONTAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_HORIZONTAL}
label="Center"
/>
<Button
className="w-full"
title="Align right"
dataId="align-right-button"
iconName={IconName.AlignRight}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_RIGHT);
}}
active={activeToolLocal === Tool.ALIGN_RIGHT}
label="Right"
/>
<Button
className="w-full"
title="Align top"
dataId="align-top-button"
iconName={IconName.AlignTop}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_TOP);
}}
active={activeToolLocal === Tool.ALIGN_TOP}
label="Top"
/>
<Button
className="w-full"
title="Align center vertical"
dataId="align-center-vertical-button"
iconName={IconName.AlignCenterVertical}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_CENTER_VERTICAL);
}}
active={activeToolLocal === Tool.ALIGN_CENTER_VERTICAL}
label="Middle"
/>
<Button
className="w-full"
title="Align bottom"
dataId="align-bottom-button"
iconName={IconName.AlignBottom}
onClick={(evt) => {
evt.stopPropagation();
handleToolClick(Tool.ALIGN_BOTTOM);
}}
active={activeToolLocal === Tool.ALIGN_BOTTOM}
label="Bottom"
/>
</DropdownButton>
<DropdownButton
className="mt-2"
title="Line color"
dataId="line-color-button"
label="Line color"
iconComponent={
<div className="w-5 h-5" style={{ backgroundColor: activeLineColorLocal }} />
}
>
{COLOR_LIST.map((color) => (
<Button
key={`line-color--${color}`}
title="Change line color"
dataId={`line-color-${color}-button`}
className="w-10"
style={{ backgroundColor: color }}
active={color === activeLineColorLocal}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineColor(color);
}}
/>
))}
</DropdownButton>
<DropdownButton
title="Line width"
dataId="line-width-button"
label="Line width"
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${activeLineWidthLocal}px` }}
/>
}
>
{times<number>(9).map((width: number) => {
const lineWidth = width + 1;
return (
<Button
key={`line-width--${lineWidth}`}
title="Change line width"
dataId={`line-width-${lineWidth}-button`}
label={`${String(lineWidth)}px`}
active={lineWidth === activeLineWidthLocal}
iconComponent={
<div
className="w-5 h-0 -rotate-45 border-t-white"
style={{ borderTopWidth: `${lineWidth}px` }}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
setActiveLineWidth(lineWidth);
}}
/>
);
})}
</DropdownButton>
<DropdownButton
title="Snap angles"
iconComponent={<div className="w-5 text-blue-700">{`${angleStepLocal}°`}</div>}
label="Snap angles"
dataId="angle-guide-button"
>
{[5, 15, 30, 45, 90].map((angle: number) => (
<Button
key={`angle-guide--${angle}`}
title={`Add guide every ${angle} degrees`}
dataId={`angle-guide-${angle}-button`}
label={`${angle}°`}
iconComponent={
<div
className={'w-5 h-0 border-t-2 border-t-white'}
style={{ rotate: `${-angle}deg` }}
/>
}
style={{ width: 'calc(50% - 2px)' }}
onClick={(evt) => {
evt.stopPropagation();
handleAngleChanged(angle);
}}
active={angle === angleStepLocal}
/>
))}
</DropdownButton>
<DropdownButton
title="Zoom level"
iconComponent={<div className="w-5 text-blue-700">{screenZoomLocal.toFixed(1)}</div>}
label="Zoom level"
dataId="zoom-level-button"
>
{[20, 50, 75, 100, 150, 200, 400].map((zoom: number) => (
<Button
key={`zoom-level--${zoom}`}
title={`Zoom level ${zoom}%`}
dataId={`zoom-level-${zoom}-button`}
label={`${zoom.toFixed(0)}%`}
style={{ width: 'calc(30% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
getScreenCanvasDrawController().setScreenScale(zoom / 100);
setScreenZoomLocal(zoom / 100);
}}
active={zoom === screenZoomLocal}
/>
))}
<Button
key="zoom-level--fit"
title="Zoom fit screen"
dataId="zoom-level-fit-button"
label="Fit screen"
style={{ width: 'calc(60% - 2px)', padding: '8px' }}
onClick={(evt) => {
evt.stopPropagation();
getScreenCanvasDrawController().zoomToFitScreen();
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
}}
active={false}
/>
</DropdownButton>
<Button
className="mt-2"
title="Save current drawing"
dataId="save-button"
iconName={IconName.Save}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToLocalStorage();
toast.success('Saved');
}}
label="Save drawing"
/>
<Button
className="mt-2"
title="Start a new drawing"
dataId="new-button"
iconName={IconName.FilePlus}
onClick={(evt) => {
evt.stopPropagation();
setEntities([]);
}}
label="New drawing"
/>
<DropdownButton
label="Import"
title={'Import files'}
iconName={IconName.SendUp}
dataId="dropdown-import-tools"
>
<Button
className="relative w-full"
title="Import image into the current drawing"
dataId="import-image-button"
iconName={IconName.ImageSolid}
onClick={noopClickHandler}
label="image"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.jpg,*.jpeg,*.png"
onChange={async (evt) => {
const image: HTMLImageElement = await importImageFromFile(evt.target.files?.[0]);
const imageImportActor = new Actor(imageImportToolStateMachine);
imageImportActor.start();
imageImportActor.send({
type: ActorEvent.FILE_SELECTED,
image,
});
setActiveToolActor(imageImportActor);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from JSON file"
dataId="json-open-button"
iconName={IconName.JavascriptSolid}
onClick={noopClickHandler}
label="JSON"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.json"
onChange={async (evt) => {
await importEntitiesFromJsonFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
<Button
className="relative w-full"
title="Load from SVG file"
dataId="svg-open-button"
iconName={IconName.VectorDocumentSolid}
onClick={noopClickHandler}
label="SVG"
>
<input
className="absolute inset-0 opacity-0"
type="file"
accept="*.svg"
onChange={async (evt) => {
await importEntitiesFromSvgFile(evt.target.files?.[0]);
evt.target.files = null;
}}
/>
</Button>
</DropdownButton>
<DropdownButton
label="Export"
title={'Export file'}
iconName={IconName.SendDown}
dataId="dropdown-export-tools"
>
<Button
className="w-full"
title="Save to JSON file"
dataId="json-save-button"
iconName={IconName.JavascriptSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToJsonFile();
}}
label="JSON"
/>
<Button
className="w-full"
title="Export to SVG file"
dataId="svg-export-button"
iconName={IconName.VectorDocumentSolid}
onClick={(evt) => {
evt.stopPropagation();
exportEntitiesToSvgFile();
}}
label="SVG"
/>
<Button
className="w-full"
title="Export to PNG file"
dataId="png-export-button"
iconName={IconName.ImageSolid}
onClick={async (evt) => {
evt.stopPropagation();
await exportEntitiesToPngFile();
}}
label="PNG"
/>
</DropdownButton>
</div>
);
};
@@ -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,487 @@
import {Point, type Vector} from '@flatten-js/core';
import {CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER} from '../App.consts';
import {containRectangle} from '../helpers/contain-rect.ts';
import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts';
import {getBoundingBoxOfMultipleEntities} from '../helpers/get-bounding-box-of-multiple-entities.ts';
import {mapNumberRange} from '../helpers/map-number-range.ts';
import {StateVariable} from '../helpers/undo-stack.ts';
import {getEntities, getScreenCanvasDrawController, triggerReactUpdate} from '../state.ts';
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
/**
* 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
*/
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);
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
);
}
public zoomToFitScreen() {
const boundingBox = getBoundingBoxOfMultipleEntities(getEntities());
const boundingWidth = boundingBox.maxX - boundingBox.minX;
const fittedRect = containRectangle(
boundingBox.minX,
boundingBox.minY,
boundingBox.maxX,
boundingBox.maxY,
0,
0,
getScreenCanvasDrawController().getCanvasSize().x,
getScreenCanvasDrawController().getCanvasSize().y
);
const fittedWidth = fittedRect.maxX - fittedRect.minX;
const zoomLevel = fittedWidth / boundingWidth;
getScreenCanvasDrawController().setScreenScale(zoomLevel);
getScreenCanvasDrawController().setScreenOffset(new Point(fittedRect.minX, fittedRect.minY));
}
/**
* 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[] = []
) {
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]);
}
}
public setFillStyles(fillColor: string) {
this.context.fillStyle = fillColor;
}
public clear() {
if (this.canvasSize === null) return;
if (!this.context) return;
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
}
/**
* 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 {
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
) {
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,
};
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 {
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) {
// 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[]) {
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,269 @@
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,
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;
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,171 @@
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,
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;
return lineEntity;
}
}
export interface ArrowHeadJsonData {
p1: { x: number; y: number };
p2: { x: number; y: number };
p3: { x: number; y: number };
}
@@ -0,0 +1,200 @@
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,
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;
return circleEntity;
}
public getRadius(): number {
return this.circle?.r ?? 0;
}
}
export interface CircleJsonData {
center: { x: number; y: number };
radius: number;
}
@@ -0,0 +1,80 @@
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;
layerId: string;
shapeData: TShapeJsonData | null;
children?: JsonEntity<ShapeJsonData>[];
}
@@ -0,0 +1,251 @@
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,
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;
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,220 @@
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,
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;
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,576 @@
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, isEntityHighlighted, isEntitySelected} from '../state.ts';
import {type Entity, EntityName, type JsonEntity} from './Entity';
import type {LineEntity} from './LineEntity.ts';
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);
// Start of the perpendicular lines
const offsetStartPointMargin = this.startPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
);
const offsetEndPointMargin = this.endPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
);
// End of the perpendicular lines
const offsetStartPointExtend = offsetStartPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
);
const offsetEndPointExtend = offsetEndPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
);
// Location for label
const midpointMeasurementLine = new Point(
(offsetStartPoint.x + offsetEndPoint.x) / 2,
(offsetStartPoint.y + offsetEndPoint.y) / 2
);
const textHeight = MEASUREMENT_FONT_SIZE;
const totalOffset = MEASUREMENT_LABEL_OFFSET + 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 => {
const screenScale = drawController.getScreenScale();
const vectorFromEndToStart = new Vector(endPoint, startPoint);
const vectorFromEndToStartUnit = vectorFromEndToStart.normalize();
const baseOfArrow = endPoint
.clone()
.translate(vectorFromEndToStartUnit.multiply(ARROW_HEAD_LENGTH * screenScale));
const perpendicularVector1 = vectorFromEndToStartUnit.rotate(90 * TO_RADIANS);
const perpendicularVector2 = vectorFromEndToStartUnit.rotate(-90 * TO_RADIANS);
const leftCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector1.multiply(ARROW_HEAD_WIDTH * screenScale));
const rightCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH * screenScale));
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,
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 textHeight = MEASUREMENT_FONT_SIZE;
// Estimate width: textString.length * fontSize * aspectRatioFactor
const textWidth = distance.length * MEASUREMENT_FONT_SIZE * 0.6;
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,
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;
return measurementEntity;
}
}
export interface MeasurementJsonData {
startPoint: { x: number; y: number };
endPoint: { x: number; y: number };
offsetPoint: { x: number; y: number };
}
@@ -0,0 +1,155 @@
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,
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;
return lineEntity;
}
}
export interface PointJsonData {
point: {
x: number;
y: number;
};
}
@@ -0,0 +1,185 @@
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,
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;
return polyLineEntity;
}
}
export type PolyLineJsonData = Record<never, never>;
@@ -0,0 +1,211 @@
import type * as Flatten from '@flatten-js/core';
import {type Box, Point, Polygon, Relations, type Segment, Vector} from '@flatten-js/core';
import {type Shape, type SnapPoint, SnapPointType} from '../App.types';
import type {DrawController} from '../drawControllers/DrawController';
import {twoPointBoxToPolygon} from '../helpers/box-to-polygon';
import {getExportColor} from '../helpers/get-export-color';
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
import {polygonToSegments} from '../helpers/polygon-to-segments';
import {scalePoint} from '../helpers/scale-point';
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
import {type Entity, EntityName, type JsonEntity} from './Entity';
import type {LineEntity} from './LineEntity.ts';
export class RectangleEntity implements Entity {
public id: string = crypto.randomUUID();
public lineColor = '#fff';
public lineWidth = 1;
public lineDash: number[] | undefined = undefined;
public layerId: string;
private polygon: Polygon;
constructor(layerId: string, startPointOrPolygon?: Point | Polygon, endPoint?: Point) {
this.layerId = layerId;
if (startPointOrPolygon instanceof Polygon) {
this.polygon = startPointOrPolygon as Polygon;
} else {
this.polygon = twoPointBoxToPolygon(startPointOrPolygon as Point, endPoint as Point);
}
}
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)) {
const startPoint = new Point(edge.start.x, edge.start.y);
const endPoint = new Point(edge.end.x, edge.end.y);
drawController.drawLine(startPoint, endPoint);
}
}
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);
}
public mirror(mirrorAxis: LineEntity) {
const mirroredVertices = this.polygon.vertices.map((p) => mirrorPointOverAxis(p, mirrorAxis));
this.polygon = new Polygon(mirroredVertices);
}
public clone(): RectangleEntity {
return new RectangleEntity(getActiveLayerId(), this.polygon.clone());
}
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) => {
return segment.distanceTo(shape);
});
let shortestDistanceInfo: [number, Segment | null] = [Number.MAX_VALUE, 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.Rectangle;
}
public containsPointOnShape(point: Flatten.Point): boolean {
return polygonToSegments(this.polygon).some((segment) => segment.contains(point));
}
public async toJson(): Promise<JsonEntity<RectangleJsonData> | null> {
return {
id: this.id,
type: EntityName.Rectangle,
lineColor: this.lineColor,
lineWidth: this.lineWidth,
layerId: this.layerId,
shapeData: {
points: this.polygon.vertices.map((vertex) => ({
x: vertex.x,
y: vertex.y,
})),
},
};
}
public static async fromJson(
jsonEntity: JsonEntity<RectangleJsonData>
): Promise<RectangleEntity> {
if (!jsonEntity.shapeData) {
throw new Error('Invalid JSON entity of type Rectangle: missing shapeData');
}
const rectangle = new Polygon(
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y))
);
const rectangleEntity = new RectangleEntity(
jsonEntity.layerId || getActiveLayerId(),
rectangle
);
rectangleEntity.id = jsonEntity.id;
rectangleEntity.lineColor = jsonEntity.lineColor;
rectangleEntity.lineWidth = jsonEntity.lineWidth;
return rectangleEntity;
}
}
export interface RectangleJsonData {
points: { x: number; y: number }[];
}
@@ -0,0 +1,198 @@
import {Box, Point, type Segment, Vector} from '@flatten-js/core';
import {cloneDeep} from 'es-toolkit/compat';
import type {Shape, SnapPoint} from '../App.types';
import {DEFAULT_TEXT_OPTIONS, type DrawController} from '../drawControllers/DrawController';
import {mirrorPointOverAxis} from '../helpers/mirror-point-over-axis.ts';
import {scalePoint} from '../helpers/scale-point.ts';
import {getActiveLayerId, isEntityHighlighted, isEntitySelected} from '../state.ts';
import {type Entity, EntityName, type JsonEntity} from './Entity';
import type {LineEntity} from './LineEntity.ts';
export interface TextOptions {
textDirection: Vector;
textAlign: 'left' | 'center' | 'right';
textColor: string;
fontSize: number;
fontFamily: string;
}
export class TextEntity implements Entity {
public id: string = crypto.randomUUID();
public lineColor = '#fff';
public lineWidth = 1;
public lineDash: number[] = [];
public layerId: string;
private readonly options: TextOptions;
constructor(
layerId: string,
private label: string,
private basePoint: Point,
options?: Partial<TextOptions>
) {
this.layerId = layerId;
this.options = {
...DEFAULT_TEXT_OPTIONS,
...options,
};
}
public draw(
drawController: DrawController,
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
drawController.setLineStyles(
parentHighlighted ?? isEntityHighlighted(this),
parentSelected ?? isEntitySelected(this),
this.lineColor,
this.lineWidth,
this.lineDash
);
drawController.drawText(this.label, this.basePoint, this.options);
}
public move(x: number, y: number) {
this.basePoint = this.basePoint.translate(x, y);
}
public scale(scaleOrigin: Point, scaleFactor: number) {
this.basePoint = scalePoint(this.basePoint, scaleOrigin, scaleFactor);
this.options.fontSize = this.options.fontSize * scaleFactor; // TODO discuss if text should scale or not?
}
public rotate(rotateOrigin: Point, angle: number) {
this.basePoint = this.basePoint.rotate(angle, rotateOrigin);
this.options.textDirection = this.options.textDirection.rotate(angle);
}
public mirror(mirrorAxis: LineEntity) {
this.basePoint = mirrorPointOverAxis(this.basePoint, mirrorAxis);
this.options.textDirection = new Vector(
new Point(0, 0),
new Point(this.options.textDirection.x, this.options.textDirection.y)
);
}
public clone(): TextEntity {
return new TextEntity(
getActiveLayerId(),
this.label,
this.basePoint.clone(),
cloneDeep(this.options)
);
}
public intersectsWithBox(box: Box): boolean {
return box.contains(this.basePoint);
}
public isContainedInBox(box: Box): boolean {
return box.contains(this.basePoint);
}
public getBoundingBox(): Box {
// TODO find better way of determining the text bounding box
return new Box(
this.basePoint.x,
this.basePoint.y,
this.basePoint.x + this.options.fontSize * this.label.length,
this.basePoint.y + this.options.fontSize
);
}
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.basePoint;
}
public distanceTo(shape: Shape): [number, Segment] | null {
return this.basePoint.distanceTo(shape);
}
public getSvgString(): string | null {
return null;
}
public getType(): EntityName {
return EntityName.Text;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public containsPointOnShape(_point: Point): boolean {
return false;
}
public async toJson(): Promise<JsonEntity<TextJsonData> | null> {
return {
id: this.id,
type: EntityName.Text,
lineColor: this.lineColor,
lineWidth: this.lineWidth,
layerId: this.layerId,
shapeData: {
label: this.label,
basePoint: { x: this.basePoint.x, y: this.basePoint.y },
options: {
textDirection: {
x: this.options.textDirection.x,
y: this.options.textDirection.y,
},
textAlign: this.options.textAlign,
textColor: this.options.textColor,
fontSize: this.options.fontSize,
fontFamily: this.options.fontFamily,
},
},
};
}
public static async fromJson(jsonEntity: JsonEntity<TextJsonData>): Promise<TextEntity> {
if (!jsonEntity.shapeData) {
throw new Error('Invalid JSON entity of type Text: missing shapeData');
}
const textEntity = new TextEntity(
jsonEntity.layerId || getActiveLayerId(),
jsonEntity.shapeData.label,
new Point(jsonEntity.shapeData.basePoint.x, jsonEntity.shapeData.basePoint.y),
{
textDirection: new Vector(
jsonEntity.shapeData.options.textDirection.x,
jsonEntity.shapeData.options.textDirection.y
),
textAlign: jsonEntity.shapeData.options.textAlign,
textColor: jsonEntity.shapeData.options.textColor,
fontSize: jsonEntity.shapeData.options.fontSize,
fontFamily: jsonEntity.shapeData.options.fontFamily,
}
);
textEntity.id = jsonEntity.id;
textEntity.lineColor = jsonEntity.lineColor;
textEntity.lineWidth = jsonEntity.lineWidth;
return textEntity;
}
}
export interface TextJsonData {
label: string;
basePoint: { x: number; y: number };
options: {
textDirection: { x: number; y: number };
textAlign: 'left' | 'center' | 'right';
textColor: string;
fontSize: number;
fontFamily: string;
};
}
@@ -0,0 +1,19 @@
import { type Box, Point, Polygon } from '@flatten-js/core';
export function boxToPolygon(box: Box): Polygon {
return new Polygon([
new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
]);
}
export function twoPointBoxToPolygon(first: Point, second: Point): Polygon {
return new Polygon([
new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)),
new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)),
new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)),
new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)),
]);
}
@@ -0,0 +1,48 @@
import {
getAngleGuideOriginPoint,
getAngleStep,
getEntities,
getHoveredSnapPoints,
getScreenCanvasDrawController,
getShouldDrawHelpers,
setAngleGuideEntities,
setSnapPoint,
setSnapPointOnAngleGuide,
} from '../state.ts';
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { getDrawHelpers } from './get-draw-guides.ts';
import { compact } from 'es-toolkit';
/**
* Calculate angle guides and snap points
*/
export function calculateAngleGuidesAndSnapPoints() {
const angleStep = getAngleStep();
const screenCanvasDrawController = getScreenCanvasDrawController();
const entities = getEntities();
const screenScale = screenCanvasDrawController.getScreenScale();
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
const hoveredSnapPoints = getHoveredSnapPoints();
const eligibleHoveredSnapPoints = hoveredSnapPoints.filter(
hoveredSnapPoint =>
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
);
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
);
if (getShouldDrawHelpers()) {
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
entities,
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
SNAP_POINT_DISTANCE / screenScale,
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
setSnapPointOnAngleGuide(angleSnapPoint);
}
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { containRectangle } from './contain-rect.ts';
describe('containRectangle', () => {
it('scales down a larger rectangle to fit into a smaller wrapper', () => {
const result = containRectangle(
0,
0,
200,
200, // contained: a 200x200 square
0,
0,
100,
100, // wrapper: a 100x100 square
);
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
});
it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => {
const result = containRectangle(
0,
0,
50,
50, // contained: 50x50
0,
0,
200,
200, // wrapper: 200x200
);
// Expected: scale up by factor of 4 to fill as much space as possible while containing
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 });
});
it('maintains aspect ratio when wrapper is rectangular and contained is square', () => {
const result = containRectangle(
0,
0,
50,
50, // contained: 50x50 square
0,
0,
200,
100, // wrapper: 200x100
);
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
// Min scale = 2, so final size = 100x100.
// Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset.
// Result = (50,0) to (150,100)
expect(result.minX).toBeCloseTo(50);
expect(result.minY).toBeCloseTo(0);
expect(result.maxX).toBeCloseTo(150);
expect(result.maxY).toBeCloseTo(100);
});
it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => {
const result = containRectangle(
0,
0,
200,
50, // contained: 200x50
0,
0,
300,
100, // wrapper: 300x100
);
// Contained AR = 200/50 = 4:1
// Wrapper AR = 300/100 = 3:1
// To fit inside 300x100:
// Scale factors: width scale = 300/200=1.5, height scale=100/50=2.
// min scale = 1.5
// Final size: 200*1.5=300 width, 50*1.5=75 height
// Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully
expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 });
});
it('handles zero-width/height contained rectangle gracefully', () => {
// Contained rectangle is essentially a line or point
const result = containRectangle(
10,
10,
10,
10, // contained has 0 width/height
0,
0,
200,
200, // wrapper
);
// Center as a single point at (100,100)
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
});
it('does not scale if contained rectangle already fits', () => {
const result = containRectangle(
0,
0,
100,
100, // contained fits easily
0,
0,
300,
300, // wrapper
);
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
// So final size is 300x300, centered at (0,0).
expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 });
});
it('correctly centers when wrapper and contained have different origins', () => {
const result = containRectangle(
5,
5,
15,
35, // contained: 10 wide x 30 tall
10,
20,
110,
220, // wrapper: 100x200
);
// Wrapper size: 100x200
// Contained size: 10x30
// Scale factors: width scale = 100/10=10, height scale=200/30 ≈ 6.666...
// min scale = 6.666...
// Final size: width = 10 * 6.666... ≈ 66.666..., height = 30 * 6.666... ≈ 200
// After scaling, top-left corner should be placed so it centers:
// Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minX≈26.666...
// Vertical center: fits height exactly, so minY=20, maxY=20+200=220
expect(result.minX).toBeCloseTo(26.6667);
expect(result.minY).toBeCloseTo(20);
expect(result.maxX).toBeCloseTo(93.3333);
expect(result.maxY).toBeCloseTo(220);
});
it('handles negative coordinates in wrapper and contained rectangles', () => {
const result = containRectangle(
-50,
-25,
50,
25, // contained: 100 wide x 50 tall
-100,
-50,
100,
50, // wrapper: 200 wide x 100 tall
);
// Scale factors: width scale = 200/100=2, height scale=100/50=2
// min scale = 2, final size: 200x100 exactly.
// Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y)
// After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50
expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 });
});
it('handles negative coordinates in contained rectangles', () => {
const result = containRectangle(
-50,
-25,
50,
25, // contained: 100 wide x 50 tall
0,
0,
100,
100, // wrapper: 100 wide x 100 tall
);
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
});
});
@@ -0,0 +1,52 @@
export function containRectangle(
containedRectMinX: number,
containedRectMinY: number,
containedRectMaxX: number,
containedRectMaxY: number,
wrapperRectMinX: number,
wrapperRectMinY: number,
wrapperRectMaxX: number,
wrapperRectMaxY: number,
): { minX: number; minY: number; maxX: number; maxY: number } {
// Calculate the width and height of the wrapper rectangle
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
const wrapperHeight = wrapperRectMaxY - wrapperRectMinY;
// Calculate the width and height of the contained rectangle
const containedWidth = containedRectMaxX - containedRectMinX;
const containedHeight = containedRectMaxY - containedRectMinY;
// Edge case: if contained dimensions are zero, just center as a point
if (containedWidth === 0 || containedHeight === 0) {
const centerX = wrapperRectMinX + wrapperWidth / 2;
const centerY = wrapperRectMinY + wrapperHeight / 2;
return {
minX: centerX,
minY: centerY,
maxX: centerX,
maxY: centerY,
};
}
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
const scale = Math.min(
wrapperWidth / containedWidth,
wrapperHeight / containedHeight,
);
// Compute final displayed dimensions
const displayWidth = containedWidth * scale;
const displayHeight = containedHeight * scale;
// Compute offsets to center the scaled rectangle
const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2;
const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2;
// Return the final coordinates of the scaled and centered rectangle
return {
minX: offsetX,
minY: offsetY,
maxX: offsetX + displayWidth,
maxY: offsetY + displayHeight,
};
}
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { svgPathToSegments } from './convert-svg-path-to-line-segments';
describe('svgPathToSegments', () => {
it('should handle simple move and line commands', () => {
const path = 'M 10 10 L 20 20';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 20 },
]);
});
it('should handle relative line commands', () => {
const path = 'M 10 10 l 10 10';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 20 },
]);
});
it('should handle horizontal and vertical lines', () => {
const path = 'M 10 10 H 20 V 30';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 10 },
{ x1: 20, y1: 10, x2: 20, y2: 30 },
]);
});
it('should handle the close path (Z) command', () => {
const path = 'M 10 10 L 20 10 L 20 20 Z';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 10 },
{ x1: 20, y1: 10, x2: 20, y2: 20 },
// The close command draws a segment back to the starting point.
{ x1: 20, y1: 20, x2: 10, y2: 10 },
]);
});
it('should approximate cubic bezier curves', () => {
const path = 'M 10 10 C 20 20 30 20 40 10';
const segments = svgPathToSegments(path);
// Since the cubic curve is subdivided into multiple segments,
// we expect more than one segment.
expect(segments.length).toBeGreaterThan(1);
// Check that the approximation starts at (10,10)
expect(segments[0].x1).toBeCloseTo(10, 5);
expect(segments[0].y1).toBeCloseTo(10, 5);
// Check that the approximation ends at (40,10)
const lastSegment = segments[segments.length - 1];
expect(lastSegment.x2).toBeCloseTo(40, 5);
expect(lastSegment.y2).toBeCloseTo(10, 5);
});
it('should approximate quadratic bezier curves', () => {
const path = 'M 10 10 Q 20 20 30 10';
const segments = svgPathToSegments(path);
expect(segments.length).toBeGreaterThan(1);
// Check that the approximation starts at (10,10)
expect(segments[0].x1).toBeCloseTo(10, 5);
expect(segments[0].y1).toBeCloseTo(10, 5);
// And that it ends at (30,10)
const lastSegment = segments[segments.length - 1];
expect(lastSegment.x2).toBeCloseTo(30, 5);
expect(lastSegment.y2).toBeCloseTo(10, 5);
});
it('should approximate arcs', () => {
// This arc command goes from (10,10) to (20,10) with radii of 10.
const path = 'M 10 10 A 10 10 0 0 1 20 10';
const segments = svgPathToSegments(path);
expect(segments.length).toBeGreaterThan(1);
// Verify that the arc approximation starts at (10,10)
expect(segments[0].x1).toBeCloseTo(10, 5);
expect(segments[0].y1).toBeCloseTo(10, 5);
// And ends at (20,10)
const lastSegment = segments[segments.length - 1];
expect(lastSegment.x2).toBeCloseTo(20, 5);
expect(lastSegment.y2).toBeCloseTo(10, 5);
});
it('should handle triangles', () => {
// This arc command goes from (10,10) to (20,10) with radii of 10.
const path = 'M 152.982 124.448 L 176.73 156.849 L 129.234 156.849 L 152.982 124.448 Z';
const segments = svgPathToSegments(path);
expect(segments.length).toBe(3);
// Verify first point of triangle
expect(segments[2].x2).toBeCloseTo(152.982, 5);
expect(segments[2].y2).toBeCloseTo(124.448, 5);
expect(segments[0].x1).toBeCloseTo(152.982, 5);
expect(segments[0].y1).toBeCloseTo(124.448, 5);
// Verify second point of triangle
expect(segments[0].x2).toBeCloseTo(176.73, 5);
expect(segments[0].y2).toBeCloseTo(156.849, 5);
expect(segments[1].x1).toBeCloseTo(176.73, 5);
expect(segments[1].y1).toBeCloseTo(156.849, 5);
// Verify third point of triangle
expect(segments[1].x2).toBeCloseTo(129.234, 5);
expect(segments[1].y2).toBeCloseTo(156.849, 5);
expect(segments[2].x1).toBeCloseTo(129.234, 5);
expect(segments[2].y1).toBeCloseTo(156.849, 5);
});
});
@@ -0,0 +1,471 @@
import {toast} from 'react-toastify';
// A small type alias for clarity.
type Point = { x: number; y: number };
// Helper: returns the midpoint between two points.
function midpoint(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
// Helper: distance from point p to the line defined by points a and b.
function distancePointToLine(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const mag = Math.sqrt(dx * dx + dy * dy);
if (mag === 0) return Math.hypot(p.x - a.x, p.y - a.y);
return Math.abs(dy * p.x - dx * p.y + b.x * a.y - b.y * a.x) / mag;
}
/**
* Recursively subdivides a cubic Bezier until the control points lie
* close enough (within tolerance) to the chord.
*/
function approximateCubicBezier(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
tolerance: number
): Point[] {
function recursive(a: Point, b: Point, c: Point, d: Point, tol: number): Point[] {
// Check the “flatness” by measuring the distance from the two control points
// to the line connecting the endpoints.
const d1 = distancePointToLine(b, a, d);
const d2 = distancePointToLine(c, a, d);
if (Math.max(d1, d2) < tol) {
return [a, d];
}
// Subdivide using de Casteljaus algorithm.
const ab = midpoint(a, b);
const bc = midpoint(b, c);
const cd = midpoint(c, d);
const abc = midpoint(ab, bc);
const bcd = midpoint(bc, cd);
const abcd = midpoint(abc, bcd);
const left = recursive(a, ab, abc, abcd, tol);
const right = recursive(abcd, bcd, cd, d, tol);
// Avoid duplicating the middle point.
return left.slice(0, -1).concat(right);
}
return recursive(p0, p1, p2, p3, tolerance);
}
/**
* Recursively subdivides a quadratic Bezier curve.
*/
function approximateQuadraticBezier(p0: Point, p1: Point, p2: Point, tolerance: number): Point[] {
function recursive(a: Point, b: Point, c: Point, tol: number): Point[] {
const d = distancePointToLine(b, a, c);
if (d < tol) {
return [a, c];
}
const ab = midpoint(a, b);
const bc = midpoint(b, c);
const abc = midpoint(ab, bc);
const left = recursive(a, ab, abc, tol);
const right = recursive(abc, bc, c, tol);
return left.slice(0, -1).concat(right);
}
return recursive(p0, p1, p2, tolerance);
}
/**
* Approximates an elliptical arc defined by the SVG “A” command.
*
* This function uses the standard SVG algorithm to compute the arcs
* center and angles and then divides the arc into small segments so that
* the chord error is below the given tolerance.
*/
function approximateArc(
p0: Point,
rx: number,
ry: number,
phi: number,
largeArcFlag: boolean,
sweepFlag: boolean,
p2: Point,
tolerance: number
): Point[] {
const phiRad = (phi * Math.PI) / 180;
const dx = (p0.x - p2.x) / 2;
const dy = (p0.y - p2.y) / 2;
let rxInternal = rx;
let ryInternal = ry;
// Step 1: Compute the transformed start point.
const x1p = Math.cos(phiRad) * dx + Math.sin(phiRad) * dy;
const y1p = -Math.sin(phiRad) * dx + Math.cos(phiRad) * dy;
// Ensure the radii are large enough.
let rxSq = rxInternal * rxInternal;
let rySq = ryInternal * ryInternal;
const x1pSq = x1p * x1p;
const y1pSq = y1p * y1p;
const lambda = x1pSq / rxSq + y1pSq / rySq;
if (lambda > 1) {
const factor = Math.sqrt(lambda);
rxInternal *= factor;
ryInternal *= factor;
rxSq = rxInternal * rxInternal;
rySq = ryInternal * ryInternal;
}
// Step 2: Compute the center.
const sign = largeArcFlag === sweepFlag ? -1 : 1;
const numerator = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq;
const denominator = rxSq * y1pSq + rySq * x1pSq;
const coefficient = sign * Math.sqrt(Math.max(0, numerator / denominator));
const cxp = (coefficient * (rxInternal * y1p)) / ryInternal;
const cyp = (coefficient * (-ryInternal * x1p)) / rxInternal;
// Step 3: Transform back to original coordinates.
const cx = Math.cos(phiRad) * cxp - Math.sin(phiRad) * cyp + (p0.x + p2.x) / 2;
const cy = Math.sin(phiRad) * cxp + Math.cos(phiRad) * cyp + (p0.y + p2.y) / 2;
// Step 4: Compute the start and delta angles.
function angle(u: Point, v: Point): number {
const dot = u.x * v.x + u.y * v.y;
const len = Math.sqrt((u.x * u.x + u.y * u.y) * (v.x * v.x + v.y * v.y));
let ang = Math.acos(Math.max(-1, Math.min(1, dot / len)));
if (u.x * v.y - u.y * v.x < 0) ang = -ang;
return ang;
}
const v1 = { x: (x1p - cxp) / rx, y: (y1p - cyp) / ry };
const v2 = { x: (-x1p - cxp) / rx, y: (-y1p - cyp) / ry };
const startAngle = angle({ x: 1, y: 0 }, v1);
let deltaAngle = angle(v1, v2);
if (!sweepFlag && deltaAngle > 0) {
deltaAngle -= 2 * Math.PI;
} else if (sweepFlag && deltaAngle < 0) {
deltaAngle += 2 * Math.PI;
}
const totalAngle = deltaAngle;
// Choose the number of segments so that the chord error is below tolerance.
const rApprox = Math.max(rx, ry);
const segCount = Math.max(
1,
Math.ceil(Math.abs(totalAngle) / (2 * Math.acos(1 - tolerance / rApprox)))
);
const points: Point[] = [];
for (let i = 0; i <= segCount; i++) {
const theta = startAngle + (totalAngle * i) / segCount;
const x =
cx + rx * Math.cos(phiRad) * Math.cos(theta) - ry * Math.sin(phiRad) * Math.sin(theta);
const y =
cy + rx * Math.sin(phiRad) * Math.cos(theta) + ry * Math.cos(phiRad) * Math.sin(theta);
points.push({ x, y });
}
return points;
}
// A simple SVG path command type.
interface SvgCommand {
type: string;
args: number[];
}
/**
* A basic parser for an SVG path string. It splits the string into commands
* (like "M", "L", "C", etc.) and extracts the numeric parameters.
*/
function parseSvgPath(path: string): SvgCommand[] {
const commands: SvgCommand[] = [];
const re = /([MmLlHhVvCcQqAaZz])([^MmLlHhVvCcQqAaZz]*)/g;
let match: RegExpExecArray | null = re.exec(path);
while (match !== null) {
const type = match[1];
const argsStr = match[2].trim();
const args: number[] = [];
if (argsStr.length > 0) {
// Match numbers (including decimals, negatives, exponents)
const numberRe = /-?\d*\.?\d+(?:e[-+]?\d+)?/gi;
let numberMatch: RegExpExecArray | null = numberRe.exec(argsStr);
while (numberMatch !== null) {
args.push(Number.parseFloat(numberMatch[0]));
numberMatch = numberRe.exec(argsStr);
}
}
commands.push({ type, args });
match = re.exec(path);
}
return commands;
}
/**
* Converts an SVG path (a string) into a list of straight-line segments.
*
* Each segment is represented as an object with start point (x1,y1)
* and end point (x2,y2). Curved path segments (cubic, quadratic, arc)
* are approximated with a polyline whose error is below a given tolerance.
*
* @param svgPath - An SVG path string (for example, "M 152.982 124.448 L 176.73 156.849 …")
* @returns An array of line segments.
*/
export function svgPathToSegments(
svgPath: string
): { x1: number; y1: number; x2: number; y2: number }[] {
const segments: { x1: number; y1: number; x2: number; y2: number }[] = [];
let current: Point = { x: 0, y: 0 };
let startPoint: Point = { x: 0, y: 0 };
const tolerance = 0.5; // adjust this value to get a closer or looser approximation
const commands = parseSvgPath(svgPath);
for (const command of commands) {
// Destructure the command type and its numeric arguments.
let type: string = command.type;
const args: number[] = command.args;
let idx = 0;
if (type.toLowerCase() === 'z') {
// Close the current subpath.
if (current.x === startPoint.x && current.y === startPoint.y) {
idx++;
continue;
}
segments.push({ x1: current.x, y1: current.y, x2: startPoint.x, y2: startPoint.y });
current = { ...startPoint };
// "Z" has no arguments so exit the loop.
idx++;
continue; // end of the line
}
// Some commands allow multiple coordinate pairs.
while (idx < args.length || type.toLowerCase() === 'z') {
switch (type) {
case 'M': {
// Absolute moveto.
const x = args[idx++];
const y = args[idx++];
current = { x, y };
startPoint = { x, y };
// If extra pairs follow, treat them as implicit "L" commands.
type = 'L';
break;
}
case 'm': {
// Relative moveto.
const x = current.x + args[idx++];
const y = current.y + args[idx++];
current = { x, y };
startPoint = { x, y };
type = 'l';
break;
}
case 'L': {
// Absolute lineto.
const x = args[idx++];
const y = args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: x, y2: y });
current = { x, y };
break;
}
case 'l': {
// Relative lineto.
const x = current.x + args[idx++];
const y = current.y + args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: x, y2: y });
current = { x, y };
break;
}
case 'H': {
// Absolute horizontal lineto.
const x = args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: x, y2: current.y });
current = { x, y: current.y };
break;
}
case 'h': {
// Relative horizontal lineto.
const x = current.x + args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: x, y2: current.y });
current = { x, y: current.y };
break;
}
case 'V': {
// Absolute vertical lineto.
const y = args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: current.x, y2: y });
current = { x: current.x, y };
break;
}
case 'v': {
// Relative vertical lineto.
const y = current.y + args[idx++];
segments.push({ x1: current.x, y1: current.y, x2: current.x, y2: y });
current = { x: current.x, y };
break;
}
case 'C': {
// Cubic Bezier: parameters are x1, y1, x2, y2, x, y.
const x1 = args[idx++];
const y1 = args[idx++];
const x2 = args[idx++];
const y2 = args[idx++];
const x = args[idx++];
const y = args[idx++];
const curvePoints = approximateCubicBezier(
current,
{ x: x1, y: y1 },
{ x: x2, y: y2 },
{ x, y },
tolerance
);
// Convert the polyline into segments.
for (let i = 0; i < curvePoints.length - 1; i++) {
segments.push({
x1: curvePoints[i].x,
y1: curvePoints[i].y,
x2: curvePoints[i + 1].x,
y2: curvePoints[i + 1].y,
});
}
current = { x, y };
break;
}
case 'c': {
// Relative cubic Bezier.
const x1 = current.x + args[idx++];
const y1 = current.y + args[idx++];
const x2 = current.x + args[idx++];
const y2 = current.y + args[idx++];
const x = current.x + args[idx++];
const y = current.y + args[idx++];
const curvePoints = approximateCubicBezier(
current,
{ x: x1, y: y1 },
{ x: x2, y: y2 },
{ x, y },
tolerance
);
for (let i = 0; i < curvePoints.length - 1; i++) {
segments.push({
x1: curvePoints[i].x,
y1: curvePoints[i].y,
x2: curvePoints[i + 1].x,
y2: curvePoints[i + 1].y,
});
}
current = { x, y };
break;
}
case 'Q': {
// Quadratic Bezier: parameters are x1, y1, x, y.
const x1 = args[idx++];
const y1 = args[idx++];
const x = args[idx++];
const y = args[idx++];
const curvePoints = approximateQuadraticBezier(
current,
{ x: x1, y: y1 },
{ x, y },
tolerance
);
for (let i = 0; i < curvePoints.length - 1; i++) {
segments.push({
x1: curvePoints[i].x,
y1: curvePoints[i].y,
x2: curvePoints[i + 1].x,
y2: curvePoints[i + 1].y,
});
}
current = { x, y };
break;
}
case 'q': {
// Relative quadratic Bezier.
const x1 = current.x + args[idx++];
const y1 = current.y + args[idx++];
const x = current.x + args[idx++];
const y = current.y + args[idx++];
const curvePoints = approximateQuadraticBezier(
current,
{ x: x1, y: y1 },
{ x, y },
tolerance
);
for (let i = 0; i < curvePoints.length - 1; i++) {
segments.push({
x1: curvePoints[i].x,
y1: curvePoints[i].y,
x2: curvePoints[i + 1].x,
y2: curvePoints[i + 1].y,
});
}
current = { x, y };
break;
}
case 'A': {
// Arc: parameters are rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y.
const rx = args[idx++];
const ry = args[idx++];
const xAxisRotation = args[idx++];
const largeArcFlag = !!args[idx++];
const sweepFlag = !!args[idx++];
const x = args[idx++];
const y = args[idx++];
const arcPoints = approximateArc(
current,
rx,
ry,
xAxisRotation,
largeArcFlag,
sweepFlag,
{ x, y },
tolerance
);
for (let i = 0; i < arcPoints.length - 1; i++) {
segments.push({
x1: arcPoints[i].x,
y1: arcPoints[i].y,
x2: arcPoints[i + 1].x,
y2: arcPoints[i + 1].y,
});
}
current = { x, y };
break;
}
case 'a': {
// Relative arc.
const rx = args[idx++];
const ry = args[idx++];
const xAxisRotation = args[idx++];
const largeArcFlag = !!args[idx++];
const sweepFlag = !!args[idx++];
const x = current.x + args[idx++];
const y = current.y + args[idx++];
const arcPoints = approximateArc(
current,
rx,
ry,
xAxisRotation,
largeArcFlag,
sweepFlag,
{ x, y },
tolerance
);
for (let i = 0; i < arcPoints.length - 1; i++) {
segments.push({
x1: arcPoints[i].x,
y1: arcPoints[i].y,
x2: arcPoints[i + 1].x,
y2: arcPoints[i + 1].y,
});
}
current = { x, y };
break;
}
default: {
toast.error(`Unsupported SVG command type: ${type} ${args.join(' ')}`);
console.error(`unsupported SVG command type: ${type} ${args.join(' ')}`);
// Unsupported commands can be skipped.
idx = args.length;
break;
}
}
}
}
return segments;
}
@@ -0,0 +1,3 @@
export function pointDistance(point1: { x: number; y: number }, point2: { x: number; y: number }) {
return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
}
@@ -0,0 +1,214 @@
import {Point} from '@flatten-js/core';
import {CURSOR_SIZE, GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, SNAP_POINT_COLOR, SNAP_POINT_SIZE,} from '../App.consts';
import {type SnapPoint, SnapPointType} from '../App.types';
import type {DrawController} from '../drawControllers/DrawController';
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController';
import type {Entity} from '../entities/Entity';
import {getLayers, isEntityHighlighted, isEntitySelected} from '../state';
import {toast} from 'react-toastify';
export function drawEntities(drawController: DrawController, entities: Entity[]) {
for (const entity of entities) {
const layer = getLayers().find((layer) => layer.id === entity.layerId);
if (!layer) {
toast.error(`Failed to find layer for entity: ${entity?.id}`);
console.error('Failed to find layer for entity: ', entity);
continue;
}
if (!layer?.isVisible) {
continue; // Layer not visible, skip drawing
}
drawController.setLineStyles(
isEntityHighlighted(entity),
isEntitySelected(entity),
entity.lineColor,
entity.lineWidth,
[]
);
entity.draw(drawController);
}
}
export function drawDebugEntities(drawController: DrawController, debugEntities: Entity[]) {
for (const debugEntity of debugEntities) {
drawController.setLineStyles(
isEntityHighlighted(debugEntity),
isEntitySelected(debugEntity),
'#FF5500',
1,
[]
);
debugEntity.draw(drawController);
}
}
/**
* Draw the point to which the mouse will snap when the user clicks to draw the next point
* @param drawController
* @param snapPointInfo
* @param isMarked indicates that the point has been hovered lang enough to draw guides from this point
*/
export function drawSnapPoint(
drawController: ScreenCanvasDrawController,
snapPointInfo: SnapPoint | null,
isMarked: boolean
) {
if (!snapPointInfo) return;
const snapPoint = snapPointInfo.point;
const screenSnapPoint = drawController.worldToTarget(snapPoint);
drawController.setLineStyles(false, false, SNAP_POINT_COLOR, 1, []);
if (isMarked) {
// We will draw a plus sign inside the current snap point to indicate that it is marked
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
}
switch (snapPointInfo.type) {
case SnapPointType.LineEndPoint:
// Endpoint is marked with a square
// top
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2)
);
// right
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
// bottom
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
// left
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
break;
case SnapPointType.LineMidPoint:
// Midpoint is shown with a triangle
drawController.drawLineScreen(
new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2)
);
break;
case SnapPointType.AngleGuide:
// Angle guide is shown with an hourglass
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2)
);
break;
case SnapPointType.Intersection:
// Intersection is shown with a cross
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
break;
case SnapPointType.CircleCenter:
// Circle center is shown with a circle
drawController.drawArcScreen(screenSnapPoint, SNAP_POINT_SIZE / 2, 0, 2 * Math.PI, true);
break;
case SnapPointType.CircleCardinal:
// Circle cardinal is shown with a diamond
drawController.drawLineScreen(
new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y),
new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2),
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y)
);
drawController.drawLineScreen(
new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y),
new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2)
);
break;
}
}
export function drawHelpers(drawController: DrawController, helperEntities: Entity[]) {
for (const entity of helperEntities) {
drawController.setLineStyles(
isEntityHighlighted(entity),
isEntitySelected(entity),
GUIDE_LINE_COLOR,
GUIDE_LINE_WIDTH,
GUIDE_LINE_STYLE
);
entity.draw(drawController);
}
}
export function drawCursor(drawController: ScreenCanvasDrawController) {
drawController.setLineStyles(false, false, '#FFF', 1, []);
const screenMouseLocation = drawController.getScreenMouseLocation();
drawController.drawLineScreen(
new Point(screenMouseLocation.x, screenMouseLocation.y - CURSOR_SIZE),
new Point(screenMouseLocation.x, screenMouseLocation.y + CURSOR_SIZE)
);
drawController.drawLineScreen(
new Point(screenMouseLocation.x - CURSOR_SIZE, screenMouseLocation.y),
new Point(screenMouseLocation.x + CURSOR_SIZE, screenMouseLocation.y)
);
}
@@ -0,0 +1,50 @@
import {
drawCursor,
drawDebugEntities,
drawEntities,
drawHelpers,
drawSnapPoint,
} from './draw-functions';
import { getClosestSnapPoint } from './get-closest-snap-point';
import { isPointEqual } from './is-point-equal';
import { HOVERED_SNAP_POINT_TIME } from '../App.consts';
import { compact } from 'es-toolkit';
import {
getAngleGuideEntities,
getDebugEntities,
getEntities,
getGhostHelperEntities,
getHoveredSnapPoints,
getInputController,
getShouldDrawCursor,
getSnapPoint,
getSnapPointOnAngleGuide,
} from '../state';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
export function draw(drawController: ScreenCanvasDrawController) {
drawController.clear();
drawHelpers(drawController, getAngleGuideEntities());
drawEntities(drawController, getGhostHelperEntities());
drawEntities(drawController, getEntities());
drawDebugEntities(drawController, getDebugEntities());
const { snapPoint: closestSnapPoint } = getClosestSnapPoint(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
drawController.getWorldMouseLocation(),
);
const isMarked =
!!closestSnapPoint &&
getHoveredSnapPoints().some(
hoveredSnapPoint =>
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME &&
isPointEqual(hoveredSnapPoint.snapPoint.point, closestSnapPoint.point),
);
drawSnapPoint(drawController, closestSnapPoint, isMarked);
if (getShouldDrawCursor()) {
drawCursor(drawController);
getInputController().draw(drawController);
}
}
@@ -0,0 +1,50 @@
import {EntityName} from '../entities/Entity.ts';
import type {JsonDrawingFileSerialized} from './import-export-handlers/export-entities-to-json.ts';
export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
entities: [
{
id: 'ef6a4059-b477-4f53-af00-42241efae328',
type: EntityName.Line,
lineColor: '#fff',
lineWidth: 1,
layerId: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50',
shapeData: {
startPoint: {
x: 276.92367603039963,
y: 1172.4901562767805,
},
endPoint: {
x: 524.8487532291128,
y: 1172.4901562767805,
},
},
},
{
id: '8656dec4-00ae-4042-ba67-1f7d0056079b',
type: EntityName.Arc,
lineColor: '#fff',
lineWidth: 1,
layerId: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50',
shapeData: {
center: {
x: 524.8487532291128,
y: 1015.5664802463798,
},
radius: 156.92367603040066,
startAngle: 0,
// endAngle: (2 * Math.PI * 3) / 4,
endAngle: 1.5707963267948966,
counterClockwise: true,
},
},
],
layers: [
{
id: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50',
isLocked: false,
isVisible: true,
name: 'Default',
},
],
};
@@ -0,0 +1,16 @@
import {Point} from "@flatten-js/core";
import {describe, expect, it} from 'vitest';
import {findClosestEntity} from './find-closest-entity';
import {arcAndLineEntitiesMock} from "./find-closest-entity.mocks.ts";
import {getEntitiesAndLayersFromJsonObject,} from './import-export-handlers/import-entities-from-json.ts';
describe('findClosestEntity', () => {
it('should return the arc as the closest entity', async () => {
const mockEntitiesAndLayers = await getEntitiesAndLayersFromJsonObject(arcAndLineEntitiesMock);
const clickPoint = new Point(393, 1108);
const closestEntityInfo = findClosestEntity(clickPoint, mockEntitiesAndLayers.entities);
expect(closestEntityInfo).toBeDefined();
if (!closestEntityInfo) return;
expect(closestEntityInfo.entity).toEqual(mockEntitiesAndLayers.entities.at(-1));
});
});
@@ -0,0 +1,24 @@
import type {Point, Segment} from '@flatten-js/core';
import type {Entity} from '../entities/Entity';
export function findClosestEntity<EntityType = Entity>(
worldPoint: Point,
entities: Entity[]
): { distance: number; segment: Segment; entity: EntityType } {
let closestEntity = null;
let closestDistanceInfo: [number, Segment | null] = [Number.MAX_SAFE_INTEGER, null];
for (const entity1 of entities) {
const distanceInfo = entity1.distanceTo(worldPoint);
if (!distanceInfo) continue;
if (distanceInfo[0] < closestDistanceInfo[0]) {
closestDistanceInfo = distanceInfo;
closestEntity = entity1;
}
}
return {
distance: closestDistanceInfo[0],
segment: closestDistanceInfo[1] as Segment,
entity: closestEntity as EntityType,
};
}
@@ -0,0 +1,44 @@
import type { Arc, Point } from '@flatten-js/core';
import { uniqWith } from 'es-toolkit';
import { isPointEqual } from './is-point-equal';
import type { ArcEntity } from '../entities/ArcEntity';
import { sortPointsOnArc } from './sort-points-on-arc';
/**
* Find the closest points on the arc on both sides of the clicked point
* @param clickedPointOnShape
* @param arc
* @param pointsOnShape
*/
export function findNeighboringPointsOnArc(
clickedPointOnShape: Point,
arc: ArcEntity,
pointsOnShape: Point[],
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortPointsOnArc(
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
(arc.getShape() as Arc).center,
(arc.getShape() as Arc).start,
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnShape, point),
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnArc',
);
}
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
return [
sortedPoints[
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
],
sortedPoints[
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
],
];
}
@@ -0,0 +1,43 @@
import type { Circle, Point } from '@flatten-js/core';
import { uniqWith } from 'es-toolkit';
import { isPointEqual } from './is-point-equal';
import type { CircleEntity } from '../entities/CircleEntity';
import { sortPointsOnCircle } from './sort-points-on-circle';
/**
* Find the closest points on the circle on both sides of the clicked point
* @param clickedPointOnShape
* @param circle
* @param pointsOnShape
*/
export function findNeighboringPointsOnCircle(
clickedPointOnShape: Point,
circle: CircleEntity,
pointsOnShape: Point[],
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortPointsOnCircle(
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
(circle.getShape() as Circle).center,
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnShape, point),
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnCircle',
);
}
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
return [
sortedPoints[
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
],
sortedPoints[
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
],
];
}
@@ -0,0 +1,41 @@
import type { Point } from '@flatten-js/core';
import { sortBy, uniqWith } from 'es-toolkit';
import { isPointEqual } from './is-point-equal';
import { pointDistance } from './distance-between-points';
/**
* Find the closest points on both sides of the clicked point
* @param clickedPointOnLine
* @param lineStartPoint
* @param lineEndPoint
* @param pointsOnLine
*/
export function findNeighboringPointsOnLine(
clickedPointOnLine: Point,
lineStartPoint: Point,
lineEndPoint: Point,
pointsOnLine: Point[],
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortBy(
uniqWith(
[lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint],
isPointEqual,
),
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)],
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnLine, point),
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnLine',
);
}
return [
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
];
}
@@ -0,0 +1,32 @@
import {LineEntity} from '../entities/LineEntity';
import {times} from './times';
import {Point} from '@flatten-js/core';
import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts";
import {getActiveLayerId} from "../state.ts";
export function getAngleGuideLines(
firstPoint: Point,
angleStep: number,
): LineEntity[] {
// Only for 180 degrees since we draw lines that are infinite in both directions,
// so we only need to fill half a circle to fill the complete circle
return times(180 / angleStep, i => {
const angle = i * angleStep;
const angleRad = angle * (Math.PI / 180);
const x = firstPoint.x + Math.cos(angleRad);
const y = firstPoint.y + Math.sin(angleRad);
const angleLine = new LineEntity(getActiveLayerId(),
new Point(
firstPoint.x - 10000 * (x - firstPoint.x),
firstPoint.y - 10000 * (y - firstPoint.y),
),
new Point(
firstPoint.x + 10000 * (x - firstPoint.x),
firstPoint.y + 10000 * (y - firstPoint.y),
),
);
angleLine.lineColor = ANGLE_GUIDES_COLOR;
angleLine.lineDash = ANGLE_GUIDES_DASH;
return angleLine
});
}
@@ -0,0 +1,26 @@
import {Point} from '@flatten-js/core';
import {describe, expect, it} from 'vitest';
import {TO_DEGREES} from '../App.consts.ts';
import {getAngleWithXAxis} from './get-angle-with-x-axis.ts';
describe('getAngleWithXAxis', () => {
it('should return 90 degrees in radians', () => {
const angle = getAngleWithXAxis(new Point(0, 0), new Point(0, 10));
expect(angle * TO_DEGREES).toBeCloseTo(90);
});
it('should return 0 degrees in radians', () => {
const angle = getAngleWithXAxis(new Point(0, 0), new Point(10, 0));
expect(angle * TO_DEGREES).toBeCloseTo(0);
});
it('should return 45 degrees in radians', () => {
const angle = getAngleWithXAxis(new Point(0, 0), new Point(10, 10));
expect(angle * TO_DEGREES).toBeCloseTo(45);
});
it('should return 270 degrees in radians', () => {
const angle = getAngleWithXAxis(new Point(0, 0), new Point(0, -10));
expect(angle * TO_DEGREES).toBeCloseTo(270);
});
});
@@ -0,0 +1,12 @@
import type {Point} from '@flatten-js/core';
export function getAngleWithXAxis(start: Point, end: Point): number {
const dx = end.x - start.x;
const dy = end.y - start.y;
let radians = Math.atan2(dy, dx); // Y difference is the first parameter
if (radians < 0) {
radians += Math.PI * 2;
}
return radians;
}
@@ -0,0 +1,32 @@
import type {Entity} from "../entities/Entity.ts";
export interface BoundingBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export function getBoundingBoxOfMultipleEntities(entities: Entity[]): BoundingBox {
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (const entity of entities) {
const boundingBox = entity.getBoundingBox();
if (boundingBox) {
minX = Math.min(minX, boundingBox.xmin);
minY = Math.min(minY, boundingBox.ymin);
maxX = Math.max(maxX, boundingBox.xmax);
maxY = Math.max(maxY, boundingBox.ymax);
}
}
return {
minX,
minY,
maxX,
maxY,
};
}
@@ -0,0 +1,76 @@
import type {Point} from '@flatten-js/core';
import {type SnapPoint, SnapPointType} from '../App.types';
import {pointDistance} from './distance-between-points';
// /**
// * Some points need to take priority over others when snapping to them. This multiplier is used to give a higher score to the points that should take priority
// */
// const SNAP_POINT_PRIORITY: Record<SnapPointType, number> = {
// [SnapPointType.AngleGuide]: 1,
// [SnapPointType.LineEndPoint]: 5,
// [SnapPointType.Intersection]: 2,
// [SnapPointType.CircleCenter]: 3,
// [SnapPointType.CircleCardinal]: 3,
// [SnapPointType.CircleTangent]: 2,
// [SnapPointType.LineMidPoint]: 4,
// [SnapPointType.Point]: 5,
// };
/**
* Finds the closest snap point to the target point
* @param worldSnapPoints
* @param worldMouseLocation
*/
export function getClosestSnapPoint(
worldSnapPoints: SnapPoint[],
worldMouseLocation: Point
): { distance: number; snapPoint: SnapPoint | null } {
let closestSnapPoint: SnapPoint | null = null;
let closestDistance: number = Number.POSITIVE_INFINITY;
for (const snapPoint1 of worldSnapPoints) {
const distance = pointDistance(snapPoint1.point, worldMouseLocation);
if (distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint1;
}
}
return {
distance: closestDistance,
snapPoint: closestSnapPoint,
};
}
/**
* First checks non angle guide snap points, then checks angle guide snap points
* @param worldSnapPoints
* @param worldMouseLocation
* @param maxDistance
*/
export function getClosestSnapPointWithinRadius(
worldSnapPoints: SnapPoint[],
worldMouseLocation: Point,
maxDistance: number
): SnapPoint | null {
const { distance: closestDistance, snapPoint: closestSnapPoint } = getClosestSnapPoint(
worldSnapPoints.filter((snapPoint) => snapPoint.type !== SnapPointType.AngleGuide),
worldMouseLocation
);
if (closestDistance < maxDistance) {
return closestSnapPoint;
}
const { distance: angleGuideDistance, snapPoint: angleGuideSnapPoint } = getClosestSnapPoint(
worldSnapPoints.filter((snapPoint) => snapPoint.type === SnapPointType.AngleGuide),
worldMouseLocation
);
if (angleGuideDistance < maxDistance) {
return angleGuideSnapPoint;
}
return null;
}
@@ -0,0 +1,91 @@
import type {Point} from '@flatten-js/core';
import {compact} from 'es-toolkit';
import {SNAP_ANGLE_DISTANCE} from '../App.consts';
import {type SnapPoint, SnapPointType} from '../App.types';
import type {Entity} from '../entities/Entity';
import type {LineEntity} from '../entities/LineEntity';
import {findClosestEntity} from './find-closest-entity';
import {getAngleGuideLines} from './get-angle-guide-lines';
import {getClosestSnapPointWithinRadius} from './get-closest-snap-point';
import {getIntersectionPoints} from './get-intersection-points';
/**
* Gets the angle guides from the angle point to the mouse if the mouse is close to one of the angle steps and also returns the closest snap point
* @param entities entities that are drawn on the canvas
* @param anglePoints the points that should get angle guides
* @param worldMouseLocation the current mouse location
* @param angleStep the angle in degrees at which the angle guides should be drawn
* @param maxSnapDistance The distance that the mouse can snap to a snap point or angle guide
*/
export function getDrawHelpers(
entities: Entity[],
anglePoints: Point[],
worldMouseLocation: Point,
angleStep: number,
maxSnapDistance: number
): {
angleGuides: LineEntity[];
entitySnapPoint: SnapPoint | null;
angleSnapPoint: SnapPoint | null;
} {
let entitySnapPoint: SnapPoint | null = null;
let angleSnapPoint: SnapPoint | null = null;
const nearestAngleSnapPoints: SnapPoint[] = [];
const angleGuides: LineEntity[] = [];
// draw angle guide
for (const anglePoint of anglePoints) {
const angleGuideLines = getAngleGuideLines(anglePoint, angleStep);
const closestLineInfo = findClosestEntity<LineEntity>(worldMouseLocation, angleGuideLines);
if (closestLineInfo.distance < SNAP_ANGLE_DISTANCE) {
angleGuides.push(closestLineInfo.entity);
nearestAngleSnapPoints.push({
point: closestLineInfo.segment.start,
type: SnapPointType.AngleGuide,
});
}
}
// Calculate snap points
const entitySnapPoints = [
...entities.flatMap((entity) => {
return entity.getSnapPoints();
}),
...getIntersectionPoints(compact(entities)).map((point) => ({
point,
type: SnapPointType.Intersection,
})),
];
const closestSnapPoint = getClosestSnapPointWithinRadius(
entitySnapPoints,
worldMouseLocation,
maxSnapDistance
);
if (closestSnapPoint) {
entitySnapPoint = closestSnapPoint;
}
const angleSnapPoints = [
...nearestAngleSnapPoints,
// TODO only search for intersections between angle guides and other angle guides and between angle guides and entities, but not between entities
...getIntersectionPoints([...compact(entities), ...angleGuides]).map((point) => ({
point,
type: SnapPointType.Intersection,
})),
];
const closestAngleSnapPoint = getClosestSnapPointWithinRadius(
angleSnapPoints,
worldMouseLocation,
maxSnapDistance
);
if (closestAngleSnapPoint) {
angleSnapPoint = closestAngleSnapPoint;
}
return { angleGuides, entitySnapPoint, angleSnapPoint };
}
@@ -0,0 +1,14 @@
/**
* If the color is white return black since the canvas background is black, it makes sense to invert the color for white
* @param color
*/
export function getExportColor(color: string): string {
if (
color.toLowerCase() === 'white' ||
color.toLowerCase() === '#fff' ||
color.toLowerCase() === '#ffffff'
) {
return '#000';
}
return color;
}
@@ -0,0 +1,22 @@
import type { Entity } from '../entities/Entity';
import type { Point } from '@flatten-js/core';
// TODO in the future we could optimize this by only calculating intersection points near the mouse
export function getIntersectionPoints(entities: Entity[]): Point[] {
const intersectionPoints: Point[] = [];
// Calculate all intersections between all entities
for (let i = 0; i < entities.length; i++) {
const entity1 = entities[i];
for (let j = i; j < entities.length; j++) {
// intersections are symmetric, so we only need to calculate them in one direction (let j = i)
if (i === j) continue; // Do not check for intersections with yourself
const entity2 = entities[j];
intersectionPoints.push(...entity1.getIntersections(entity2));
}
}
return intersectionPoints;
}
@@ -0,0 +1,11 @@
import type {Layer} from '../App.types.ts';
import {getLayers} from '../state.ts';
export function getNewLayer(): Layer {
return {
id: crypto.randomUUID(),
isLocked: false,
isVisible: true,
name: `New layer ${getLayers().length}${1}`,
};
}
@@ -0,0 +1,49 @@
import {type Point, Vector} from '@flatten-js/core';
import {
type AbsolutePointInputEvent,
ActorEvent,
type DrawEvent,
type MouseClickEvent,
type NumberInputEvent,
type PointInputEvent,
type RelativePointInputEvent,
} from '../tools/tool.types.ts';
/**
* Various tools need to convert user input into a point
* This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events
* @param startPoint
* @param event
*/
export function getPointFromEvent(startPoint: Point | null, event: PointInputEvent): Point {
if (event.type === ActorEvent.DRAW) {
return (event as DrawEvent).drawController.getWorldMouseLocation();
}
if (event.type === ActorEvent.MOUSE_CLICK) {
return (event as MouseClickEvent).worldMouseLocation;
}
if (event.type === ActorEvent.NUMBER_INPUT) {
if (!startPoint) {
throw new Error('Cannot get relative point by distance if no start point is provided');
}
const distance = (event as NumberInputEvent).value;
// Direction indicated by the startPoint and the mouse location
const direction = new Vector(
event.worldMouseLocation.x - startPoint.x,
event.worldMouseLocation.y - startPoint.y
);
const unitDirection = direction.normalize();
return startPoint.translate(unitDirection.multiply(distance));
}
if (event.type === ActorEvent.ABSOLUTE_POINT_INPUT) {
return (event as AbsolutePointInputEvent).value;
}
if (event.type === ActorEvent.RELATIVE_POINT_INPUT) {
if (!startPoint) {
throw new Error('Cannot get relative point by coordinates if no start point is provided');
}
const relativeCoordinates = (event as RelativePointInputEvent).value;
return startPoint.clone().translate(relativeCoordinates.x, relativeCoordinates.y);
}
throw new Error('Received unexpected event type in DRAW_FINAL_LINE of LineEntity');
}
@@ -0,0 +1,6 @@
import type { Point } from '@flatten-js/core';
export interface PointWithAngle {
point: Point;
angle: number;
}
@@ -0,0 +1,33 @@
import {compact} from 'es-toolkit';
import {saveAs} from 'file-saver';
import type {Layer} from '../../App.types.ts';
import type {Entity, JsonEntity} from '../../entities/Entity';
import {getEntities, getLayers} from '../../state';
export async function exportEntitiesToJsonFile() {
const json = await exportEntitiesAndLayersToJsonString();
const blob = new Blob([json], { type: 'text/json;charset=utf-8' });
saveAs(blob, 'open-web-cad--drawing.json');
}
export async function exportEntitiesAndLayersToJsonString() {
const entities = getEntities();
const jsonEntities = entities.map((entity) => entity.toJson());
const jsonDrawingFile: JsonDrawingFileSerialized = {
entities: compact(await Promise.all(jsonEntities)), // TODO use a mapLimit to avoid overloading the event loop
layers: getLayers(),
};
return JSON.stringify(jsonDrawingFile, null, 2);
}
export interface JsonDrawingFileSerialized {
entities: JsonEntity[];
layers: Layer[];
}
export interface JsonDrawingFileDeserialized {
entities: Entity[];
layers: Layer[];
}
@@ -0,0 +1,8 @@
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts';
export async function exportEntitiesToLocalStorage() {
const json = await exportEntitiesAndLayersToJsonString();
localStorage.setItem(LOCAL_STORAGE_KEY.DRAWING, json);
}
@@ -0,0 +1,68 @@
import { saveAs } from 'file-saver';
import { convertEntitiesToSvgString } from './export-entities-to-svg';
import { getEntities } from '../../state';
/**
* Takes an svg string and converts it to a png data uri
* by creating an svg element in the dom and drawing that element on the canvas
* Then taking the canvas data and outputting it as a png data blob
* @param svgLines
* @param width
* @param height
* @param margin
*/
export function convertSvgToPngBlob(
svgLines: string[],
width: number,
height: number,
margin: number,
): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
const img = new Image();
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
const url = URL.createObjectURL(svg);
img.onload = () => {
canvas.width = width + margin * 2;
canvas.height = height + margin * 2;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, margin, margin);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Could not convert canvas to blob'));
}
}, 'image/png');
};
img.src = url;
});
}
export async function exportEntitiesToPngFile() {
const entities = getEntities();
const svg = convertEntitiesToSvgString(entities);
const pngDataBlob: Blob = await convertSvgToPngBlob(
svg.svgLines,
svg.width,
svg.height,
20,
);
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
}
@@ -0,0 +1,36 @@
import {saveAs} from 'file-saver';
import {SVG_MARGIN} from '../../App.consts';
import {SvgDrawController} from '../../drawControllers/svg.drawController.ts';
import type {Entity} from '../../entities/Entity';
import {getEntities} from '../../state';
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
export function convertEntitiesToSvgString(entities: Entity[]): {
svgLines: string[];
width: number;
height: number;
} {
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
const svgDrawController = new SvgDrawController(
boundingBox.minX - SVG_MARGIN,
boundingBox.minY - SVG_MARGIN,
boundingBox.maxX + SVG_MARGIN,
boundingBox.maxY + SVG_MARGIN
);
for (const entity of entities) {
entity.draw(svgDrawController);
}
return svgDrawController.export();
}
export function exportEntitiesToSvgFile() {
const entities = getEntities();
const svg = convertEntitiesToSvgString(entities);
const blob = new Blob(svg.svgLines, { type: 'text/svg;charset=utf-8' });
saveAs(blob, 'open-web-cad--drawing.svg');
}
@@ -0,0 +1,92 @@
import {compact} from 'es-toolkit';
import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity';
import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity';
import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity';
import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts';
import {LineEntity, type LineJsonData} from '../../entities/LineEntity';
import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts';
import {PointEntity, type PointJsonData} from '../../entities/PointEntity';
import {PolyLineEntity, type PolyLineJsonData} from '../../entities/PolyLineEntity.ts';
import {RectangleEntity, type RectangleJsonData} from '../../entities/RectangleEntity';
import {TextEntity, type TextJsonData} from '../../entities/TextEntity.ts';
import {setActiveLayerId, setEntities, setLayers} from '../../state';
import {getNewLayer} from '../get-new-layer.ts';
import type {JsonDrawingFileDeserialized, JsonDrawingFileSerialized,} from './export-entities-to-json';
/**
* Open a file selection dialog to select *.json files
* Parse the JSON file
* Generate entities from the JSON data
* Set the entities in the state
*/
export function importEntitiesFromJsonFile(file: File | null | undefined) {
return new Promise<void>((resolve) => {
if (!file) return;
const reader = new FileReader();
reader.addEventListener('load', async () => {
const json = reader.result as string;
const file = await getEntitiesAndLayersFromJsonString(json);
setEntities(file.entities);
setLayers(file.layers);
setActiveLayerId(file.layers[0].id);
resolve();
});
reader.readAsText(file, 'utf-8');
});
}
export async function getEntitiesAndLayersFromJsonObject(
data: JsonDrawingFileSerialized
): Promise<JsonDrawingFileDeserialized> {
const entityPromises: Promise<Entity | null>[] = compact(
data.entities.map((entity) => {
switch (entity.type) {
case EntityName.Arc:
return ArcEntity.fromJson(entity as JsonEntity<ArcJsonData>);
case EntityName.Circle:
return CircleEntity.fromJson(entity as JsonEntity<CircleJsonData>);
case EntityName.Line:
return LineEntity.fromJson(entity as JsonEntity<LineJsonData>);
case EntityName.Point:
return PointEntity.fromJson(entity as JsonEntity<PointJsonData>);
case EntityName.Rectangle:
return RectangleEntity.fromJson(entity as JsonEntity<RectangleJsonData>);
case EntityName.Text:
return TextEntity.fromJson(entity as JsonEntity<TextJsonData>);
case EntityName.Measurement:
return MeasurementEntity.fromJson(entity as JsonEntity<MeasurementJsonData>);
case EntityName.Image:
return ImageEntity.fromJson(entity as JsonEntity<ImageJsonData>);
case EntityName.PolyLine:
return PolyLineEntity.fromJson(entity as JsonEntity<PolyLineJsonData>);
default:
throw new Error(`Invalid entity type: ${entity.type}`);
}
})
);
const entities = compact(await Promise.all(entityPromises));
let layers = data.layers;
if (data.layers.length === 0) {
layers = [getNewLayer()];
}
return {
entities,
layers,
};
}
export async function getEntitiesAndLayersFromJsonString(
json: string
): Promise<JsonDrawingFileDeserialized> {
const data = JSON.parse(json) as JsonDrawingFileSerialized;
if (!data.entities) {
throw new Error('Invalid JSON file');
}
// TODO use map limit to avoid overloading the event loop
return await getEntitiesAndLayersFromJsonObject(data);
}
@@ -0,0 +1,25 @@
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
import {setActiveLayerId, setEntities, setLayers} from '../../state.ts';
import {getNewLayer} from '../get-new-layer.ts';
import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts';
import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts";
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
const file = await getEntitiesAndLayersFromLocalStorage();
setEntities(file.entities);
setLayers(file.layers);
setActiveLayerId(file.layers[0].id);
}
export async function getEntitiesAndLayersFromLocalStorage(): Promise<JsonDrawingFileDeserialized> {
const json = localStorage.getItem(LOCAL_STORAGE_KEY.DRAWING);
if (!json) {
return {
entities: [],
layers: [getNewLayer()],
};
}
const file = (await getEntitiesAndLayersFromJsonString(json)) || [];
return file;
}
@@ -0,0 +1,149 @@
import {toast} from 'react-toastify';
import {CircleEntity} from '../../entities/CircleEntity';
import type {Entity} from '../../entities/Entity';
import {LineEntity} from '../../entities/LineEntity';
import {RectangleEntity} from '../../entities/RectangleEntity';
import {getActiveLayerId, getEntities, setEntities} from '../../state';
import {Point} from '@flatten-js/core';
import {type Node, parse, type RootNode} from 'svg-parser';
import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts';
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
import {middle} from '../middle.ts';
function svgChildrenToEntities(root: RootNode): Entity[] {
if (!root.children || !root.children?.[0]) {
toast.error('Failed to load SVG file since it appears to be empty');
console.error(new Error('Empty SVG file'));
return [];
}
const entities: Entity[] = [];
const childrenToProcess: (string | Node)[] = [root.children[0]];
while (childrenToProcess[0]) {
const child = childrenToProcess[0];
if (typeof child === 'string') {
continue; // Don't import text // TODO convert this text to a TextEntity
}
if (child.type === 'element') {
if (child.tagName === 'rect') {
const corner = new Point(
Number.parseFloat(String(child.properties?.x)),
Number.parseFloat(String(child.properties?.y))
);
entities.push(
new RectangleEntity(
getActiveLayerId(),
corner,
new Point(
corner.x + Number.parseFloat(String(child.properties?.width)),
corner.y + Number.parseFloat(String(child.properties?.height))
)
)
);
}
if (child.tagName === 'ellipse') {
if (child.properties?.rx === child.properties?.ry) {
entities.push(
new CircleEntity(
getActiveLayerId(),
new Point(
Number.parseFloat(String(child.properties?.cx)),
Number.parseFloat(String(child.properties?.cy))
),
Number.parseFloat(String(child.properties?.rx))
)
);
} else {
// TODO convert ellipse to line segments
}
}
if (child.tagName === 'path' && typeof child.properties?.d === 'string') {
const lines = svgPathToSegments(child.properties.d);
for (const line of lines) {
entities.push(
new LineEntity(
getActiveLayerId(),
new Point(line.x1, line.y1),
new Point(line.x2, line.y2)
)
);
}
}
if (child.tagName === 'polygon' && typeof child.properties?.points === 'string') {
const coords: number[] = child.properties.points
.split(' ')
.map((coord) => Number.parseFloat(coord));
for (let i = 0; i <= coords.length; i = i + 2) {
if (i + 4 <= coords.length) {
// still enough points, keep going
const startPoint = new Point(coords[i], coords[i + 1]);
const endPoint = new Point(coords[i + 2], coords[i + 3]);
entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint));
} else if (i + 2 === coords.length) {
// last point, add line back to the start
const startPoint = new Point(coords[i], coords[i + 1]);
const endPoint = new Point(coords[0], coords[1]);
entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint));
} else {
// stop
toast.error(
`Error processing SVG polygon: expected an even number of points, but got ${coords.length}`
);
console.error(`expected even number of points but got: ${coords.length}`);
}
}
}
childrenToProcess.push(...(child.children || []));
}
childrenToProcess.shift();
}
return entities;
}
/**
* Open a file selection dialog to select *.svg files
* Parse the SVG file
* Generate entities from the XML data
* Set the entities in the state
*/
export function importEntitiesFromSvgFile(file: File | null | undefined) {
return new Promise<void>((resolve) => {
if (!file) return;
const reader = new FileReader();
reader.addEventListener('load', async () => {
try {
const svg = reader.result as string;
const data = parse(svg);
const svgEntities: Entity[] = svgChildrenToEntities(data);
// We still need to flip the image top to bottom since the coordinate system of svg has a y-axis that goes down
// And the world coordinate system of this application has a mathematical y-axis that goes up
const boundingBox = getBoundingBoxOfMultipleEntities(svgEntities);
const centerPoint = new Point(
middle(boundingBox.minX, boundingBox.maxX),
middle(boundingBox.minY, boundingBox.maxY)
);
const mirrorAxis = new LineEntity(
getActiveLayerId(),
centerPoint,
new Point(centerPoint.x + 1, centerPoint.y)
);
for (const svgEntity of svgEntities) {
svgEntity.mirror(mirrorAxis);
}
setEntities([...getEntities(), ...svgEntities]);
resolve();
} catch (error) {
toast.error('Failed to load SVG file');
console.error(error);
}
});
reader.readAsText(file, 'utf-8');
});
}
@@ -0,0 +1,12 @@
export interface SvgParseResult {
type: string
children: Children[]
}
export interface Children {
type: string
tagName: string
properties: Record<string, string>
children: Children[]
metadata?: string
}
@@ -0,0 +1,18 @@
/**
* Open a file selection dialog to select *.jpg, *.jpeg, *.png files
* Load the image data
* Convert it to a base64 string
*/
export function importImageFromFile(
file: File | null | undefined,
): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>(resolve => {
if (!file) return;
const img = new Image();
img.onload = () => {
resolve(img);
};
img.src = URL.createObjectURL(file);
});
}
@@ -0,0 +1,87 @@
import {Point} from '@flatten-js/core';
import {describe, expect, it} from 'vitest';
import type {StartAndEndpointEntity} from '../App.types.ts';
import {isClosedPolygon} from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity
// Mock implementation for StartAndEndpointEntity
class MockEntity implements StartAndEndpointEntity {
constructor(
private start: Point,
private end: Point
) {}
getStartPoint(): Point {
return this.start;
}
getEndPoint(): Point {
return this.end;
}
}
describe('PolygonChecker.isClosedPolygon', () => {
it('should return true for a simple triangle', () => {
const A = new Point(0, 0);
const B = new Point(1, 0);
const C = new Point(0, 1);
const entities = [new MockEntity(A, B), new MockEntity(B, C), new MockEntity(C, A)];
expect(isClosedPolygon(entities)).toBe(true);
});
it('should return true for a square with mixed ordering and reversed segments', () => {
const P1 = new Point(0, 0);
const P2 = new Point(1, 0);
const P3 = new Point(1, 1);
const P4 = new Point(0, 1);
const entities = [
new MockEntity(P2, P3),
new MockEntity(P4, P1),
new MockEntity(P3, P4),
new MockEntity(P1, P2),
];
expect(isClosedPolygon(entities)).toBe(true);
});
it('should return false for an open chain of segments', () => {
const A = new Point(0, 0);
const B = new Point(1, 0);
const C = new Point(2, 0);
const entities = [new MockEntity(A, B), new MockEntity(B, C)];
expect(isClosedPolygon(entities)).toBe(false);
});
it('should return false when there is a zero-length segment', () => {
const A = new Point(0, 0);
const entities = [new MockEntity(A, A)];
expect(isClosedPolygon(entities)).toBe(false);
});
it('should return false when three segments share the same point', () => {
const A = new Point(0, 0);
const B = new Point(1, 0);
const C = new Point(0, 1);
const D = new Point(-1, 0);
const entities = [new MockEntity(A, B), new MockEntity(A, C), new MockEntity(A, D)];
expect(isClosedPolygon(entities)).toBe(false);
});
it('should return false for two disjoint loops', () => {
const A = new Point(0, 0);
const B = new Point(1, 0);
const C = new Point(0, 1);
const D = new Point(2, 2);
const E = new Point(3, 2);
const F = new Point(2, 3);
const entities = [
// First triangle
new MockEntity(A, B),
new MockEntity(B, C),
new MockEntity(C, A),
// Second triangle
new MockEntity(D, E),
new MockEntity(E, F),
new MockEntity(F, D),
];
expect(isClosedPolygon(entities)).toBe(false);
});
});
@@ -0,0 +1,84 @@
import type {Point} from "@flatten-js/core";
import type {StartAndEndpointEntity} from "../App.types.ts";
import {isPointEqual} from "./is-point-equal.ts";
/**
* Check if entities form a closed loop polygon
*/
export function isClosedPolygon(entities: StartAndEndpointEntity[]): boolean {
const uniquePoints: Point[] = [];
const counts: number[] = [];
const edges: Array<[number, number]> = [];
// Helper to find or add a point to uniquePoints, returning its index
const findOrAdd = (pt: Point): number => {
for (let i = 0; i < uniquePoints.length; i++) {
if (isPointEqual(uniquePoints[i], pt)) {
return i;
}
}
uniquePoints.push(pt);
counts.push(0);
return uniquePoints.length - 1;
};
// 1) Process each segment
for (const entity of entities) {
const start = entity.getStartPoint();
const end = entity.getEndPoint();
// 1a) no zerolength segments
if (isPointEqual(start, end)) {
return false;
}
const si = findOrAdd(start);
const ei = findOrAdd(end);
counts[si]++;
counts[ei]++;
edges.push([si, ei]);
}
const N = entities.length;
// 2) must have exactly N unique points
if (uniquePoints.length !== N) {
return false;
}
// 3) each point must appear exactly twice
if (counts.some((c) => c !== 2)) {
return false;
}
// 4) build undirected adjacency
const adj: number[][] = Array.from({ length: N }, () => []);
for (const [u, v] of edges) {
adj[u].push(v);
adj[v].push(u);
}
// 5) each vertex must have degree 2
if (adj.some((neigh) => neigh.length !== 2)) {
return false;
}
// 6) connectivity: traverse from 0
const visited = new Set<number>();
const stack = [0];
while (stack.length) {
const u = stack.pop();
if (typeof u === 'undefined') {
break;
}
if (!visited.has(u)) {
visited.add(u);
for (const v of adj[u]) {
if (!visited.has(v)) stack.push(v);
}
}
}
return visited.size === N;
}
@@ -0,0 +1,5 @@
import { EPSILON } from '../App.consts';
export function isLengthEqual(length1: number, length2: number): boolean {
return Math.abs(length1 - length2) < EPSILON;
}
@@ -0,0 +1,9 @@
import type { Point } from '@flatten-js/core';
import { EPSILON } from '../App.consts';
export function isPointEqual(point1: Point, point2: Point): boolean {
return (
Math.abs(point1.x - point2.x) < EPSILON &&
Math.abs(point1.y - point2.y) < EPSILON
);
}
@@ -0,0 +1,9 @@
import type {KeyboardEvent} from "react";
export function keyboardHandler(clickHandler: () => void) {
return (evt: KeyboardEvent) => {
if (evt.key === 'Enter' || evt.key === 'Space') {
clickHandler();
}
};
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { mapNumberRange } from './map-number-range';
describe('mapNumberRange', () => {
it('should map a value from the source range to the target range (normal range)', () => {
expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50);
expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0);
expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100);
});
it('should map values outside the source range', () => {
expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range
expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range
});
it('should handle inverted source ranges', () => {
// Source range is 10 to 0, mapping 5 should be halfway
// Target range is 100 to 0, so halfway is 50
expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50);
// Outside inverted range
expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150);
expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50);
});
it('should handle inverted target ranges', () => {
// Normal source range, but inverted target
expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50);
expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100);
expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0);
});
it('should handle zero-length source range', () => {
// If the source range is a single point
expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range
expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range
});
it('should handle negative numbers and other ranges', () => {
expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50);
// Here: num = -10, source = [-20,0], target = [0,100]
// Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50
});
it('should handle floating point values', () => {
expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input
expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check
});
it('should handle large ranges', () => {
expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000);
});
it('should handle screen coordinates to world correctly', () => {
expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900);
});
it('should handle world coordinates to screen correctly', () => {
expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100);
});
});
@@ -0,0 +1,22 @@
/**
* Convert numbers in a specific range to another range
* This is moslty used to convert screen space coordinates to world space coordinates and vice versa
*/
export function mapNumberRange(
num: number,
startSourceRange: number,
endSourceRange: number,
startTargetRange: number,
endTargetRange: number,
): number {
// Handle the case where source range has zero length
if (startSourceRange === endSourceRange) {
return startTargetRange;
}
return (
startTargetRange +
((num - startSourceRange) * (endTargetRange - startTargetRange)) /
(endSourceRange - startSourceRange)
);
}
@@ -0,0 +1,3 @@
export function middle(numMin: number, numMax: number) {
return numMin + (numMax - numMin) / 2;
}
@@ -0,0 +1,6 @@
import type {LineEntity} from "../entities/LineEntity.ts";
export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) {
const mirrorAngle = mirrorAxis.getAngle();
return mirrorAngle * 2 - angle;
}
@@ -0,0 +1,49 @@
import {describe, expect, it} from "vitest";
import {Point} from "@flatten-js/core";
import {mirrorPointOverAxis} from './mirror-point-over-axis';
import {LineEntity} from "../entities/LineEntity.ts";
import {getActiveLayerId} from "../state.ts";
describe("mirrorPointOverAxis", () => {
it('should mirror if the axis is horizontal', () => {
const point = new Point(100, 100);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50));
const mirroredPoint = mirrorPointOverAxis(point, axis);
expect(mirroredPoint.x).toBe(100);
expect(mirroredPoint.y).toBe(0);
});
it("mirrors a point over a vertical axis", () => {
const point = new Point(3, 4);
const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0
const mirrored = mirrorPointOverAxis(point, axis);
expect(mirrored.x).toBeCloseTo(-3);
expect(mirrored.y).toBeCloseTo(4);
});
it("mirrors a point over the diagonal line y = x", () => {
const point = new Point(3, 4);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x
const mirrored = mirrorPointOverAxis(point, axis);
// The mirror of (3,4) over y=x is (4,3)
expect(mirrored.x).toBeCloseTo(4);
expect(mirrored.y).toBeCloseTo(3);
});
it("returns the same point if the point lies on the mirror axis", () => {
const point = new Point(1, 1);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line
const mirrored = mirrorPointOverAxis(point, axis);
expect(mirrored.x).toBeCloseTo(1);
expect(mirrored.y).toBeCloseTo(1);
});
it("returns the original point when mirrored twice", () => {
const point = new Point(5, 7);
const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis
const mirrored = mirrorPointOverAxis(point, axis);
const doubleMirrored = mirrorPointOverAxis(mirrored, axis);
expect(doubleMirrored.x).toBeCloseTo(point.x);
expect(doubleMirrored.y).toBeCloseTo(point.y);
});
});
@@ -0,0 +1,22 @@
import { Point, type Segment } from '@flatten-js/core';
import type {LineEntity} from "../entities/LineEntity.ts";
export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) {
const mirrorAxisSegment = mirrorAxis.getShape() as Segment;
const A = mirrorAxisSegment.start;
const B = mirrorAxisSegment.end;
// Compute the vector components for the mirror axis
const dx = B.x - A.x;
const dy = B.y - A.y;
// Compute the projection factor t
const t = ((point.x - A.x) * dx + (point.y - A.y) * dy) / (dx * dx + dy * dy);
// Compute the projection of the point onto the line
const projX = A.x + t * dx;
const projY = A.y + t * dy;
// Reflect the point: new point = 2 * projection - original point
return new Point(2 * projX - point.x, 2 * projY - point.y);
}
@@ -0,0 +1,3 @@
export function normaliseAngleRadians(angle: number): number {
return (angle + 2 * Math.PI) % (2 * Math.PI);
}
@@ -0,0 +1,3 @@
import { Point } from '@flatten-js/core';
export const A4Format = new Point(210, 297);
@@ -0,0 +1,9 @@
import type {Polygon, Segment} from '@flatten-js/core';
export function polygonToSegments(polygon: Polygon): Segment[] {
const segments: Segment[] = [];
for (const edge of polygon.edges) {
segments.push(edge.shape);
}
return segments;
}

Some files were not shown because too many files have changed in this diff Show More