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

This commit is contained in:
2026-08-29 16:17:51 +09:00
parent 5393ea4720
commit 068eac7f6a
95 changed files with 8136 additions and 1 deletions
@@ -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
@@ -225,7 +225,7 @@ export const Toolbar: FC = () => {
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B08 </span>
<span>B07 </span>
</div>
<div className="cad-file-state">
<span className="cad-file-state__dot" />
@@ -0,0 +1,213 @@
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,
lineDash: this.lineDash,
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;
rectangleEntity.lineDash = jsonEntity.lineDash;
return rectangleEntity;
}
}
export interface RectangleJsonData {
points: { x: number; y: number }[];
}
@@ -0,0 +1,217 @@
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 getTextOptions(): TextOptions {
return this.options;
}
public setTextOptions(newOptions: Partial<Omit<TextOptions, 'textDirection'>>): void {
Object.assign(this.options, newOptions);
}
public getLabel(): string {
return this.label;
}
/** 테이블 값 셀 등 기존 텍스트 내용을 직접 수정한다 (aislo 더블클릭 편집용). */
public setLabel(newLabel: string): void {
this.label = newLabel;
}
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,
lineDash: this.lineDash,
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;
textEntity.lineDash = jsonEntity.lineDash ?? [];
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,56 @@
import {
getAngleGuideOriginPoint,
getAngleStep,
getHoveredSnapPoints,
getLayerById,
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 { queryEntitiesNearPoint } from './spatial-index.ts';
import { compact } from 'es-toolkit';
/**
* Calculate angle guides and snap points
*/
export function calculateAngleGuidesAndSnapPoints() {
const angleStep = getAngleStep();
const screenCanvasDrawController = getScreenCanvasDrawController();
const screenScale = screenCanvasDrawController.getScreenScale();
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
// 잠금 레이어(b08-frame 등 참조용)는 스냅 대상에서 제외한다.
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
const entities = queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2,
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
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,
maxSnapDistance,
);
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 {getLayerById, isEntityHighlighted, isEntitySelected} from '../state';
import {toast} from 'react-toastify';
export function drawEntities(drawController: DrawController, entities: Entity[]) {
for (const entity of entities) {
const layer = getLayerById(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,69 @@
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,
getHighlightedEntityIds,
getHoveredSnapPoints,
getInputController,
getShouldDrawCursor,
getSnapPoint,
getSnapPointOnAngleGuide,
} from '../state';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import { drawScene } from './scene-cache';
/**
* Hover highlight is excluded from the static scene cache (it changes every
* mouse move) — re-draw the few highlighted entities on top of the blit.
*/
function drawHighlightedEntities(drawController: ScreenCanvasDrawController) {
const highlightedIds = getHighlightedEntityIds();
if (!highlightedIds.length) return;
const idSet = new Set(highlightedIds);
drawEntities(
drawController,
getEntities().filter(entity => idSet.has(entity.id)),
);
}
export function draw(drawController: ScreenCanvasDrawController) {
drawController.clear();
// Static scene (all entities): cached bitmap blit, rebuilt only when needed.
drawScene(drawController, performance.now());
drawHighlightedEntities(drawController);
drawHelpers(drawController, getAngleGuideEntities());
drawEntities(drawController, getGhostHelperEntities());
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,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;
}
@@ -0,0 +1,3 @@
export function toHex(red: number, green: number, blue: number, alpha: number): string {
return (blue | (green << 8) | (red << 16) | (1 << 24)).toString(16).slice(1) + alpha;
}
@@ -0,0 +1,11 @@
import { Point, Vector } from '@flatten-js/core';
export function rotatePoint(
point: Point,
rotateOrigin: Point,
angle: number,
): Point {
const vector = new Vector(rotateOrigin, point);
const rotatedVector = vector.rotate(angle);
return new Point(rotatedVector.x, rotatedVector.y);
}
@@ -0,0 +1,11 @@
import { Point, Vector } from '@flatten-js/core';
export function scalePoint(
point: Point,
scaleOrigin: Point,
scaleFactor: number,
): Point {
const vector = new Vector(scaleOrigin, point);
const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1);
return new Point(point.x + scaledVector.x, point.y + scaledVector.y);
}
@@ -0,0 +1,143 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import {
getGridEnabled,
getHighlightedEntityIds,
setHighlightedEntityIds,
} from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
/**
* Static scene cache: all entities are rendered once into an offscreen canvas.
* While panning, the cached bitmap is blitted at a pixel offset instead of
* re-stroking every entity each frame. The cache is rebuilt when the scene
* version bumps (entities/layers/selection changed), when zoom or canvas size
* changes, or after the pan offset has settled for PAN_SETTLE_MS.
*
* Highlight is intentionally NOT baked into the cache (it changes on every
* mouse move) draw.ts re-draws highlighted entities on top each frame.
*/
const PAN_SETTLE_MS = 120;
interface RenderedParams {
version: number;
scale: number;
offsetX: number;
offsetY: number;
sizeX: number;
sizeY: number;
}
let offscreenCanvas: HTMLCanvasElement | null = null;
let rendered: RenderedParams | null = null;
let prevOffsetX = Number.NaN;
let prevOffsetY = Number.NaN;
let lastOffsetChangeAt = 0;
export const scenePerf = {
sceneRebuilds: 0,
lastRebuildMs: 0,
avgFrameMs: 0,
};
(window as unknown as Record<string, unknown>).__aisloCadPerf = scenePerf;
export function invalidateSceneCache(): void {
rendered = null;
}
/**
* Viewport culling: only entities whose bbox intersects the current view
* (expanded by one viewport on each side, so short pans stay covered by the
* blit before the settle-rebuild) are rendered into the scene cache.
*/
function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) {
const size = drawController.getCanvasSize();
const scale = drawController.getScreenScale();
const offset = drawController.getScreenOffset();
const viewWidth = size.x / scale;
const viewHeight = size.y / scale;
return queryEntitiesInBox(
offset.x - viewWidth,
offset.y - viewHeight,
offset.x + 2 * viewWidth,
offset.y + 2 * viewHeight
);
}
function rebuildScene(drawController: ScreenCanvasDrawController): void {
const size = drawController.getCanvasSize();
if (!offscreenCanvas) {
offscreenCanvas = document.createElement('canvas');
}
if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) {
offscreenCanvas.width = Math.max(1, size.x);
offscreenCanvas.height = Math.max(1, size.y);
}
const offscreenContext = offscreenCanvas.getContext('2d');
if (!offscreenContext) return;
const startedAt = performance.now();
// Exclude the (rapidly changing) hover highlight from the baked bitmap.
const savedHighlight = getHighlightedEntityIds();
if (savedHighlight.length) setHighlightedEntityIds([]);
drawController.withContext(offscreenContext, () => {
drawController.clear();
// Style-run batching + sub-pixel decimation: one stroke per style run
drawController.beginBatch();
drawEntities(drawController, sceneEntitiesForViewport(drawController));
drawController.endBatch();
});
if (savedHighlight.length) setHighlightedEntityIds(savedHighlight);
scenePerf.lastRebuildMs = performance.now() - startedAt;
scenePerf.sceneRebuilds++;
const offset = drawController.getScreenOffset();
rendered = {
version: getSceneVersion(),
scale: drawController.getScreenScale(),
offsetX: offset.x,
offsetY: offset.y,
sizeX: size.x,
sizeY: size.y,
};
}
/**
* Draw the static scene: rebuild the cache when needed, otherwise blit the
* cached bitmap (shifted by the pan delta). Called once per frame by draw().
*/
export function drawScene(drawController: ScreenCanvasDrawController, now: number): void {
const size = drawController.getCanvasSize();
const scale = drawController.getScreenScale();
const offset = drawController.getScreenOffset();
if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) {
lastOffsetChangeAt = now;
prevOffsetX = offset.x;
prevOffsetY = offset.y;
}
const paramsChanged =
!rendered ||
rendered.version !== getSceneVersion() ||
rendered.scale !== scale ||
rendered.sizeX !== size.x ||
rendered.sizeY !== size.y;
const offsetChanged =
!!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y);
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
// bitmap would drag the grid along — always re-render while grid is on.
if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
rebuildScene(drawController);
}
if (!offscreenCanvas || !rendered) return;
// screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards:
// content shifts left when offset.x grows, down when offset.y grows.
const dx = (rendered.offsetX - offset.x) * scale;
const dy = (offset.y - rendered.offsetY) * scale;
drawController.blitImage(offscreenCanvas, dx, dy);
}
@@ -0,0 +1,13 @@
/**
* Scene version counter incremented whenever content that is baked into the
* cached static scene bitmap changes (entities, layers, selection, grid).
* Kept dependency-free so both state.ts and scene-cache.ts can import it
* without a cycle.
*/
let sceneVersion = 0;
export const bumpSceneVersion = (): void => {
sceneVersion++;
};
export const getSceneVersion = (): number => sceneVersion;
@@ -0,0 +1,33 @@
import { Line, type Point } from '@flatten-js/core';
import { sortBy } from 'es-toolkit';
import type { PointWithAngle } from './helpers.types';
import { ArcEntity } from '../entities/ArcEntity';
/**
* Sorts points that lie on an arc by angle around the arc, from start point to end point.
* @param pointsOnArc
* @param centerPoint
* @param startPoint
*/
export function sortPointsOnArc(
pointsOnArc: Point[],
centerPoint: Point,
startPoint: Point,
): Point[] {
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
// Angles calculated from start point (0 degrees) and up
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => {
return {
point,
// Ensure all angles are between 0 (start point) and < 2PI,
// so we can sort them starting at the start point angle
angle:
(new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) %
(2 * Math.PI),
};
});
return sortBy(pointsWithAngles, [
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
]).map(pointsWithAngle => pointsWithAngle.point);
}
@@ -0,0 +1,23 @@
import { Line, type Point } from '@flatten-js/core';
import { sortBy } from 'es-toolkit';
import type { PointWithAngle } from './helpers.types';
/**
* Sorts points that lie on a circle by angle around the circle, angle from 0 => 360
* @param pointsOnCircle
* @param centerPoint
*/
export function sortPointsOnCircle(
pointsOnCircle: Point[],
centerPoint: Point,
): Point[] {
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => {
return {
point,
angle: new Line(centerPoint, point).slope,
};
});
return sortBy(pointsWithAngles, [
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
]).map(pointsWithAngle => pointsWithAngle.point);
}
@@ -0,0 +1,140 @@
import type { Entity } from '../entities/Entity';
import { getEntities } from '../state';
import { getSceneVersion } from './scene-version';
/**
* Uniform-grid spatial index over top-level entity bounding boxes.
* Rebuilt lazily whenever the scene version changes (entity edits bump it).
* Queries return entities in original array order so z-order is preserved.
*/
const GRID_CELLS_PER_AXIS = 64;
interface IndexedEntity {
entity: Entity;
minX: number;
minY: number;
maxX: number;
maxY: number;
}
let indexVersion = -1;
let indexedEntities: IndexedEntity[] = [];
let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results
let cells: Map<number, number[]> = new Map();
let cellSize = 1;
let gridMinX = 0;
let gridMinY = 0;
let gridCols = 1;
function cellRange(min: number, max: number, gridMin: number): [number, number] {
return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)];
}
function ensureIndex(): void {
const version = getSceneVersion();
if (version === indexVersion) return;
indexedEntities = [];
unindexedEntities = [];
cells = new Map();
const entities = getEntities();
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const entity of entities) {
let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null;
try {
box = entity.getBoundingBox();
} catch {
box = null;
}
if (
!box ||
!Number.isFinite(box.xmin) ||
!Number.isFinite(box.ymin) ||
!Number.isFinite(box.xmax) ||
!Number.isFinite(box.ymax)
) {
unindexedEntities.push(entity);
continue;
}
indexedEntities.push({
entity,
minX: box.xmin,
minY: box.ymin,
maxX: box.xmax,
maxY: box.ymax,
});
if (box.xmin < minX) minX = box.xmin;
if (box.ymin < minY) minY = box.ymin;
if (box.xmax > maxX) maxX = box.xmax;
if (box.ymax > maxY) maxY = box.ymax;
}
if (indexedEntities.length) {
const extent = Math.max(maxX - minX, maxY - minY, 1e-9);
cellSize = extent / GRID_CELLS_PER_AXIS;
gridMinX = minX;
gridMinY = minY;
gridCols = GRID_CELLS_PER_AXIS + 2;
indexedEntities.forEach((item, index) => {
const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX);
const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const key = cy * gridCols + cx;
const bucket = cells.get(key);
if (bucket) bucket.push(index);
else cells.set(key, [index]);
}
}
});
}
indexVersion = version;
}
/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */
export function queryEntitiesInBox(
minX: number,
minY: number,
maxX: number,
maxY: number
): Entity[] {
ensureIndex();
if (!indexedEntities.length) return [...unindexedEntities];
const seen = new Set<number>();
const [cx0, cx1] = cellRange(minX, maxX, gridMinX);
const [cy0, cy1] = cellRange(minY, maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const bucket = cells.get(cy * gridCols + cx);
if (!bucket) continue;
for (const index of bucket) seen.add(index);
}
}
const result: Entity[] = [];
let unindexedCursor = 0;
for (let index = 0; index < indexedEntities.length; index++) {
if (!seen.has(index)) continue;
const item = indexedEntities[index];
if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue;
result.push(item.entity);
}
// bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음)
for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) {
result.push(unindexedEntities[unindexedCursor]);
}
return result;
}
/** 점 주변 반경 후보 엔티티 (스냅·호버용). */
export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] {
return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius);
}
@@ -0,0 +1,12 @@
export function times<T>(
num: number,
iterateeFunc: (i: number) => T = (i: number) => i as T,
): T[] {
let i = 0;
const items = [];
while (i < num) {
items.push(iterateeFunc(i));
i++;
}
return items;
}
@@ -0,0 +1,77 @@
import type { HoverPoint, SnapPoint } from '../App.types';
import { pointDistance } from './distance-between-points';
import { HOVERED_SNAP_POINT_TIME, MAX_MARKED_SNAP_POINTS } from '../App.consts';
/**
* Checks the current snap point every 100ms to mark certain snap points when they are hovered for a certain amount of time (marked)
* So we can show extra angle guides for the ones that are marked
*/
export function trackHoveredSnapPoint(
worldSnapPoint: SnapPoint | null,
worldHoveredSnapPoints: HoverPoint[],
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
maxHoverDistance: number,
elapsedTime: number,
) {
if (!worldSnapPoint) {
return;
}
const lastHoveredPoint = worldHoveredSnapPoints.at(-1);
let newHoverSnapPoints: HoverPoint[];
// Angle guide points should never be marked
if (lastHoveredPoint) {
if (
pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) <
maxHoverDistance
) {
// Last hovered snap point is still the current closest snap point
// Increase the hover time
newHoverSnapPoints = [
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
{
...lastHoveredPoint,
milliSecondsHovered:
lastHoveredPoint.milliSecondsHovered + elapsedTime,
},
];
} else {
// The closest snap point has changed
// Check if the last snap point was hovered for long enough to be considered a marked snap point
if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) {
// Append the new point to the list
newHoverSnapPoints = [
...worldHoveredSnapPoints,
{
snapPoint: worldSnapPoint,
milliSecondsHovered: elapsedTime,
},
];
} else {
// Replace the last point with the new point
newHoverSnapPoints = [
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
{
snapPoint: worldSnapPoint,
milliSecondsHovered: elapsedTime,
},
];
}
}
} else {
// No snap points were hovered before
newHoverSnapPoints = [
{
snapPoint: worldSnapPoint,
milliSecondsHovered: elapsedTime,
},
];
}
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(
0,
MAX_MARKED_SNAP_POINTS,
);
setHoveredSnapPoints(newHoverSnapPointsTruncated);
}
@@ -0,0 +1,84 @@
export interface UndoState {
variable: StateVariable;
// biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types
value: any;
}
export enum StateVariable {
canvasSize = 'canvasSize',
canvas = 'canvas',
context = 'context',
screenMouseLocation = 'screenMouseLocation',
activeTool = 'activeTool',
entities = 'entities',
activeEntity = 'activeEntity',
shouldDrawCursor = 'shouldDrawCursor',
helperEntities = 'helperEntities',
debugEntities = 'debugEntities',
angleStep = 'angleStep',
screenOffset = 'screenOffset',
screenZoom = 'screenZoom',
panStartLocation = 'panStartLocation',
snapPoint = 'snapPoint',
snapPointOnAngleGuide = 'snapPointOnAngleGuide',
hoveredSnapPoints = 'hoveredSnapPoints',
lastDrawTimestamp = 'lastDrawTimestamp',
activeLineColor = 'activeLineColor',
activeLineWidth = 'activeLineWidth',
activeLineDash = 'activeLineDash',
activeTextStyle = 'activeTextStyle',
designMeta = 'designMeta',
layers = 'layers',
}
/**
* Based on https://github.com/wobsoriano/undo-stacker
*/
export function createStack() {
let stack: UndoState[] = [];
let index = stack.length;
function peek() {
return stack[index - 1];
}
return {
push: (value: UndoState) => {
stack.length = index;
stack[index++] = value;
// console.log('stack push', JSON.stringify(stack, null, 2));
return peek();
},
replace: (value: UndoState) => {
stack[index - 1] = value;
// console.log('stack replace', JSON.stringify(stack, null, 2));
return peek();
},
peek: () => {
return peek();
},
undo: () => {
if (index > 1) index -= 1;
// console.log('stack undo', JSON.stringify(stack, null, 2));
return peek();
},
redo: () => {
if (index < stack.length) index += 1;
// console.log('stack redo', JSON.stringify(stack, null, 2));
return peek();
},
// Clear certain states from the undo stack
clear: (variable: StateVariable) => {
// Update the index be reducing it by the number of states that are removed to the left of the index
index = index - stack.slice(0, index).filter((state) => state.variable === variable).length;
stack = stack.filter((state) => state.variable !== variable);
},
};
}
@@ -0,0 +1,11 @@
// Wraps the index so that if you go past the length it resets to 0
// ["one", "two", "three"]
// index maps like this
// 0 => 0
// 1 => 1
// 2 => 2
// 3 => 0
// 4 => 1
export function wrapModule(index: number, length: number) {
return (index + length) % length;
}
@@ -0,0 +1,528 @@
import { Point } from '@flatten-js/core';
import { compact, round } from 'es-toolkit';
import { Actor } from 'xstate';
import {
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
CANVAS_INPUT_FIELD_HEIGHT,
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
CANVAS_INPUT_FIELD_TEXT_COLOR,
CANVAS_INPUT_FIELD_WIDTH,
HIGHLIGHT_ENTITY_DISTANCE,
SNAP_POINT_DISTANCE,
} from '../App.consts.ts';
import { MouseButton } from '../App.types.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import {
getActiveToolActor,
getCanvas,
getEntities,
getLastStateInstructions,
getPanStartLocation,
getSnapEnabled,
getScreenCanvasDrawController,
getSelectedEntities,
getSnapPoint,
getSnapPointOnAngleGuide,
redo,
setActiveToolActor,
setGhostHelperEntities,
setHighlightedEntityIds,
setPanStartLocation,
setSelectedEntityIds,
setShouldDrawCursor,
undo,
} from '../state.ts';
import { Tool } from '../tools.ts';
import { TOOL_STATE_MACHINES } from '../tools/tool.consts.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
type MouseClickEvent,
type NumberInputEvent,
type RelativePointInputEvent,
type TextInputEvent,
} from '../tools/tool.types.ts';
const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
const ABSOLUTE_POINT_REGEXP = /^([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/;
const RELATIVE_POINT_REGEXP = /^@([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/;
export class InputController {
private text = '';
constructor() {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
return; // used during unit testing
}
// Listen for keystrokes
document.addEventListener('keydown', (evt) => {
this.handleKeyStroke(evt);
});
// Listen for right mouse button click => perform the same action as ENTER
const canvas = getCanvas();
canvas?.addEventListener('mousedown', (evt: MouseEvent) => this.handleMouseDown(evt));
canvas?.addEventListener('mousemove', (evt: MouseEvent) => this.handleMouseMove(evt));
canvas?.addEventListener('mouseup', (evt: MouseEvent) => this.handleMouseUp(evt));
canvas?.addEventListener('wheel', (evt: WheelEvent) => this.handleMouseWheel(evt));
canvas?.addEventListener('mouseout', () => this.handleMouseOut());
canvas?.addEventListener('mouseenter', () => this.handleMouseEnter());
// Stop the context menu from appearing when right-clicking
canvas?.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
});
}
public draw(drawController: ScreenCanvasDrawController) {
const screenMouseLocation = drawController.getScreenMouseLocation();
// draw input field
drawController.fillRectScreen(
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET,
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET,
CANVAS_INPUT_FIELD_WIDTH,
CANVAS_INPUT_FIELD_HEIGHT,
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
);
// Draw text in input field
if (this.text) {
drawController.drawTextScreen(
this.text,
new Point(
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
),
{
textAlign: 'left',
textColor: CANVAS_INPUT_FIELD_TEXT_COLOR,
fontSize: 18,
}
);
}
const matchingToolNames = this.getToolNamesFromPrefixText();
const toolInstruction = getLastStateInstructions();
const texts: string[] = [];
if (toolInstruction) {
// Draw tool instruction
texts.push(toolInstruction);
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
texts.push(`${roundedX},${roundedY}`);
}
if (matchingToolNames.length) {
// Draw list of matching tools. eg: C => CIRCLE, COPY, ...
texts.push(...matchingToolNames);
}
this.drawListBelowInputField(drawController, texts);
}
public submitText(value: string) {
this.text = value.trim().toUpperCase();
this.handleEnterKey();
}
public handleMouseUp(evt: MouseEvent) {
if (evt.button === MouseButton.Right) {
// Right click => confirm action (ENTER)
evt.preventDefault();
evt.stopPropagation();
this.handleEnterKey();
}
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
const controlsParent = (evt?.target as HTMLElement)?.closest('.controls');
if (controlsParent) {
return;
}
if (evt.button === MouseButton.Middle) {
setPanStartLocation(null);
}
if (evt.button === MouseButton.Left) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
const activeToolActor = getActiveToolActor();
activeToolActor?.send({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
} as MouseClickEvent);
}
}
public handleMouseEnter() {
setShouldDrawCursor(true);
}
public handleMouseMove(evt: MouseEvent) {
setShouldDrawCursor(true);
const screenCanvasDrawController = getScreenCanvasDrawController();
const newScreenMouseLocation = this.getCanvasPoint(evt);
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
// If the middle mouse button is pressed, pan the screen
const panStartLocation = getPanStartLocation();
if (panStartLocation) {
screenCanvasDrawController.panScreen(
newScreenMouseLocation.x - panStartLocation.x,
newScreenMouseLocation.y - panStartLocation.y
);
setPanStartLocation(newScreenMouseLocation);
}
// Calculate angle guides and snap points
if (getSnapEnabled()) {
calculateAngleGuidesAndSnapPoints();
}
// Highlight the entity closest to the mouse when the select tool is active
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
const closestEntityInfo = findClosestEntity(
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
getEntities()
);
if (closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) {
setHighlightedEntityIds([closestEntityInfo.entity.id]);
} else {
setHighlightedEntityIds([]);
}
}
}
public handleMouseOut() {
setShouldDrawCursor(false);
}
/**
* Change the zoom level of screen space
* @param evt
*/
public handleMouseWheel(evt: WheelEvent) {
if (Math.abs(evt.deltaY) === 0) {
return; // We can't zoom by zero delta
}
const drawController = getScreenCanvasDrawController();
// Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing
// zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02).
drawController.zoomScreen(-evt.deltaY);
}
public handleMouseDown(evt: MouseEvent) {
if (evt.button !== MouseButton.Middle) return;
setPanStartLocation(this.getCanvasPoint(evt));
}
private getCanvasPoint(evt: MouseEvent): Point {
const bounds = getCanvas()?.getBoundingClientRect();
return new Point(
evt.clientX - (bounds?.left ?? 0),
(bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY
);
}
/**
* Returns a distance to pan the screen when a directional arrow is pressed
* Offset is based on shift key being pressed (larger offset)
* and zoom level
* @private
*/
private getScreenPanStep(
direction: 'up' | 'right' | 'down' | 'left',
shiftPressed: boolean
): Point {
const screenOffset = getScreenCanvasDrawController().getScreenOffset();
const screenZoom = getScreenCanvasDrawController().getScreenScale();
let step = 20;
if (shiftPressed) {
step = 100;
}
step *= screenZoom;
switch (direction) {
case 'up':
return new Point(screenOffset.x, screenOffset.y - step);
case 'right':
return new Point(screenOffset.x - step, screenOffset.y);
case 'down':
return new Point(screenOffset.x, screenOffset.y + step);
case 'left':
return new Point(screenOffset.x + step, screenOffset.y);
}
}
public handleKeyStroke(evt: KeyboardEvent) {
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
return;
}
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
return;
}
if (evt.key === 'F5') {
// F5 => reload the page
return;
}
if (evt.key === 'F11') {
// F11 => toggle fullscreen
return;
}
if (evt.key === 'Tab') {
// Tab => move keyboard focus
return;
}
evt.preventDefault();
evt.stopPropagation();
if (evt.ctrlKey && evt.key === 'v') {
// User wants to paste the clipboard
} else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
// User wants to undo the last action
this.handleUndo(evt);
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 'y') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 'a') {
// User wants to select everything
setSelectedEntityIds(getEntities().map((entity) => entity.id));
} else if (evt.key === 'Backspace') {
// Remove the last character from the input field
evt.preventDefault();
this.text = this.text.slice(0, this.text.length - 1);
} else if (evt.key === 'Delete') {
// User wants to delete the current selection
evt.preventDefault();
getActiveToolActor()?.send({
type: ActorEvent.DELETE,
});
} else if (evt.key === 'Escape') {
// User wants to cancel the current action
this.handleEscapeKey();
} else if (evt.key === 'Enter') {
// User wants to submit the input or submit the action
this.handleEnterKey();
} else if (evt.key === 'ArrowDown') {
// Move the screen down
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
} else if (evt.key === 'ArrowUp') {
// Move the screen up
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('up', evt.shiftKey));
} else if (evt.key === 'ArrowLeft') {
// Move the screen left
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('left', evt.shiftKey));
} else if (evt.key === 'ArrowRight') {
// Move the screen right
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey));
} else if (evt.key === '+') {
// Zoom in
// TODO keep the center of the screen centered during zoom
getScreenCanvasDrawController().setScreenScale(
getScreenCanvasDrawController().getScreenScale() * 1.1
);
} else if (evt.key === '-') {
// Zoom in
// TODO keep the center of the screen centered during zoom
getScreenCanvasDrawController().setScreenScale(
getScreenCanvasDrawController().getScreenScale() * 0.9
);
} else if (evt.key?.length === 1) {
// User entered a single character => add to input field text
this.text += evt.key.toUpperCase();
}
}
public handleEscapeKey() {
if (getSelectedEntities().length > 0) {
// Deselect entities
setSelectedEntityIds([]);
} else if (this.text === '') {
// Cancel tool action
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
} else {
// clear the input field
this.text = '';
}
}
private getToolNamesFromPrefixText(): Tool[] {
if (this.text === '') {
return [];
}
return (Object.keys(TOOL_STATE_MACHINES).filter((cmd) =>
cmd.startsWith(this.text.toUpperCase())
) || null) as Tool[];
}
public handleEnterKey() {
// submit the text as input to the active tool and clear the input field
const activeTool = getActiveToolActor();
const activeToolSnapshot = activeTool?.getSnapshot();
const activeToolState = activeToolSnapshot?.value;
const activeToolCanHandleTextInput =
!!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT;
if (this.text === '') {
console.log('ENTER: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// Send the ENTER event to the active tool
getActiveToolActor()?.send({
type: ActorEvent.ENTER,
});
} else if (activeToolCanHandleTextInput) {
console.log('TEXT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// Send the text to the active tool
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
this.text = '';
} else if (this.getToolNamesFromPrefixText()[0]) {
// User entered a command. eg: L or LINE
const toolName = this.getToolNamesFromPrefixText()[0];
getActiveToolActor()?.stop();
const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]);
setActiveToolActor(newToolActor);
console.log('SWITCH TO TOOL: ', {
toolName,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
activeTool: (getActiveToolActor()?.src as any).config.context.type,
});
this.text = '';
} else if (NUMBER_REGEXP.test(this.text)) {
console.log(' NUMBER_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered a number. eg: 100
getActiveToolActor()?.send({
type: ActorEvent.NUMBER_INPUT,
value: Number.parseFloat(this.text),
worldMouseLocation:
getSnapPointOnAngleGuide()?.point ||
getSnapPoint()?.point ||
getScreenCanvasDrawController().getWorldMouseLocation(),
} as NumberInputEvent);
this.text = '';
} else if (ABSOLUTE_POINT_REGEXP.test(this.text)) {
console.log('ABSOLUTE_POINT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered coordinates to an absolute point on the canvas. eg: 100, 200
const match = ABSOLUTE_POINT_REGEXP.exec(this.text);
if (!match) {
return;
}
const x = Number.parseFloat(match[1]);
const y = Number.parseFloat(match[3]);
getActiveToolActor()?.send({
type: ActorEvent.ABSOLUTE_POINT_INPUT,
value: new Point(x, y),
} as AbsolutePointInputEvent);
this.text = '';
} else if (RELATIVE_POINT_REGEXP.test(this.text)) {
console.log('RELATIVE_POINT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// User entered coordinates to a relative point on the canvas. eg: @100, 200
const match = RELATIVE_POINT_REGEXP.exec(this.text);
if (!match) {
return;
}
const x = Number.parseFloat(match[1]);
const y = Number.parseFloat(match[3]);
getActiveToolActor()?.send({
type: ActorEvent.RELATIVE_POINT_INPUT,
value: new Point(x, y),
} as RelativePointInputEvent);
this.text = '';
} else {
console.log('TEXT_INPUT: ', {
text: this.text,
activeTool: getActiveToolActor(),
});
// Send the text to the active tool
getActiveToolActor()?.send({
type: ActorEvent.TEXT_INPUT,
value: this.text,
} as TextInputEvent);
this.text = '';
}
}
public handleUndo(evt: KeyboardEvent) {
evt.preventDefault();
undo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
}
public handleRedo(evt: KeyboardEvent) {
evt.preventDefault();
redo();
setGhostHelperEntities([]);
setSelectedEntityIds([]);
getActiveToolActor()?.send({
type: ActorEvent.ESC,
});
}
private drawListBelowInputField(
drawController: ScreenCanvasDrawController,
texts: string[]
): void {
const screenMouseLocation = drawController.worldToTarget(
drawController.getWorldMouseLocation()
);
const startY =
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2;
const offsetY = CANVAS_INPUT_FIELD_HEIGHT;
texts.forEach((text, index) => {
drawController.drawTextScreen(
text,
new Point(
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
startY - index * offsetY
),
{
textAlign: 'left',
textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
fontSize: 18,
}
);
});
}
}
@@ -0,0 +1,141 @@
import { Point } from '@flatten-js/core';
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
import { TextEntity } from '../entities/TextEntity.ts';
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
import {
getCanvas,
getDesignMeta,
getEntities,
getLayers,
getScreenCanvasDrawController,
setActiveLayerId,
setDesignMeta,
setEntities,
setLayers,
} from '../state.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b08:drawing-loaded';
export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b08:drawing-error';
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed';
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
drawing: JsonDrawingFileSerialized;
meta?: DesignMeta | null;
}
interface DrawingSaveRequestMessage {
type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE;
}
function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<DrawingLoadMessage>;
return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing);
}
function notifyParent(type: string, payload: Record<string, unknown> = {}) {
if (window.parent === window) return;
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */
export function notifyDrawingChangedByTable() {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
}
/**
* ( ) .
* CAD ( · ) .
*/
function registerTextDoubleClickEdit() {
const canvas = getCanvas();
if (!canvas) return;
canvas.addEventListener('dblclick', (event: MouseEvent) => {
const drawController = getScreenCanvasDrawController();
const bounds = canvas.getBoundingClientRect();
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
const worldPoint = drawController.targetToWorld(screenPoint);
const lockedLayerIds = new Set(
getLayers()
.filter((layer) => layer.isLocked)
.map((layer) => layer.id)
);
let closest: TextEntity | null = null;
let closestDistance = Number.MAX_SAFE_INTEGER;
for (const entity of getEntities()) {
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
const distanceInfo = entity.distanceTo(worldPoint);
if (distanceInfo && distanceInfo[0] < closestDistance) {
closestDistance = distanceInfo[0];
closest = entity;
}
}
if (!closest) return;
const fontSize = closest.getTextOptions().fontSize;
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
if (closestDistance > tolerance) return;
const nextLabel = window.prompt('값 수정', closest.getLabel());
if (nextLabel === null || nextLabel === closest.getLabel()) return;
closest.setLabel(nextLabel);
setEntities([...getEntities()], true); // undo 스택 + DRAWING_CHANGED 통지
});
}
/**
* B08 parent page와 CAD same-origin JSON .
* DXF/DWG .
*/
export function registerAisloDrawingBridge() {
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
const message = event.data as Partial<DrawingSaveRequestMessage>;
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
try {
const drawing = JSON.parse(
await exportEntitiesAndLayersToJsonString()
) as JsonDrawingFileSerialized;
// 편집된 수량표 값을 도면과 함께 부모로 돌려준다.
const quantityTable = getDesignMeta()?.quantityTable ?? null;
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
}
return;
}
if (!isDrawingLoadMessage(event.data)) return;
try {
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
setEntities(drawing.entities, false);
setLayers(drawing.layers);
setActiveLayerId(drawing.layers[0].id);
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
const detail = error instanceof Error ? error.message : 'Invalid drawing JSON';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
}
});
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
});
registerTextDoubleClickEdit();
notifyParent(AISLO_DRAWING_READY_MESSAGE);
}
@@ -0,0 +1,18 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
/**
* AlignBottom tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are bottom aligned
*/
export const alignBottomToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin);
})
);
@@ -0,0 +1,22 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import {middle} from "../helpers/middle.ts";
/**
* AlignCenterHorizontal tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are center horizontal aligned
*/
export const alignCenterHorizontalToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
const entityBoundingBox = entity.getBoundingBox();
const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX);
const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax);
entity.move(centerBoundingBoxX - centerEntityX, 0);
})
);
@@ -0,0 +1,18 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
/**
* AlignLeft tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are left aligned
*/
export const alignLeftToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0);
})
);
@@ -0,0 +1,22 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import {middle} from "../helpers/middle.ts";
/**
* AlignCenterVertical tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are center vertical aligned
*/
export const alignCenterVerticalToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
const entityBoundingBox = entity.getBoundingBox();
const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY);
const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax);
entity.move(0, centerBoundingBoxY - centerEntityY);
})
);
@@ -0,0 +1,18 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
/**
* AlignRight tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are right aligned
*/
export const alignRightToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0);
})
);
@@ -0,0 +1,144 @@
import {assign, type MachineContext, sendTo} from 'xstate';
import type {Entity} from '../entities/Entity.ts';
import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts';
import {
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state.ts';
import type {Tool} from '../tools.ts';
import {selectToolStateMachine} from './select-tool.ts';
import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts';
export interface AlignContext extends ToolContext {}
export enum AlignState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
}
export enum AlignAction {
INIT_ALIGN_TOOL = 'INIT_ALIGN_TOOL',
ALIGN_SELECTION = 'ALIGN_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
}
export function GET_ALIGN_TOOL_STATE(type: Tool): MachineContext {
return {
types: {} as {
context: AlignContext;
events: StateEvent;
},
context: {
type,
},
initial: AlignState.INIT,
states: {
[AlignState.INIT]: {
description: 'Initializing the align tool',
always: {
actions: AlignAction.INIT_ALIGN_TOOL,
target: AlignState.CHECK_SELECTION,
},
},
[AlignState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to align',
meta: {
instructions: 'Select what you want to align, then ENTER',
},
invoke: {
id: 'selectToolInsideTheAlignTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {};
}),
target: AlignState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo(
'selectToolInsideTheAlignTool',
({ event }: { event: MouseClickEvent }) => {
return event;
}
),
},
ESC: {
actions: [AlignAction.DESELECT_ENTITIES, AlignAction.INIT_ALIGN_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo(
'selectToolInsideTheAlignTool',
({ event }: { event: KeyboardEnterEvent }) => {
return event;
}
),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheAlignTool', ({ event }: { event: DrawEvent }) => {
return event;
}),
},
},
},
[AlignState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
actions: AlignAction.ALIGN_SELECTION,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: AlignState.WAITING_FOR_SELECTION,
},
],
},
},
};
}
export function GET_ALIGN_ACTION(alignEntity: (entity: Entity, boundingBox: BoundingBox) => void) {
return {
actions: {
[AlignAction.INIT_ALIGN_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {};
}),
[AlignAction.ALIGN_SELECTION]: () => {
const selectedEntities = getSelectedEntities();
if (!selectedEntities?.length) {
throw new Error('[ALIGN_LEFT] Calling align without entities selected');
}
// Align the entities
const boundingBox = getBoundingBoxOfMultipleEntities(selectedEntities);
for (const entity of selectedEntities) {
alignEntity(entity, boundingBox);
}
},
[AlignAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {};
}),
...selectToolStateMachine.implementations.actions,
},
};
}
@@ -0,0 +1,18 @@
import {Tool} from '../tools';
import {createMachine} from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
/**
* AlignTop tool state machine
* This state machine is responsible for aligning entities on the canvas
* It uses the select tool state machine to select entities to align
* When the user presses enter, the selected entities are top aligned
*/
export const alignTopToolStateMachine = createMachine(
GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY));
})
);
@@ -0,0 +1,368 @@
import {type Point, Vector} from '@flatten-js/core';
import {assign, createMachine, sendTo} from 'xstate';
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS} from '../App.consts.ts';
import type {Entity} from '../entities/Entity';
import {LineEntity} from "../entities/LineEntity.ts";
import {getPointFromEvent} from "../helpers/get-point-from-event.ts";
import {
addEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {CopyAction} from './copy-tool.ts';
import {selectToolStateMachine} from './select-tool.ts';
import type {
AbsolutePointInputEvent,
DrawEvent,
MouseClickEvent,
NumberInputEvent,
StateEvent,
TextInputEvent,
ToolContext,
} from './tool.types';
export enum ArrayState {
INIT = 'INIT',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
CHECK_SELECTION = 'CHECK_SELECTION',
ASK_COPY_MODE = 'ASK_COPY_MODE',
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
EXECUTE_COPY = 'EXECUTE_COPY',
}
export enum ArrayAction {
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
PERFORM_COPY = 'PERFORM_COPY',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
}
export interface ArrayContext extends ToolContext {
copyMode: CopyMode | null;
startDistanceVector: Point | null;
endDistanceVector: Point | null;
pivotPoint: Point | null;
angleStep: number | null;
numberOfCopies: number;
}
enum CopyMode {
LINEAR = 'LINEAR',
RADIAL = 'RADIAL',
}
const initialArrayContext: ArrayContext = {
copyMode: null,
startDistanceVector: null,
endDistanceVector: null,
pivotPoint: null,
angleStep: null,
numberOfCopies: 0,
type: Tool.ARRAY,
};
export const arrayToolStateMachine = createMachine(
{
types: {} as {
context: ArrayContext;
events: StateEvent;
},
context: initialArrayContext,
initial: ArrayState.INIT,
states: {
[ArrayState.INIT]: {
description: 'Initializing the array copy tool',
always: {
actions: ArrayState.INIT,
target: ArrayState.CHECK_SELECTION,
},
},
[ArrayState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return initialArrayContext;
}),
target: ArrayState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
},
},
[ArrayState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: ArrayState.ASK_COPY_MODE,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: ArrayState.WAITING_FOR_SELECTION,
},
],
},
[ArrayState.ASK_COPY_MODE]: {
meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' },
on: {
TEXT_INPUT: {
actions: assign(({ event }) => {
return {
copyMode:
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
};
}),
target: ArrayState.ASK_NUMBER_OF_COPIES,
},
ENTER: {
actions: assign(() => ({
copyMode: CopyMode.LINEAR,
})),
target: ArrayState.ASK_NUMBER_OF_COPIES,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.ASK_NUMBER_OF_COPIES]: {
meta: { instructions: 'Enter number of copies' },
on: {
NUMBER_INPUT: [
{
guard: ({ context }) => {
return context.copyMode === CopyMode.LINEAR;
},
actions: assign(({ event }) => ({
numberOfCopies: (event as NumberInputEvent).value,
})),
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
},
{
guard: ({ context }) => {
return context.copyMode === CopyMode.RADIAL;
},
actions: assign(({ event }) => ({
numberOfCopies: (event as NumberInputEvent).value,
})),
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
},
],
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
meta: { instructions: 'Click to define the start of the distance vector' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
startDistanceVector: (event as AbsolutePointInputEvent).value,
})),
target: ArrayState.EXECUTE_COPY,
},
MOUSE_CLICK: {
actions: assign(({ event }) => ({
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
})),
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
meta: { instructions: 'Click to define the end of the distance vector' },
on: {
RELATIVE_POINT_INPUT: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
MOUSE_CLICK: {
actions: assign(({ event, context }) => ({
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
})),
target: ArrayState.EXECUTE_COPY,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
DRAW: {
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
},
},
},
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
meta: { instructions: 'Click to set pivot point' },
on: {
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => ({
pivotPoint: (event as AbsolutePointInputEvent).value,
})),
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
},
MOUSE_CLICK: {
actions: assign(({ event }) => ({
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
})),
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
},
ESC: {
actions: ArrayAction.INIT_ARRAY_TOOL,
},
},
},
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
meta: { instructions: 'Enter angle per step (in degrees)' },
on: {
NUMBER_INPUT: {
target: ArrayState.EXECUTE_COPY,
actions: assign(({ event }) => ({
angleStep: (event as NumberInputEvent).value,
})),
},
},
},
[ArrayState.EXECUTE_COPY]: {
entry: ArrayAction.PERFORM_COPY,
always: ArrayState.INIT,
},
},
},
{
actions: {
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
copyMode: null,
distanceVector: null,
pivotPoint: null,
angleStep: null,
numberOfCopies: 0,
selectedEntities: [],
};
}),
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
if (!context.startDistanceVector) {
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
}
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// Draw the array entities to show the result
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
// TODO
// // Draw a dashed line between the start move point and the current mouse location
const activeDistanceLine = new LineEntity(
getActiveLayerId(),
context.startDistanceVector,
endPointTemp
);
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeDistanceLine]);
},
[ArrayAction.PERFORM_COPY]: ({ context }) => {
const resultEntities: Entity[] = [];
for (let i = 1; i <= context.numberOfCopies; i++) {
if (
context.copyMode === 'LINEAR' &&
context.startDistanceVector &&
context.endDistanceVector
) {
const distanceVector = new Vector(
context.endDistanceVector.x - context.startDistanceVector.x,
context.endDistanceVector.y - context.startDistanceVector.y
);
const x = distanceVector.x * i;
const y = distanceVector.y * i;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = entity.clone();
clone.move(x, y);
return clone;
})
);
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
const angleRad = context.angleStep * i * TO_RADIANS;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = entity.clone();
clone.rotate(context.pivotPoint as Point, angleRad);
return clone;
})
);
}
}
addEntities(resultEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[CopyAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return initialArrayContext;
}),
},
}
);
@@ -0,0 +1,164 @@
import { CircleEntity } from '../entities/CircleEntity';
import type { Point } from '@flatten-js/core';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { assign, createMachine } from 'xstate';
import { pointDistance } from '../helpers/distance-between-points';
import { LineState } from './line-tool.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
export interface CircleContext extends ToolContext {
centerPoint: Point | null;
}
export enum CircleState {
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
INIT = 'INIT',
}
export enum CircleAction {
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
}
export const circleToolStateMachine = createMachine(
{
types: {} as {
context: CircleContext;
events: StateEvent;
},
context: {
centerPoint: null,
type: Tool.CIRCLE,
},
initial: CircleState.INIT,
states: {
[CircleState.INIT]: {
description: 'Initializing the circle tool',
always: {
actions: CircleAction.INIT_CIRCLE_TOOL,
target: CircleState.WAITING_FOR_CENTER_POINT,
},
},
[CircleState.WAITING_FOR_CENTER_POINT]: {
description: 'Select the center point of the circle tool',
meta: {
instructions: 'Select the center point of the circle',
},
on: {
MOUSE_CLICK: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
},
},
[CircleState.WAITING_FOR_POINT_ON_CIRCLE]: {
description: 'Select a point on the circle',
meta: {
instructions: 'Select the point on the circle',
},
on: {
DRAW: {
actions: CircleAction.DRAW_TEMP_CIRCLE,
},
MOUSE_CLICK: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: CircleState.INIT,
},
NUMBER_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ESC: {
target: CircleState.INIT,
},
},
},
},
},
{
actions: {
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
centerPoint: null,
};
}),
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
centerPoint: startPoint,
};
}),
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(
(event as DrawEvent).drawController.getWorldMouseLocation(),
context.centerPoint as Point
)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
setGhostHelperEntities([activeCircle]);
},
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
if (!context.centerPoint) {
throw new Error(
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool'
);
}
const pointOnCircle: Point = getPointFromEvent(
context.centerPoint,
event as PointInputEvent
);
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(pointOnCircle, context.centerPoint as Point)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
addEntities([activeCircle], true);
setGhostHelperEntities([]);
return {
centerPoint: null,
};
}),
},
}
);
@@ -0,0 +1,296 @@
import type {Point} from '@flatten-js/core';
import {
addEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {assign, createMachine, sendTo} from 'xstate';
import {selectToolStateMachine} from './select-tool';
import type {Entity} from '../entities/Entity';
import {compact} from 'es-toolkit';
import {LineEntity} from '../entities/LineEntity';
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
import {moveEntities} from './move-tool.helpers';
export interface CopyContext extends ToolContext {
startPoint: Point | null;
originalSelectedEntities: Entity[];
copiedEntities: Entity[];
lastDrawLocation: Point | null;
}
export enum CopyState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT',
WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT',
}
export enum CopyAction {
INIT_COPY_TOOL = 'INIT_COPY_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_START_POINT = 'RECORD_START_POINT',
COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY',
DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES',
COPY_SELECTION = 'COPY_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
/**
* Copy tool state machine
* This state machine is responsible for copying entities around the canvas
* It uses the select tool state machine to select entities to copy
* When the user presses enter, the selected entities are marked for copying
* The user can then select a start point
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
* When the user clicks again, the end point is selected and the entities are copied to the new location
*/
export const copyToolStateMachine = createMachine(
{
types: {} as {
context: CopyContext;
events: StateEvent;
},
context: {
startPoint: null,
originalSelectedEntities: [],
copiedEntities: [],
lastDrawLocation: null,
type: Tool.COPY,
},
initial: CopyState.INIT,
states: {
[CopyState.INIT]: {
description: 'Initializing the copy tool',
always: {
actions: CopyAction.INIT_COPY_TOOL,
target: CopyState.CHECK_SELECTION,
},
},
[CopyState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: CopyState.WAITING_FOR_START_COPY_POINT,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: CopyState.WAITING_FOR_SELECTION,
},
],
},
[CopyState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to copy',
meta: {
instructions: 'Select what you want to copy, then ENTER',
},
invoke: {
id: 'selectToolInsideTheCopyTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {
startPoint: null,
};
}),
target: CopyState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
return event;
}),
},
},
},
[CopyState.WAITING_FOR_START_COPY_POINT]: {
description: 'Select the start of the copy line',
meta: {
instructions: 'Select the start of the copy line',
},
always: {
actions: CopyAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
actions: [
CopyAction.RECORD_START_POINT,
CopyAction.COPY_SELECTION_BEFORE_COPY,
],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
actions: CopyAction.DESELECT_ENTITIES,
target: CopyState.INIT,
},
},
},
[CopyState.WAITING_FOR_END_COPY_POINT]: {
description: 'Select the end of the copy line',
meta: {
instructions: 'Select the end of the copy line',
},
on: {
DRAW: {
actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES],
},
MOUSE_CLICK: {
actions: [CopyAction.COPY_SELECTION],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
actions: CopyAction.DESELECT_ENTITIES,
target: CopyState.INIT,
},
},
},
},
},
{
actions: {
[CopyAction.INIT_COPY_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
originalSelectedEntities: [],
copiedEntities: [],
lastDrawLocation: null,
};
}),
[CopyAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
startPoint: (event as MouseClickEvent).worldMouseLocation,
};
}),
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
// Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides
setSelectedEntityIds([]);
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
copiedEntities: selectedEntities,
};
}),
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[COPY] Calling draw temp copy line without a start point',
);
}
const endPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
// Copy the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
const movedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y,
);
// // Draw a dashed line between the start copy point and the current mouse location
const activeCopyLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp,
);
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
activeCopyLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeCopyLine, ...movedEntities]);
},
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[COPY] Calling copy selection without a start point',
);
}
// Copy the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
const copiedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
moveEntities(
copiedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y,
);
// Switch the copied entities back from the ghost helper entities to the real entities
addEntities([...context.originalSelectedEntities, ...copiedEntities], true);
},
[CopyAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
[CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
...selectToolStateMachine.implementations.actions,
},
},
);
@@ -0,0 +1,126 @@
import {type Circle, Point, type Segment} from '@flatten-js/core';
import {compact} from 'es-toolkit';
import {ArcEntity} from '../entities/ArcEntity';
import type {CircleEntity} from '../entities/CircleEntity';
import type {Entity} from '../entities/Entity';
import type {LineEntity} from '../entities/LineEntity';
import {findNeighboringPointsOnArc} from '../helpers/find-neighboring-points-on-arc';
import {findNeighboringPointsOnCircle} from '../helpers/find-neighboring-points-on-circle';
import {findNeighboringPointsOnLine} from '../helpers/find-neighboring-points-on-line';
import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts';
import {isPointEqual} from '../helpers/is-point-equal';
import {addEntities, deleteEntities, getActiveLayerId} from '../state';
export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] {
// TODO see if we need to make this list unique
return compact(
entities
.filter((otherEntity) => otherEntity.id !== entity.id)
.flatMap((otherEntity) => {
if (entity.id === otherEntity.id) {
return null;
}
return entity.getIntersections(otherEntity);
})
);
}
export function eraseLineSegment(
line: LineEntity,
clickedPointOnShape: Point,
intersections: Point[]
): void {
const segment = line.getShape() as Segment;
const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnLine(
clickedPointOnShape,
segment.start,
segment.end,
intersections
);
const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]);
// Remove the segment that has the clickedPointOnShape point on it
const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape));
deleteEntities([line], false);
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
addEntities(remainingLines, false);
}
export function eraseCircleSegment(
circle: CircleEntity,
clickedPointOnShape: Point,
intersections: Point[]
): void {
if (intersections.length === 0) {
deleteEntities([circle], false);
return;
}
const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnCircle(
clickedPointOnShape,
circle,
intersections
);
const circleShape = circle.getShape() as Circle;
const center = circleShape.center;
const angles = [firstCutPoint, secondCutPoint, clickedPointOnShape].map((p) =>
getAngleWithXAxis(new Point(center.x, center.y), new Point(p.x, p.y))
);
const [startAngle, endAngle] = isAngleBetween(angles[2], angles[0], angles[1])
? [angles[1], angles[0]]
: [angles[0], angles[1]];
const newArc = new ArcEntity(
getActiveLayerId(),
center,
circleShape.r,
startAngle,
endAngle,
true
);
Object.assign(newArc, {
lineColor: circle.lineColor,
lineWidth: circle.lineWidth,
});
deleteEntities([circle], false);
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
addEntities([newArc], false);
}
function isAngleBetween(angle: number, start: number, end: number): boolean {
const twoPi = 2 * Math.PI;
return (angle - start + twoPi) % twoPi <= (end - start + twoPi) % twoPi;
}
export function eraseArcSegment(
arc: ArcEntity,
clickedPointOnShape: Point,
intersections: Point[]
): void {
const [first, second] = findNeighboringPointsOnArc(clickedPointOnShape, arc, intersections);
if (isPointEqual(first, second)) {
deleteEntities([arc], true);
return;
}
const newArcs = arc
.cutAtPoints([first, second])
.filter((cutArc) => !cutArc.containsPointOnShape(clickedPointOnShape));
for (const newArc of newArcs) {
Object.assign(newArc, {
lineColor: arc.lineColor,
lineWidth: arc.lineWidth,
});
addEntities([newArc], false);
}
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
deleteEntities([arc], false);
}
@@ -0,0 +1,83 @@
import {type Arc, Point} from '@flatten-js/core';
import {describe, expect, it} from 'vitest';
import {TO_DEGREES, TO_RADIANS} from '../App.consts.ts';
import type {ArcEntity} from '../entities/ArcEntity.ts';
import {CircleEntity} from '../entities/CircleEntity.ts';
import {EntityName} from '../entities/Entity.ts';
import {RectangleEntity} from '../entities/RectangleEntity.ts';
import {getEntities, setEntities} from '../state.ts';
import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts';
import {handleMouseClick} from './eraser-tool.ts';
describe('erase-tool', () => {
/**
* ---
* --- ---
* -- --
* - -------------------------
* -- | |
* --- | x |
* --| |
* | |
* | |
* | |
* | |
* -------------------------
*/
it('should delete part of circle to form an arc', () => {
const entities = [
new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)),
new CircleEntity('layer1', new Point(0, 0), 10),
];
setEntities(entities);
handleMouseClick(new Point(8, -8));
const entitiesAfterErase = getEntities();
expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle);
expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc);
const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc;
expect(arc.center).toEqual(new Point(0, 0));
expect(arc.r).toEqual(10);
expect(arc.startAngle * TO_DEGREES).toEqual(0);
expect(arc.endAngle * TO_DEGREES).toEqual(270);
});
/**
* ---
* --- ---
* -- --
* - -------------------------
* -- | |
* --- | x |
* --| |
* | |
* | |
* | |
* | |
* -------------------------
*/
it('should delete part of circle to form an arc using inner functions', () => {
const entities = [
new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)),
new CircleEntity('layer1', new Point(0, 0), 10),
];
setEntities(entities);
const intersections = getAllIntersectionPoints(entities[1], getEntities());
expect(intersections[0]).toEqual(new Point(0, -10));
expect(intersections[1]).toEqual(new Point(10, 0));
eraseCircleSegment(
entities[1] as CircleEntity,
new Point(10 * Math.cos(-45 * TO_RADIANS), 10 * Math.sin(-45 * TO_RADIANS)),
intersections
);
const entitiesAfterErase = getEntities();
expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle);
expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc);
const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc;
expect(arc.center).toEqual(new Point(0, 0));
expect(arc.r).toEqual(10);
expect(arc.startAngle * TO_DEGREES).toEqual(0);
expect(arc.endAngle * TO_DEGREES).toEqual(270);
});
});
@@ -0,0 +1,152 @@
import type {Point, Polygon} from '@flatten-js/core';
import {assign, createMachine} from 'xstate';
import type {ArcEntity} from '../entities/ArcEntity';
import type {CircleEntity} from '../entities/CircleEntity';
import {EntityName} from '../entities/Entity';
import {LineEntity} from '../entities/LineEntity';
import type {RectangleEntity} from '../entities/RectangleEntity';
import {findClosestEntity} from '../helpers/find-closest-entity';
import {polygonToSegments} from '../helpers/polygon-to-segments';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getEntities,
setEntities,
setGhostHelperEntities,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {eraseArcSegment, eraseCircleSegment, eraseLineSegment, getAllIntersectionPoints,} from './eraser-tool.helpers';
import type {MouseClickEvent, StateEvent, ToolContext} from './tool.types';
export interface EraserContext extends ToolContext {
startPoint: Point | null;
}
export enum EraserState {
INIT = 'INIT',
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
}
export enum EraserAction {
INIT_ERASER_TOOL = 'INIT_ERASER_TOOL',
HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK',
}
export const eraserToolStateMachine = createMachine(
{
types: {} as {
context: EraserContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.ERASER,
},
initial: EraserState.INIT,
states: {
[EraserState.INIT]: {
description: 'Initializing the eraser tool',
always: {
actions: EraserAction.INIT_ERASER_TOOL,
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
[EraserState.WAITING_FOR_FIRST_CLICK]: {
description: 'Select a line segment to delete',
meta: {
instructions: 'Select a line segment to delete',
},
on: {
// TODO implement a DRAW action to draw the segment that will be erased in dotted line
MOUSE_CLICK: {
actions: EraserAction.HANDLE_MOUSE_CLICK,
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
},
// TODO implement rectangle selection to delete
},
},
{
actions: {
[EraserAction.INIT_ERASER_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
return {};
}),
[EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => {
handleMouseClick((event as MouseClickEvent).worldMouseLocation);
return context;
}),
},
}
);
export function handleMouseClick(worldMouseLocation: Point) {
const closestEntity = findClosestEntity(worldMouseLocation, getEntities());
if (!closestEntity) {
return;
}
const clickedPointOnShape = closestEntity.segment.start;
// Find entities that intersect with the closest entity
const intersections = getAllIntersectionPoints(closestEntity.entity, getEntities());
const entityType = closestEntity.entity.getType();
switch (entityType) {
case EntityName.Line: {
const line = closestEntity.entity as LineEntity;
eraseLineSegment(line, clickedPointOnShape, intersections);
break;
}
case EntityName.Circle: {
const circle = closestEntity.entity as CircleEntity;
eraseCircleSegment(circle, clickedPointOnShape, intersections);
break;
}
case EntityName.Arc: {
const arc = closestEntity.entity as ArcEntity;
eraseArcSegment(arc, clickedPointOnShape, intersections);
break;
}
case EntityName.Rectangle: {
const rectangle = closestEntity.entity as RectangleEntity;
const segments = polygonToSegments(rectangle.getShape() as Polygon);
const segmentEntities = segments.map(
(segment) => new LineEntity(getActiveLayerId(), segment)
);
// Find the closest segment to the clicked point
const closestSegmentInfo = findClosestEntity(worldMouseLocation, segmentEntities);
// Remove the rectangle
deleteEntities([rectangle], false);
// Add 4 lines instead of the rectangle
addEntities(segmentEntities, false);
// Remove (a segment) of the 4th line closest to the cursor
const lineIntersections = getAllIntersectionPoints(closestSegmentInfo.entity, getEntities());
eraseLineSegment(
closestSegmentInfo.entity as LineEntity,
clickedPointOnShape,
lineIntersections
);
break;
}
case EntityName.Image: {
// TODO implement image eraser
// Switch to rectangle entity and delete the image and the line that was closest to the cursor
}
}
setEntities(getEntities(), true); // Force a new undo state entry
}
@@ -0,0 +1,46 @@
import {Box, type Point} from '@flatten-js/core';
export function getContainRectangleInsideRectangle(
imageWidth: number,
imageHeight: number,
rectangleStartPoint: Point,
rectangleEndPoint: Point
): Box | null {
const width = Math.abs(rectangleStartPoint.x - rectangleEndPoint.x);
const height = Math.abs(rectangleStartPoint.y - rectangleEndPoint.y);
if (width === 0 || height === 0) {
return null;
}
const imageAspectRatio = imageWidth / imageHeight;
const cursorRectangleAspectRatio = width / height;
// Draw the image to contain the rectangle created by rectangleStartPoint and rectangleEndPoint
if (imageAspectRatio < cursorRectangleAspectRatio) {
const newWidth = Math.abs(height * imageAspectRatio);
const drawX =
rectangleStartPoint.x < rectangleEndPoint.x
? rectangleStartPoint.x
: rectangleStartPoint.x - newWidth;
const drawY =
rectangleStartPoint.y < rectangleEndPoint.y
? rectangleStartPoint.y
: rectangleStartPoint.y - height;
return new Box(drawX, drawY, drawX + newWidth, drawY + height);
}
const newHeight = Math.abs(width / imageAspectRatio);
const drawX =
rectangleStartPoint.x < rectangleEndPoint.x
? rectangleStartPoint.x
: rectangleStartPoint.x - width;
const drawY =
rectangleStartPoint.y < rectangleEndPoint.y
? rectangleStartPoint.y
: rectangleStartPoint.y - newHeight;
return new Box(drawX, drawY, drawX + width, drawY + newHeight);
}
@@ -0,0 +1,245 @@
import type {Point} from '@flatten-js/core';
import {
addEntities,
getActiveLayerId,
setActiveToolActor,
setAngleGuideEntities,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {Actor, assign, createMachine} from 'xstate';
import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types';
import {ImageEntity} from '../entities/ImageEntity';
import {getContainRectangleInsideRectangle} from './image-import-tool.helpers';
import {RectangleEntity} from '../entities/RectangleEntity';
import {selectToolStateMachine} from './select-tool';
import {boxToPolygon, twoPointBoxToPolygon} from '../helpers/box-to-polygon';
import {isPointEqual} from '../helpers/is-point-equal.ts';
import {getPointFromEvent} from '../helpers/get-point-from-event.ts';
export interface ImageImportContext extends ToolContext {
startPoint: Point | null;
imageElement: HTMLImageElement | null;
}
export enum ImageImportState {
INIT = 'INIT',
WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA',
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
}
export enum ImageImportAction {
INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL',
STORE_IMAGE_DATA = 'STORE_IMAGE_DATA',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT',
DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
}
export const imageImportToolStateMachine = createMachine(
{
types: {} as {
context: ImageImportContext;
events: StateEvent;
},
context: {
type: Tool.IMAGE_IMPORT,
startPoint: null,
imageElement: null,
},
initial: ImageImportState.INIT,
states: {
[ImageImportState.INIT]: {
description: 'Initializing the imageImport tool',
always: {
actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
target: ImageImportState.WAIT_FOR_IMAGE_DATA,
},
},
[ImageImportState.WAIT_FOR_IMAGE_DATA]: {
description: 'Select an image file to import',
meta: {
instructions: 'Select an image file to import',
},
on: {
[ActorEvent.FILE_SELECTED]: {
actions: ImageImportAction.STORE_IMAGE_DATA,
target: ImageImportState.WAITING_FOR_START_POINT,
},
ESC: {
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
},
},
},
[ImageImportState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the imageImport',
meta: {
instructions: 'Select the start point of the imageImport',
},
on: {
MOUSE_CLICK: {
actions: ImageImportAction.RECORD_START_POINT,
target: ImageImportState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: ImageImportAction.RECORD_START_POINT,
target: ImageImportState.WAITING_FOR_END_POINT,
},
},
},
[ImageImportState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the imageImport',
meta: {
instructions: 'Select the end point of the imageImport',
},
on: {
DRAW: {
actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT,
},
MOUSE_CLICK: {
actions: [
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
ImageImportAction.SWITCH_TO_SELECT_TOOL,
],
},
NUMBER_INPUT: {
actions: [
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
ImageImportAction.SWITCH_TO_SELECT_TOOL,
],
},
ABSOLUTE_POINT_INPUT: {
actions: [
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
ImageImportAction.SWITCH_TO_SELECT_TOOL,
],
},
RELATIVE_POINT_INPUT: {
actions: [
ImageImportAction.DRAW_FINAL_IMAGE_IMPORT,
ImageImportAction.INIT_IMAGE_IMPORT_TOOL,
ImageImportAction.SWITCH_TO_SELECT_TOOL,
],
},
ESC: {
actions: ImageImportAction.SWITCH_TO_SELECT_TOOL,
},
},
},
},
},
{
actions: {
[ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
imageElement: null,
};
}),
[ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => {
return {
imageElement: (event as FileSelectedEvent).image,
};
}),
[ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
...context,
startPoint: startPoint,
};
}),
[ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
);
}
if (!context.imageElement) {
throw new Error(
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT',
);
}
if (
isPointEqual(
context.startPoint,
(event as DrawEvent).drawController.getWorldMouseLocation(),
)
) {
return; // Can't draw an image that is 0 pixels wide
}
const endPoint = getPointFromEvent(
context.startPoint,
event as PointInputEvent,
);
const containRectangle = getContainRectangleInsideRectangle(
context.imageElement.naturalWidth,
context.imageElement.naturalHeight,
context.startPoint,
endPoint,
);
if (!containRectangle) {
return;
}
const activeImage = new ImageEntity(
getActiveLayerId(),
context.imageElement,
containRectangle.low,
containRectangle.high,
0,
);
const draggedRectangle = new RectangleEntity(
getActiveLayerId(),
twoPointBoxToPolygon(context.startPoint, endPoint),
);
setGhostHelperEntities([activeImage]);
setAngleGuideEntities([draggedRectangle]);
},
[ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
);
}
if (!context.imageElement) {
throw new Error(
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT',
);
}
const containRectangle = getContainRectangleInsideRectangle(
context.imageElement.naturalWidth,
context.imageElement.naturalHeight,
context.startPoint,
(event as MouseClickEvent).worldMouseLocation,
);
if (!containRectangle) {
return;
}
const activeImage = new ImageEntity(
getActiveLayerId(),
context.imageElement,
boxToPolygon(containRectangle),
);
addEntities([activeImage], true);
},
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
},
);
@@ -0,0 +1,169 @@
import type { Point } from '@flatten-js/core';
import { LineEntity } from '../entities/LineEntity';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setActiveToolActor,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { selectToolStateMachine } from './select-tool.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
export interface LineContext extends ToolContext {
startPoint: Point | null;
}
export enum LineState {
INIT = 'INIT',
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
}
export enum LineAction {
INIT_LINE_TOOL = 'INIT_LINE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
}
export const lineToolStateMachine = createMachine(
{
types: {} as {
context: LineContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.LINE,
},
initial: LineState.INIT,
states: {
[LineState.INIT]: {
description: 'Initializing the line tool',
always: {
actions: LineAction.INIT_LINE_TOOL,
target: LineState.WAITING_FOR_START_POINT,
},
},
[LineState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the line',
meta: {
instructions: 'Select the start point of the line',
},
on: {
MOUSE_CLICK: {
actions: LineAction.RECORD_START_POINT,
target: LineState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: LineAction.RECORD_START_POINT,
target: LineState.WAITING_FOR_END_POINT,
},
ESC: {
actions: LineAction.SWITCH_TO_SELECT_TOOL,
},
},
},
[LineState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the line',
meta: {
instructions: 'Select the end point of the line',
},
on: {
DRAW: {
actions: LineAction.DRAW_TEMP_LINE,
},
MOUSE_CLICK: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
NUMBER_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
RELATIVE_POINT_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
ESC: {
target: LineState.INIT,
},
ENTER: {
target: LineState.INIT,
},
},
},
},
},
{
actions: {
[LineAction.INIT_LINE_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
};
}),
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
};
}),
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
const activeLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation()
);
activeLine.lineColor = getActiveLineColor();
activeLine.lineWidth = getActiveLineWidth();
activeLine.lineDash = getActiveLineDash();
setGhostHelperEntities([activeLine]);
},
[LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => {
if (!context.startPoint) {
throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity');
}
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const activeLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPoint
);
activeLine.lineColor = getActiveLineColor();
activeLine.lineWidth = getActiveLineWidth();
activeLine.lineDash = getActiveLineDash();
addEntities([activeLine], true);
// Keep drawing from the last point
setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]);
setAngleGuideOriginPoint(endPoint);
return {
startPoint: endPoint,
};
}),
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
}
);
@@ -0,0 +1,221 @@
import { type Point, Vector } from '@flatten-js/core';
import { MeasurementEntity } from '../entities/MeasurementEntity';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import { assign, createMachine } from 'xstate';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import { isPointEqual } from '../helpers/is-point-equal.ts';
export interface MeasurementContext extends ToolContext {
startPoint: Point | null;
endPoint: Point | null;
}
export enum MeasurementState {
INIT = 'INIT',
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
}
export enum MeasurementAction {
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
RECORD_END_POINT = 'RECORD_END_POINT',
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
}
export const measurementToolStateMachine = createMachine(
{
types: {} as {
context: MeasurementContext;
events: StateEvent;
},
context: {
startPoint: null,
endPoint: null,
type: Tool.MEASUREMENT,
},
initial: MeasurementState.INIT,
states: {
[MeasurementState.INIT]: {
description: 'Initializing the line tool',
always: {
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
target: MeasurementState.WAITING_FOR_START_POINT,
},
},
[MeasurementState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the measurement',
meta: {
instructions: 'Select the start point of the measurement',
},
on: {
MOUSE_CLICK: {
actions: MeasurementAction.RECORD_START_POINT,
target: MeasurementState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: MeasurementAction.RECORD_START_POINT,
target: MeasurementState.WAITING_FOR_END_POINT,
},
},
},
[MeasurementState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the measurement',
meta: {
instructions: 'Select the end point of the measurement',
},
on: {
DRAW: {
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
},
MOUSE_CLICK: {
actions: MeasurementAction.RECORD_END_POINT,
target: MeasurementState.WAITING_FOR_OFFSET,
},
NUMBER_INPUT: {
actions: MeasurementAction.RECORD_END_POINT,
target: MeasurementState.WAITING_FOR_OFFSET,
},
ABSOLUTE_POINT_INPUT: {
actions: MeasurementAction.RECORD_END_POINT,
target: MeasurementState.WAITING_FOR_OFFSET,
},
RELATIVE_POINT_INPUT: {
actions: MeasurementAction.RECORD_END_POINT,
target: MeasurementState.WAITING_FOR_OFFSET,
},
ESC: {
target: MeasurementState.INIT,
},
},
},
[MeasurementState.WAITING_FOR_OFFSET]: {
description: 'Select the offset to display the measurement at',
meta: {
instructions: 'Select the offset to display the measurement at',
},
on: {
DRAW: {
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
},
MOUSE_CLICK: {
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
target: MeasurementState.INIT,
},
NUMBER_INPUT: {
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
target: MeasurementState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
target: MeasurementState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
target: MeasurementState.INIT,
},
ESC: {
target: MeasurementState.INIT,
},
},
},
},
},
{
actions: {
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
endPoint: null,
};
}),
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
};
}),
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
setAngleGuideOriginPoint(endPoint);
return {
...context,
endPoint,
};
}),
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
const startPoint = context.startPoint as Point;
let endPoint: Point;
let offsetPoint: Point;
if (!context.endPoint) {
// User has drawn startPoint, but not yet endPoint
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
endPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
if (isPointEqual(startPoint, endPoint)) {
return; // Cannot draw temp measurement when start and endpoint are equal
}
const normalVector = new Vector(startPoint, endPoint)
.rotate(-90 * TO_RADIANS)
.normalize();
// Pixel constant → world units so the default offset is zoom-independent
const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1;
offsetPoint = startPoint
.clone()
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor));
} else {
// User has already selected a startPoint and endPoint
// The offsetPoint should be set to the mouse location
endPoint = context.endPoint as Point;
offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
}
const activeMeasurement = new MeasurementEntity(
getActiveLayerId(),
context.startPoint as Point,
endPoint,
offsetPoint
);
activeMeasurement.lineColor = getActiveLineColor();
activeMeasurement.lineWidth = getActiveLineWidth();
activeMeasurement.lineDash = getActiveLineDash();
setGhostHelperEntities([activeMeasurement]);
},
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent);
const activeMeasurement = new MeasurementEntity(
getActiveLayerId(),
context.startPoint as Point,
context.endPoint as Point,
offsetPoint
);
activeMeasurement.lineColor = getActiveLineColor();
activeMeasurement.lineWidth = getActiveLineWidth();
activeMeasurement.lineDash = getActiveLineDash();
addEntities([activeMeasurement], true);
},
},
}
);
@@ -0,0 +1,13 @@
import type {Entity} from '../entities/Entity';
/**
* Move entities by the difference between the start and end points
* @param entities
* @param deltaX
* @param deltaY
*/
export function moveEntities(entities: Entity[], deltaX: number, deltaY: number) {
for (const entity of entities) {
entity.move(deltaX, deltaY);
}
}
@@ -0,0 +1,298 @@
import type {Point} from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {assign, createMachine, sendTo} from 'xstate';
import {selectToolStateMachine} from './select-tool';
import type {Entity} from '../entities/Entity';
import {compact} from 'es-toolkit';
import {moveEntities} from './move-tool.helpers';
import {LineEntity} from '../entities/LineEntity';
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
export interface MoveContext extends ToolContext {
startPoint: Point | null;
originalSelectedEntities: Entity[];
movedEntities: Entity[];
lastDrawLocation: Point | null;
}
export enum MoveState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT',
WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT',
}
export enum MoveAction {
INIT_MOVE_TOOL = 'INIT_MOVE_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_START_POINT = 'RECORD_START_POINT',
COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE',
DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES',
MOVE_SELECTION = 'MOVE_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
/**
* Move tool state machine
* This state machine is responsible for moving entities around the canvas
* It uses the select tool state machine to select entities to move
* When the user presses enter, the selected entities are marked for moving
* The user can then select a start point
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
* When the user clicks again, the end point is selected and the entities are moved to the new location
*/
export const moveToolStateMachine = createMachine(
{
types: {} as {
context: MoveContext;
events: StateEvent;
},
context: {
startPoint: null,
originalSelectedEntities: [],
movedEntities: [],
lastDrawLocation: null,
type: Tool.MOVE,
},
initial: MoveState.INIT,
states: {
[MoveState.INIT]: {
description: 'Initializing the move tool',
always: {
actions: MoveAction.INIT_MOVE_TOOL,
target: MoveState.CHECK_SELECTION,
},
},
[MoveState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: MoveState.WAITING_FOR_START_MOVE_POINT,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: MoveState.WAITING_FOR_SELECTION,
},
],
},
[MoveState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to move',
meta: {
instructions: 'Select what you want to move, then ENTER',
},
invoke: {
id: 'selectToolInsideTheMoveTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {
startPoint: null,
};
}),
target: MoveState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
},
},
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
description: 'Select the start of the move line',
meta: {
instructions: 'Select the start of the move line',
},
always: {
actions: MoveAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
actions: [
MoveAction.RECORD_START_POINT,
MoveAction.COPY_SELECTION_BEFORE_MOVE,
],
target: MoveState.WAITING_FOR_END_MOVE_POINT,
},
ESC: {
actions: MoveAction.DESELECT_ENTITIES,
target: MoveState.INIT,
},
},
},
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
description: 'Select the end of the move line',
meta: {
instructions: 'Select the end of the move line',
},
on: {
DRAW: {
actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES],
},
MOUSE_CLICK: {
actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES],
target: MoveState.WAITING_FOR_SELECTION,
},
ESC: {
actions: MoveAction.RESTORE_ORIGINAL_ENTITIES,
target: MoveState.INIT,
},
},
},
},
},
{
actions: {
[MoveAction.INIT_MOVE_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
originalSelectedEntities: [],
movedEntities: [],
lastDrawLocation: null,
};
}),
[MoveAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
startPoint: (event as MouseClickEvent).worldMouseLocation,
};
}),
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
// Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
// Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
deleteEntities(selectedEntities, false);
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides
setSelectedEntityIds([]);
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
movedEntities: selectedEntities,
};
}),
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[MOVE] Calling draw temp move line without a start point',
);
}
const endPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
// Move the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
const movedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y,
);
// // Draw a dashed line between the start move point and the current mouse location
const activeMoveLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp,
);
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
activeMoveLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeMoveLine, ...movedEntities]);
},
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[MOVE] Calling move selection without a start point',
);
}
// Move the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
moveEntities(
context.originalSelectedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y,
);
// Switch the moved entities back from the ghost helper entities to the real entities
addEntities(context.originalSelectedEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[MoveAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
[MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
...selectToolStateMachine.implementations.actions,
},
},
);
@@ -0,0 +1,138 @@
import {toast} from 'react-toastify';
import {assign, createMachine, sendTo} from 'xstate';
import {PolyLineEntity} from '../entities/PolyLineEntity.ts';
import {
getActiveLayerId,
getNotSelectedEntities,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {selectToolStateMachine} from './select-tool';
import type {StateEvent, ToolContext} from './tool.types';
export interface PeditContext extends ToolContext {}
export enum PeditState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
}
export enum PeditAction {
INIT_PEDIT_TOOL = 'INIT_PEDIT_TOOL',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
}
/**
* Pedit tool state machine
* This state machine is responsible for combining entities into a polyline
* It uses the select tool state machine to select entities to combine
* When the user presses enter, the selected entities are combined into one polyline
*/
export const peditToolStateMachine = createMachine(
{
types: {} as {
context: PeditContext;
events: StateEvent;
},
context: {
type: Tool.PEDIT,
},
initial: PeditState.INIT,
states: {
[PeditState.INIT]: {
description: 'Initializing the pedit tool',
always: {
actions: PeditAction.INIT_PEDIT_TOOL,
target: PeditState.CHECK_SELECTION,
},
},
[PeditState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: PeditState.CONVERT_SELECTION_TO_POLYLINE,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: PeditState.WAITING_FOR_SELECTION,
},
],
},
[PeditState.WAITING_FOR_SELECTION]: {
description: 'Select the entities that you want to combine into a polyline',
meta: {
instructions: 'Select what you want to combine into a polyline, then ENTER',
},
invoke: {
id: 'selectToolInsideThePeditTool',
src: selectToolStateMachine,
onDone: {
target: PeditState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [PeditAction.DESELECT_ENTITIES, PeditAction.INIT_PEDIT_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
return event;
}),
},
},
},
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
always: {
actions: PeditAction.CONVERT_SELECTION_TO_POLYLINE,
target: PeditState.INIT,
},
},
},
},
{
actions: {
[PeditAction.INIT_PEDIT_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {};
}),
[PeditAction.CONVERT_SELECTION_TO_POLYLINE]: assign(() => {
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
const newEntities = [...getNotSelectedEntities(), newPolyLine];
setEntities(newEntities, true);
setSelectedEntityIds([]);
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
return {};
}),
...selectToolStateMachine.implementations.actions,
},
}
);
@@ -0,0 +1,152 @@
import type { Point } from '@flatten-js/core';
import { RectangleEntity } from '../entities/RectangleEntity';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { assign, createMachine } from 'xstate';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
export interface RectangleContext extends ToolContext {
startPoint: Point | null;
}
export enum RectangleState {
INIT = 'INIT',
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
}
export enum RectangleAction {
INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE',
DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE',
}
export const rectangleToolStateMachine = createMachine(
{
types: {} as {
context: RectangleContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.RECTANGLE,
},
initial: RectangleState.INIT,
states: {
[RectangleState.INIT]: {
description: 'Initializing the rectangle tool',
always: {
actions: RectangleAction.INIT_RECTANGLE_TOOL,
target: RectangleState.WAITING_FOR_START_POINT,
},
},
[RectangleState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the rectangle',
meta: {
instructions: 'Select the start point of the rectangle',
},
on: {
MOUSE_CLICK: {
actions: RectangleAction.RECORD_START_POINT,
target: RectangleState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: RectangleAction.RECORD_START_POINT,
target: RectangleState.WAITING_FOR_END_POINT,
},
},
},
[RectangleState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the rectangle',
meta: {
instructions: 'Select the end point of the rectangle',
},
on: {
DRAW: {
actions: RectangleAction.DRAW_TEMP_RECTANGLE,
},
MOUSE_CLICK: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
NUMBER_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0
target: RectangleState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
ESC: {
target: RectangleState.INIT,
},
},
},
},
},
{
actions: {
[RectangleAction.INIT_RECTANGLE_TOOL]: () => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
},
[RectangleAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
};
}),
[RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[RECTANGLE]: calling draw without start point being set');
}
const activeRectangle = new RectangleEntity(
getActiveLayerId(),
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation()
);
activeRectangle.lineColor = getActiveLineColor();
activeRectangle.lineWidth = getActiveLineWidth();
activeRectangle.lineDash = getActiveLineDash();
setGhostHelperEntities([activeRectangle]);
},
[RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => {
if (!context.startPoint) {
throw Error(
'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool'
);
}
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const activeRectangle = new RectangleEntity(
getActiveLayerId(),
context.startPoint as Point,
endPoint
);
activeRectangle.lineColor = getActiveLineColor();
activeRectangle.lineWidth = getActiveLineWidth();
activeRectangle.lineDash = getActiveLineDash();
addEntities([activeRectangle], true);
},
},
}
);
@@ -0,0 +1,22 @@
import {Line, type Point} from '@flatten-js/core';
import type {Entity} from '../entities/Entity';
/**
* Rotate entities round a base point by a certain angle
* @param entities
* @param rotateOrigin
* @param startAnglePoint
* @param endAnglePoint
*/
export function rotateEntities(
entities: Entity[],
rotateOrigin: Point,
startAnglePoint: Point,
endAnglePoint: Point
) {
const rotationAngle =
new Line(rotateOrigin, endAnglePoint).slope - new Line(rotateOrigin, startAnglePoint).slope;
for (const entity of entities) {
entity.rotate(rotateOrigin, rotationAngle);
}
}
@@ -0,0 +1,325 @@
import type {Point} from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {assign, createMachine, sendTo} from 'xstate';
import {selectToolStateMachine} from './select-tool';
import type {Entity} from '../entities/Entity';
import {compact} from 'es-toolkit';
import {rotateEntities} from './rotate-tool.helpers';
export interface RotateContext extends ToolContext {
rotationOrigin: Point | null;
angleStartPoint: Point | null;
originalSelectedEntities: Entity[];
}
export enum RotateState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN',
WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT',
WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT',
}
export enum RotateAction {
INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN',
RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT',
COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE',
DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES',
ROTATE_SELECTION = 'ROTATE_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
/**
* Rotate tool state machine
* This state machine is responsible for rotating entities around a point (rotation origin) by a certain angle
* It uses the select tool state machine to select entities to rotate
* When the user presses enter, the selected entities are marked for rotating
* The user can then select a rotation origin point
* The user can then select the start point of the rotation angle
* When the user moves their mouse, the selected entities will be updated to rotate according to the angle: start angle point => rotation origin => mouse location
* When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin
*/
export const rotateToolStateMachine = createMachine(
{
types: {} as {
context: RotateContext;
events: StateEvent;
},
context: {
rotationOrigin: null,
angleStartPoint: null,
originalSelectedEntities: [],
type: Tool.ROTATE,
},
initial: RotateState.INIT,
states: {
[RotateState.INIT]: {
description: 'Initializing the rotate tool',
always: {
actions: RotateAction.INIT_ROTATE_TOOL,
target: RotateState.CHECK_SELECTION,
},
},
[RotateState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: RotateState.WAITING_FOR_ROTATION_ORIGIN,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: RotateState.WAITING_FOR_SELECTION,
},
],
},
[RotateState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to rotate',
meta: {
instructions: 'Select what you want to rotate, then ENTER',
},
invoke: {
id: 'selectToolInsideTheRotateTool',
src: selectToolStateMachine,
onDone: {
actions: assign(({ context }) => {
return {
...context,
};
}),
target: RotateState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [
RotateAction.DESELECT_ENTITIES,
RotateAction.INIT_ROTATE_TOOL,
],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
return event;
}),
},
},
},
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
description: 'Select the origin of the rotate operation',
meta: {
instructions: 'Select the origin of the rotate operation',
},
always: {
actions: RotateAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
actions: [RotateAction.RECORD_ROTATION_ORIGIN],
target: RotateState.WAITING_FOR_ANGLE_START_POINT,
},
ESC: {
actions: RotateAction.DESELECT_ENTITIES,
target: RotateState.INIT,
},
},
},
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
description: 'Select the end of the base rotate line',
meta: {
instructions: 'Select the end of the base rotate line',
},
on: {
MOUSE_CLICK: {
actions: [
RotateAction.RECORD_ROTATION_ANGLE_START_POINT,
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
],
target: RotateState.WAITING_FOR_ANGLE_END_POINT,
},
ESC: {
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
target: RotateState.INIT,
},
},
},
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
description: 'Select the end of the rotate line',
meta: {
instructions: 'Select the end of the rotate line',
},
on: {
DRAW: {
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
},
MOUSE_CLICK: {
actions: [
RotateAction.ROTATE_SELECTION,
RotateAction.DESELECT_ENTITIES,
],
target: RotateState.WAITING_FOR_SELECTION,
},
ESC: {
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
target: RotateState.INIT,
},
},
},
},
},
{
actions: {
[RotateAction.INIT_ROTATE_TOOL]: () => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
},
[RotateAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(
({ context, event }): RotateContext => {
setAngleGuideOriginPoint(
(event as MouseClickEvent).worldMouseLocation,
);
return {
...context,
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
};
},
),
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
({ context, event }): RotateContext => {
return {
...context,
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
),
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(
({ context }): RotateContext => {
const selectedEntities = getSelectedEntities();
// Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
// Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
deleteEntities(selectedEntities, false);
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being rotated and the original entities also are used for snap points / angle guides
setSelectedEntityIds([]);
return {
...context,
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
};
},
),
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
if (!context.rotationOrigin || !context.angleStartPoint) {
throw new Error(
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point',
);
}
const angleEndpoint = (
event as DrawEvent
).drawController.getWorldMouseLocation();
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
const rotatedEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
);
rotateEntities(
rotatedEntities,
context.rotationOrigin,
context.angleStartPoint,
angleEndpoint,
);
setGhostHelperEntities(rotatedEntities);
},
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
if (!context.rotationOrigin || !context.angleStartPoint) {
throw new Error(
'[ROTATE] Calling rotate selection without some rotate vector endpoints',
);
}
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
// Rotate the entities one final time
const rotatedEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
);
rotateEntities(
rotatedEntities,
context.rotationOrigin,
context.angleStartPoint,
angleEndpoint,
);
// Switch the rotated entities back from the ghost helper entities to the real entities
addEntities(rotatedEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
...context,
rotationOrigin: null,
angleStartPoint: null,
originalSelectedEntities: [],
};
}),
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(
({ context }): RotateContext => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
...context,
rotationOrigin: null,
angleStartPoint: null,
originalSelectedEntities: [],
};
},
),
...selectToolStateMachine.implementations.actions,
},
},
);
@@ -0,0 +1,24 @@
import type {Point} from '@flatten-js/core';
import type {Entity} from '../entities/Entity';
import {pointDistance} from '../helpers/distance-between-points';
/**
* Scale entities by base vector to destination scale vector
* @param entities
* @param baseVectorStartPoint
* @param baseVectorEndPoint
* @param scaleVectorEndPoint
*/
export function scaleEntities(
entities: Entity[],
baseVectorStartPoint: Point,
baseVectorEndPoint: Point,
scaleVectorEndPoint: Point
) {
const scaleFactor =
pointDistance(baseVectorStartPoint, scaleVectorEndPoint) /
pointDistance(baseVectorStartPoint, baseVectorEndPoint);
for (const entity of entities) {
entity.scale(baseVectorStartPoint, scaleFactor);
}
}
@@ -0,0 +1,326 @@
import type {Point} from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {assign, createMachine, sendTo} from 'xstate';
import {selectToolStateMachine} from './select-tool';
import type {Entity} from '../entities/Entity';
import {compact} from 'es-toolkit';
import {scaleEntities} from './scale-tool.helpers';
export interface ScaleContext extends ToolContext {
baseVectorStartPoint: Point | null;
baseVectorEndPoint: Point | null;
scaleVectorEndPoint: Point | null;
originalSelectedEntities: Entity[];
}
export enum ScaleState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT',
WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT',
WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT',
}
export enum ScaleAction {
INIT_SCALE_TOOL = 'INIT_SCALE_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT',
RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT',
COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE',
DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES',
SCALE_SELECTION = 'SCALE_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
}
/**
* Scale tool state machine
* This state machine is responsible for scaling entities from a base vector to a certain scale vector
* It uses the select tool state machine to select entities to scale
* When the user presses enter, the selected entities are marked for scaling
* The user can then select a base scale vector start point
* The user can then select the base scale vector end point
* When the user moves their mouse, the selected entities will be updated to scale according to the vector: base vector => scale vector from start point to mouse location
* When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector
*/
export const scaleToolStateMachine = createMachine(
{
types: {} as {
context: ScaleContext;
events: StateEvent;
},
context: {
baseVectorStartPoint: null,
baseVectorEndPoint: null,
scaleVectorEndPoint: null,
originalSelectedEntities: [],
type: Tool.SCALE,
},
initial: ScaleState.INIT,
states: {
[ScaleState.INIT]: {
description: 'Initializing the scale tool',
always: {
actions: ScaleAction.INIT_SCALE_TOOL,
target: ScaleState.CHECK_SELECTION,
},
},
[ScaleState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: ScaleState.WAITING_FOR_SELECTION,
},
],
},
[ScaleState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to scale',
meta: {
instructions: 'Select what you want to scale, then ENTER',
},
invoke: {
id: 'selectToolInsideTheScaleTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {
baseVectorStartPoint: null,
baseVectorEndPoint: null,
scaleVectorEndPoint: null,
originalSelectedEntities: [],
};
}),
target: ScaleState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [
ScaleAction.DESELECT_ENTITIES,
ScaleAction.INIT_SCALE_TOOL,
],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
return event;
}),
},
},
},
[ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: {
description: 'Select the origin of the scale operation',
meta: {
instructions: 'Select the origin of the scale operation',
},
always: {
actions: ScaleAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT],
target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT,
},
ESC: {
actions: ScaleAction.DESELECT_ENTITIES,
target: ScaleState.INIT,
},
},
},
[ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: {
description: 'Select the end of the base scale line',
meta: {
instructions: 'Select the end of the base scale line',
},
on: {
MOUSE_CLICK: {
actions: [
ScaleAction.RECORD_BASE_VECTOR_END_POINT,
ScaleAction.COPY_SELECTION_BEFORE_SCALE,
],
target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT,
},
ESC: {
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
target: ScaleState.INIT,
},
},
},
[ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: {
description: 'Select the end of the scale line',
meta: {
instructions: 'Select the end of the scale line',
},
on: {
DRAW: {
actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES],
},
MOUSE_CLICK: {
actions: [
ScaleAction.SCALE_SELECTION,
ScaleAction.DESELECT_ENTITIES,
],
target: ScaleState.WAITING_FOR_SELECTION,
},
ESC: {
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
target: ScaleState.INIT,
},
},
},
},
},
{
actions: {
[ScaleAction.INIT_SCALE_TOOL]: () => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
},
[ScaleAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(
({ context, event }) => {
setAngleGuideOriginPoint(
(event as MouseClickEvent).worldMouseLocation,
);
return {
...context,
baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
),
[ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(
({ context, event }) => {
return {
...context,
baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
),
[ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
// Scale the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
// Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
deleteEntities(selectedEntities, false);
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being scaled and the original entities also are used for snap points / angle guides
setSelectedEntityIds([]);
return {
...context,
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
};
}),
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
throw new Error(
'[SCALE] Calling draw temp scale entities without a base start point or base end point',
);
}
const scaleVectorEndPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
const scaledEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
);
scaleEntities(
scaledEntities,
context.baseVectorStartPoint,
context.baseVectorEndPoint,
scaleVectorEndPointTemp,
);
setGhostHelperEntities(scaledEntities);
},
[ScaleAction.SCALE_SELECTION]: ({ context, event }) => {
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
throw new Error(
'[SCALE] Calling scale selection without some scale vector endpoints',
);
}
const scaleVectorEndPoint = (event as MouseClickEvent)
.worldMouseLocation;
// Scale the entities one final time
const scaledEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
);
scaleEntities(
scaledEntities,
context.baseVectorStartPoint,
context.baseVectorEndPoint,
scaleVectorEndPoint,
);
// Switch the scaled entities back from the ghost helper entities to the real entities
addEntities(scaledEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[ScaleAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
[ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
setGhostHelperEntities([]);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
...selectToolStateMachine.implementations.actions,
},
},
);
@@ -0,0 +1,151 @@
import type {Box, Point, Polygon} from '@flatten-js/core';
import {compact} from 'es-toolkit';
import {
EPSILON,
HIGHLIGHT_ENTITY_DISTANCE,
SELECTION_RECTANGLE_COLOR_CONTAINS,
SELECTION_RECTANGLE_COLOR_INTERSECTION,
SELECTION_RECTANGLE_STYLE,
SELECTION_RECTANGLE_WIDTH,
} from '../App.consts';
import {RectangleEntity} from '../entities/RectangleEntity';
import {toast} from 'react-toastify';
import {findClosestEntity} from '../helpers/find-closest-entity';
import {
getActiveLayerId,
getEntities,
getLayers,
getSelectedEntityIds,
isEntitySelected,
setGhostHelperEntities,
setSelectedEntityIds,
} from '../state';
import type {SelectContext} from './select-tool';
import type {MouseClickEvent} from './tool.types';
export function handleFirstSelectionPoint(
context: SelectContext,
event: MouseClickEvent
): SelectContext {
const closestEntityInfo = findClosestEntity(event.worldMouseLocation, getEntities());
// Mouse is close to entity and is not dragging a rectangle
if (closestEntityInfo && closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) {
// Select the entity close to the mouse
const closestEntity = closestEntityInfo.entity;
if (!event.holdingCtrl && !event.holdingShift) {
setSelectedEntityIds([closestEntity.id]);
} else if (event.holdingCtrl) {
// ctrl => toggle selection
if (isEntitySelected(closestEntity)) {
// Remove the entity from the selection
setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id));
} else {
// Add the entity to the selection
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
}
} else {
// shift => add to selection
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
}
return {
...context,
startPoint: null,
};
}
// No elements are close to the mouse and no selection dragging is in progress
// Start a new selection rectangle drag
return {
...context,
startPoint: event.worldMouseLocation,
};
}
export function selectEntitiesInsideRectangle(
startPoint: Point,
endPoint: Point,
holdingCtrl: boolean
// holdingShift: boolean, // TODO implement add to selection using shift
): void {
// Finish the selection
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint);
const newSelectedEntityIds: string[] = compact(
getEntities().map((entity): string | null => {
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);
return null;
}
if (intersectionSelection) {
// Select all entities that are inside the selection rectangle or intersect with the selection rectangle
if (
entity.intersectsWithBox(activeSelectionRectangle.getBoundingBox() as Box) ||
entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)
) {
if (holdingCtrl) {
if (isEntitySelected(entity)) {
return null;
}
if (!layer.isLocked) {
return entity.id;
}
}
if (!layer.isLocked) {
return entity.id;
}
}
} else {
// Select only entities that are completely inside the selection rectangle
if (entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)) {
if (holdingCtrl) {
if (isEntitySelected(entity)) {
return null;
}
if (!layer.isLocked) {
return entity.id;
}
}
if (!layer.isLocked) {
return entity.id;
}
}
}
return null;
})
);
setSelectedEntityIds(newSelectedEntityIds);
}
export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) {
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
const isIntersectionSelection: boolean = getIsIntersectionSelection(
activeSelectionRectangle,
startPoint
);
activeSelectionRectangle.lineColor = isIntersectionSelection
? SELECTION_RECTANGLE_COLOR_INTERSECTION
: SELECTION_RECTANGLE_COLOR_CONTAINS;
activeSelectionRectangle.lineWidth = SELECTION_RECTANGLE_WIDTH;
activeSelectionRectangle.lineDash = SELECTION_RECTANGLE_STYLE;
setGhostHelperEntities([activeSelectionRectangle]);
}
/**
* Selections to the left of the start point are intersection selections (green), and everything intersecting with the selection rectangle will be selected
* Selections to the right of the start point are normal selections (blue), and only the entities fully inside the selection rectangle will be selected
*/
export function getIsIntersectionSelection(
rectangleEntity: RectangleEntity,
startPoint: Point
): boolean {
if (!rectangleEntity.getShape() || !startPoint) {
return false;
}
const selectionRectangleMinX = Math.min(
...(rectangleEntity.getShape() as Polygon).vertices.map((v) => v.x)
);
return Math.abs(startPoint.x - selectionRectangleMinX) > EPSILON;
}
@@ -0,0 +1,180 @@
import type {Point} from '@flatten-js/core';
import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {Tool} from '../tools';
import {assign, createMachine} from 'xstate';
import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers';
export interface SelectContext extends ToolContext {
startPoint: Point | null;
}
export enum SelectState {
INIT = 'INIT',
WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT',
SELECTION_COMPLETED = 'SELECTION_COMPLETED',
}
export enum SelectAction {
INIT_SELECT_TOOL = 'INIT_SELECT_TOOL',
HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT',
SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE',
DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE',
DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES',
}
export const selectToolStateMachine = createMachine(
{
types: {} as {
context: SelectContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.SELECT,
},
initial: SelectState.INIT,
states: {
[SelectState.INIT]: {
description: 'Initializing the select tool',
always: {
actions: SelectAction.INIT_SELECT_TOOL,
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
},
},
[SelectState.WAITING_FOR_FIRST_SELECT_POINT]: {
description:
'Select a line or select the first point of a selection rectangle',
meta: {
instructions: 'Select a line or start drawing a selection rectangle',
},
on: {
MOUSE_CLICK: {
actions: SelectAction.HANDLE_FIRST_SELECT_POINT,
target: SelectState.CHECK_SELECTION,
},
ESC: {
actions: SelectAction.INIT_SELECT_TOOL,
},
ENTER: {
target: SelectState.SELECTION_COMPLETED,
},
DELETE: {
actions: SelectAction.DELETE_SELECTED_ENTITIES,
target: SelectState.INIT,
},
},
},
[SelectState.CHECK_SELECTION]: {
description:
'Checking to select one line or start drawing a selection rectangle',
meta: {
instructions:
'Select one line or start drawing a selection rectangle',
},
always: [
{
// User started drawing a selection rectangle
guard: ({ context }: { context: SelectContext }) =>
!!context.startPoint,
target: SelectState.WAITING_FOR_SECOND_SELECT_POINT,
},
{
// User clicked on an entity
guard: ({ context }: { context: SelectContext }) =>
!context.startPoint,
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
},
],
},
[SelectState.WAITING_FOR_SECOND_SELECT_POINT]: {
description: 'Select the second point of a selection rectangle',
meta: {
instructions: 'Select the second point of a selection rectangle',
},
on: {
DRAW: {
actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE,
},
MOUSE_CLICK: {
actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE,
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
},
ESC: {
target: SelectState.INIT,
},
},
},
[SelectState.SELECTION_COMPLETED]: {
description: 'Selection completed',
type: 'final',
},
},
},
{
actions: {
INIT_SELECT_TOOL: () => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
},
HANDLE_FIRST_SELECT_POINT: assign(
({ context, event }: { context: SelectContext; event: StateEvent }) => {
return handleFirstSelectionPoint(context, event as MouseClickEvent);
},
),
DRAW_TEMP_SELECTION_RECTANGLE: ({
context,
event,
}: {
context: SelectContext;
event: StateEvent;
}) => {
if (!context.startPoint) {
// assert
throw new Error(
'[SELECT] Calling drawTempSelectionRectangle without startPoint set',
);
}
drawTempSelectionRectangle(
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation(),
);
},
SELECT_ENTITIES_INSIDE_RECTANGLE: ({
context,
event,
}: {
context: SelectContext;
event: StateEvent;
}) => {
if (!context.startPoint) {
//
throw new Error(
'[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point',
);
}
selectEntitiesInsideRectangle(
context.startPoint,
(event as MouseClickEvent).worldMouseLocation,
(event as MouseClickEvent).holdingCtrl,
// (event as MouseClickEvent).holdingShift,
);
setGhostHelperEntities([]);
},
DELETE_SELECTED_ENTITIES: () => {
setEntities(getNotSelectedEntities(), true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
},
RESET_SELECTION: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
};
}),
},
},
);
@@ -0,0 +1,75 @@
import type {StateMachine} from 'xstate'; /* eslint-disable @typescript-eslint/no-explicit-any */
import {Tool} from '../tools';
import {alignBottomToolStateMachine} from './align-bottom-tool.ts';
import {alignCenterHorizontalToolStateMachine} from './align-center-horizontal-tool.ts';
import {alignLeftToolStateMachine} from './align-left-tool.ts';
import {alignCenterVerticalToolStateMachine} from './align-middle-vertical-tool.ts';
import {alignRightToolStateMachine} from './align-right-tool.ts';
import {alignTopToolStateMachine} from './align-top-tool.ts';
import {arrayToolStateMachine} from './array-tool.ts';
import {circleToolStateMachine} from './circle-tool';
import {copyToolStateMachine} from './copy-tool.ts';
import {eraserToolStateMachine} from './eraser-tool';
import {imageImportToolStateMachine} from './image-import-tool';
import {lineToolStateMachine} from './line-tool';
import {measurementToolStateMachine} from './measurement-tool';
import {moveToolStateMachine} from './move-tool';
import {rectangleToolStateMachine} from './rectangle-tool';
import {rotateToolStateMachine} from './rotate-tool';
import {scaleToolStateMachine} from './scale-tool';
import {selectToolStateMachine} from './select-tool';
import {peditToolStateMachine} from "./pedit-tool.ts";
export const TOOL_STATE_MACHINES: Record<
Partial<Tool>,
StateMachine<
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
any
>
> = {
[Tool.LINE]: lineToolStateMachine,
[Tool.RECTANGLE]: rectangleToolStateMachine,
[Tool.CIRCLE]: circleToolStateMachine,
[Tool.SELECT]: selectToolStateMachine,
[Tool.ERASER]: eraserToolStateMachine,
[Tool.MOVE]: moveToolStateMachine,
[Tool.COPY]: copyToolStateMachine,
[Tool.SCALE]: scaleToolStateMachine,
[Tool.ROTATE]: rotateToolStateMachine,
[Tool.IMAGE_IMPORT]: imageImportToolStateMachine,
[Tool.MEASUREMENT]: measurementToolStateMachine,
[Tool.ALIGN_LEFT]: alignLeftToolStateMachine,
[Tool.ALIGN_CENTER_HORIZONTAL]: alignCenterHorizontalToolStateMachine,
[Tool.ALIGN_RIGHT]: alignRightToolStateMachine,
[Tool.ALIGN_TOP]: alignTopToolStateMachine,
[Tool.ALIGN_CENTER_VERTICAL]: alignCenterVerticalToolStateMachine,
[Tool.ALIGN_BOTTOM]: alignBottomToolStateMachine,
[Tool.ARRAY]: arrayToolStateMachine,
[Tool.PEDIT]: peditToolStateMachine,
};
@@ -0,0 +1,117 @@
import type { Point } from '@flatten-js/core';
import type { Tool } from '../tools';
import type { EventObject } from 'xstate';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
export enum ActionType {
Click = 'Click',
TypedCommand = 'TypedCommand',
ActivateTool = 'ActivateTool',
}
export interface ClickEvent {
worldMouseLocation: Point;
holdingCtrl: boolean;
holdingShift: boolean;
}
export interface TypedCommandEvent {
text: string;
}
export interface ToolHandler {
handleToolActivate(): void;
handleToolClick(
worldMouseLocation: Point,
holdingCtrl: boolean,
holdingShift: boolean,
): void;
handleToolTypedCommand(command: string): void;
}
export enum ActorEvent {
MOUSE_CLICK = 'MOUSE_CLICK',
ESC = 'ESC',
ENTER = 'ENTER',
DELETE = 'DELETE',
DRAW = 'DRAW',
FILE_SELECTED = 'FILE_SELECTED',
NUMBER_INPUT = 'NUMBER_INPUT',
TEXT_INPUT = 'TEXT_INPUT',
ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT',
RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT',
}
export interface MouseClickEvent extends EventObject {
type: ActorEvent.MOUSE_CLICK;
worldMouseLocation: Point;
screenMouseLocation: Point;
holdingCtrl: boolean;
holdingShift: boolean;
}
export interface KeyboardEscEvent extends EventObject {
type: ActorEvent.ESC;
}
export interface KeyboardEnterEvent extends EventObject {
type: ActorEvent.ENTER;
}
export interface KeyboardDeleteEvent extends EventObject {
type: ActorEvent.DELETE;
}
export interface NumberInputEvent extends EventObject {
type: ActorEvent.NUMBER_INPUT;
value: number;
worldMouseLocation: Point;
}
export interface TextInputEvent extends EventObject {
type: ActorEvent.TEXT_INPUT;
value: string;
}
export interface AbsolutePointInputEvent extends EventObject {
type: ActorEvent.ABSOLUTE_POINT_INPUT;
value: Point;
}
export interface RelativePointInputEvent extends EventObject {
type: ActorEvent.RELATIVE_POINT_INPUT;
value: Point;
}
export interface FileSelectedEvent extends EventObject {
type: ActorEvent.FILE_SELECTED;
image: HTMLImageElement;
}
export interface DrawEvent extends EventObject {
type: ActorEvent.DRAW;
drawController: ScreenCanvasDrawController;
}
export type PointInputEvent =
| DrawEvent
| MouseClickEvent
| NumberInputEvent
| AbsolutePointInputEvent
| RelativePointInputEvent;
export type StateEvent =
| MouseClickEvent
| KeyboardEscEvent
| KeyboardEnterEvent
| KeyboardDeleteEvent
| NumberInputEvent
| TextInputEvent
| AbsolutePointInputEvent
| RelativePointInputEvent
| FileSelectedEvent
| DrawEvent;
export interface ToolContext {
type: Tool;
}
@@ -0,0 +1,25 @@
import {TOOLBAR_WIDTH} from '../../src/App.consts';
import {MouseButton} from '../../src/App.types';
import type {InputController} from '../../src/inputController/input-controller';
/**
* Trigger a click event on the canvas
* @param inputController
* @param x x-coordinate relative to the left of the draw area excluding the toolbar
* @param y y-coordinate relative to the top of the draw area
* @param mouseButton
*/
export function click(
inputController: InputController,
x: number,
y: number,
mouseButton: MouseButton = MouseButton.Left
) {
inputController.handleMouseUp({
button: mouseButton,
clientX: TOOLBAR_WIDTH + x, // Coordinates are relative to the top left of the draw area excluding the toolbar
clientY: y,
preventDefault: () => {},
stopPropagation: () => {},
} as MouseEvent);
}
@@ -0,0 +1,25 @@
import {Point} from '@flatten-js/core';
import {Actor} from 'xstate';
import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenCanvas.drawController';
import {InputController} from '../../src/inputController/input-controller';
import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state';
import {Tool} from '../../src/tools';
import {TOOL_STATE_MACHINES} from '../../src/tools/tool.consts';
import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController';
import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts';
export function initApplication(): InputController {
const inputController = new InputController();
setInputController(inputController);
setEntities([], true); // Creates the first undo entry
const canvasSize = new Point(CANVAS_WIDTH, CANVAS_HEIGHT);
const lineToolActor = new Actor(TOOL_STATE_MACHINES[Tool.LINE]);
lineToolActor.start();
setActiveToolActor(lineToolActor);
setScreenCanvasDrawController(
new ScreenCanvasDrawControllerMock(null, canvasSize) as unknown as ScreenCanvasDrawController
);
return inputController;
}
@@ -0,0 +1,76 @@
import type {InputController} from '../../src/inputController/input-controller';
import {Tool} from '../../src/tools';
import {click} from './click';
import type {Recording, Step} from './replay-recording.types';
import {setActiveTool} from './set-active-tool';
const DATA_ID_TO_TOOL_NAME: Record<string, Tool | null> = {
'select-button': Tool.SELECT,
'line-button': Tool.LINE,
'rectangle-button': Tool.RECTANGLE,
'circle-button': Tool.CIRCLE,
'move-button': Tool.MOVE,
'scale-button': Tool.SCALE,
'rotate-button': Tool.ROTATE,
'measurement-button': Tool.MEASUREMENT,
'undo-button': null,
'redo-button': null,
'delete-segment-button': Tool.ERASER,
'line-color-button': null,
'line-width-button': null,
'angle-guide-button': null,
'angle-guide-5-button': null,
'angle-guide-15-button': null,
'angle-guide-30-button': null,
'angle-guide-45-button': null,
'angle-guide-90-button': null,
'zoom-level-button': null,
'import-image-button': null,
'json-open-button': null,
'json-save-button': null,
'svg-export-button': null,
'png-export-button': null,
'pdf-export-button': null,
'github-link-button': null,
};
function handleClick(inputController: InputController, step: Step) {
const firstSelector = step.selectors[0][0];
if (firstSelector.startsWith('[data-id')) {
// clicked a button or the canvas
const dataId = step.selectors[0][0].split("'")[1];
if (dataId === 'canvas') {
click(inputController, step.offsetX, step.offsetY);
} else if (DATA_ID_TO_TOOL_NAME[dataId]) {
setActiveTool(DATA_ID_TO_TOOL_NAME[dataId]);
} else {
console.error('Failed to replay step: ', step);
}
} else {
console.error('Failed to replay step without data id: ', step);
}
}
function handleKeyUp(inputController: InputController, step: Step) {
inputController.handleKeyStroke({
key: step.key,
preventDefault: () => {},
stopPropagation: () => {},
ctrlKey: false,
shiftKey: false,
} as KeyboardEvent);
}
export function replayRecording(inputController: InputController, recording: Recording) {
for (const step of recording.steps) {
switch (step.type) {
case 'click':
handleClick(inputController, step);
break;
case 'keyUp':
handleKeyUp(inputController, step);
break;
}
}
}
@@ -0,0 +1,28 @@
export interface Recording {
title: string;
selectorAttribute: string;
steps: Step[];
}
export interface Step {
type: string;
width?: number;
height?: number;
deviceScaleFactor?: number;
isMobile?: boolean;
hasTouch?: boolean;
isLandscape?: boolean;
url?: string;
assertedEvents?: AssertedEvent[];
target?: string;
selectors?: string[][];
offsetY?: number;
offsetX?: number;
key?: string;
}
export interface AssertedEvent {
type: string;
url: string;
title: string;
}
@@ -0,0 +1,11 @@
import type { Tool } from '../../src/tools';
import { getActiveToolActor, setActiveToolActor } from '../../src/state';
import { Actor } from 'xstate';
import { TOOL_STATE_MACHINES } from '../../src/tools/tool.consts';
export function setActiveTool(toolName: Tool) {
getActiveToolActor()?.stop();
const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]);
setActiveToolActor(newToolActor);
}
@@ -0,0 +1,4 @@
import {TOOLBAR_WIDTH} from "../../src/App.consts";
export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH;
export const CANVAS_HEIGHT = 1080;
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2292" height="2025" viewBox="0 0 2292 2025">
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 13.148438 2012.207031 L 2279.820312 2012.207031 L 2279.820312 12.21875 L 13.148438 12.21875 L 13.148438 2012.207031 "/>
<path fill="none" stroke-width="239.998" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 125.390625 128.867188 L 22792.109375 128.867188 L 22792.109375 20128.789062 L 125.390625 20128.789062 Z M 125.390625 128.867188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="119.999" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13484.414062 8130.15625 L 9484.414062 8130.15625 L 9484.414062 12130.195312 L 13484.414062 12130.195312 L 13484.414062 9489.53125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 1614.410156 1205.984375 L 1887.230469 1205.984375 L 1887.230469 1617.980469 L 1475.230469 1617.980469 L 1475.230469 1211.984375 L 1487.230469 1211.984375 L 1487.230469 1605.980469 L 1875.230469 1605.980469 L 1875.230469 1217.984375 L 1614.410156 1217.984375 L 1614.410156 1205.984375 "/>
<path fill="none" stroke-width="119.999" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 18788.789062 13457.5 L 14807.1875 13457.5 M 14807.1875 14807.109375 L 14807.1875 17452.890625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 815.246094 945.980469 L 940.039062 945.980469 C 940.039062 872.339844 884.167969 812.648438 815.246094 812.648438 L 815.246094 945.980469 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 815.246094 945.980469 L 822.265625 945.980469 L 822.265625 812.648438 L 815.246094 812.648438 L 815.246094 945.980469 "/>
<path fill="none" stroke-width="4.9252" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 8304.200575 10628.116535 L 9535.792387 10628.116535 C 9535.792387 11353.384327 8984.376587 11941.409136 8304.200575 11941.409136 Z M 8304.200575 10628.116535 " transform="matrix(0.0984752, 0, 0, -0.101525, 0, 2025)"/>
<path fill="none" stroke-width="4.9252" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 8304.200575 10628.116535 L 8373.459764 10628.116535 L 8373.459764 11941.409136 L 8304.200575 11941.409136 Z M 8304.200575 10628.116535 " transform="matrix(0.0984752, 0, 0, -0.101525, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1339.558594 1615.925781 L 1347.210938 1615.925781 L 1347.210938 1478.144531 L 1339.558594 1478.144531 L 1339.558594 1615.925781 "/>
<path fill="none" stroke-width="4.85005" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13873.41741 3942.32005 L 12551.4082 3942.32005 C 12551.4082 4680.392078 13143.299075 5278.730552 13873.41741 5278.730552 Z M 13873.41741 3942.32005 " transform="matrix(0.0969021, 0, 0, -0.103098, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13367.8125 4064.453125 L 13444.21875 4064.453125 L 13444.21875 5438.554688 L 13367.8125 5438.554688 Z M 13367.8125 4064.453125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1880.78125 1204.261719 L 1880.78125 1077.589844 C 1803.929688 1077.589844 1741.589844 1134.304688 1741.589844 1204.261719 L 1880.78125 1204.261719 "/>
<path fill="none" stroke-width="4.85005" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M -8521.325905 18242.692935 L -9752.433126 18242.692935 C -9752.433126 17497.19472 -9201.256668 16892.718275 -8521.366217 16892.718275 Z M -8521.325905 18242.692935 " transform="matrix(0, 0.0969021, 0.103098, 0, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1741.589844 1204.261719 L 1880.769531 1204.261719 L 1880.769531 1197.136719 L 1741.589844 1197.136719 L 1741.589844 1204.261719 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17415.898438 8207.382812 L 18807.695312 8207.382812 L 18807.695312 8278.632812 L 17415.898438 8278.632812 Z M 17415.898438 8207.382812 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1036.21875 1232.300781 L 1290.21875 1232.300781 L 1290.21875 1280.230469 C 1290.21875 1289.617188 1282.539062 1297.296875 1273.148438 1297.296875 L 1053.289062 1297.296875 C 1043.898438 1297.296875 1036.21875 1289.617188 1036.21875 1280.230469 L 1036.21875 1232.300781 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 10362.1875 7926.992188 L 12902.1875 7926.992188 L 12902.1875 7447.695312 C 12902.1875 7353.828125 12825.390625 7277.03125 12731.484375 7277.03125 L 10532.890625 7277.03125 C 10438.984375 7277.03125 10362.1875 7353.828125 10362.1875 7447.695312 Z M 10362.1875 7926.992188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1677.199219 1600.976562 L 1870.210938 1600.976562 L 1870.210938 1345.164062 L 1677.199219 1345.164062 L 1677.199219 1600.976562 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16771.992188 4240.234375 L 18702.109375 4240.234375 L 18702.109375 6798.359375 L 16771.992188 6798.359375 Z M 16771.992188 4240.234375 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1708.699219 1545.542969 L 1756.710938 1545.542969 C 1761.101562 1545.542969 1764.710938 1549.144531 1764.710938 1553.542969 L 1764.710938 1582.542969 C 1764.710938 1586.945312 1761.101562 1590.542969 1756.710938 1590.542969 L 1708.699219 1590.542969 C 1704.300781 1590.542969 1700.699219 1586.945312 1700.699219 1582.542969 L 1700.699219 1553.542969 C 1700.699219 1549.144531 1704.300781 1545.542969 1708.699219 1545.542969 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1804.460938 1543.046875 L 1852.460938 1543.046875 C 1856.851562 1543.046875 1860.449219 1546.644531 1860.449219 1551.039062 L 1860.449219 1580.042969 C 1860.449219 1584.441406 1856.851562 1588.042969 1852.460938 1588.042969 L 1804.460938 1588.042969 C 1800.050781 1588.042969 1796.460938 1584.441406 1796.460938 1580.042969 L 1796.460938 1551.039062 C 1796.460938 1546.644531 1800.050781 1543.046875 1804.460938 1543.046875 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17018.28125 4819.570312 L 17498.398438 4819.570312 C 17542.304688 4819.570312 17578.398438 4783.554688 17578.398438 4739.570312 L 17578.398438 4449.570312 C 17578.398438 4405.546875 17542.304688 4369.570312 17498.398438 4369.570312 L 17018.28125 4369.570312 C 16974.21875 4369.570312 16938.28125 4405.546875 16938.28125 4449.570312 L 16938.28125 4739.570312 C 16938.28125 4783.554688 16974.21875 4819.570312 17018.28125 4819.570312 Z M 17018.28125 4819.570312 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17975.78125 4819.53125 L 18455.78125 4819.53125 C 18499.6875 4819.53125 18535.78125 4783.554688 18535.78125 4739.609375 L 18535.78125 4449.570312 C 18535.78125 4405.585938 18499.6875 4369.570312 18455.78125 4369.570312 L 17975.78125 4369.570312 C 17931.796875 4369.570312 17895.898438 4405.585938 17895.898438 4449.570312 L 17895.898438 4739.609375 C 17895.898438 4783.554688 17931.796875 4819.53125 17975.78125 4819.53125 Z M 17975.78125 4819.53125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 963.941406 1194.949219 L 1156.941406 1194.949219 L 1156.941406 939.140625 L 963.941406 939.140625 L 963.941406 1194.949219 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9639.414062 8300.507812 L 11569.414062 8300.507812 L 11569.414062 10858.59375 L 9639.414062 10858.59375 Z M 9639.414062 8300.507812 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 995.441406 1139.515625 L 1043.441406 1139.515625 C 1047.839844 1139.515625 1051.441406 1143.117188 1051.441406 1147.515625 L 1051.441406 1176.515625 C 1051.441406 1180.917969 1047.839844 1184.515625 1043.441406 1184.515625 L 995.441406 1184.515625 C 991.039062 1184.515625 987.441406 1180.917969 987.441406 1176.515625 L 987.441406 1147.515625 C 987.441406 1143.117188 991.039062 1139.515625 995.441406 1139.515625 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1091.191406 1137.019531 L 1139.191406 1137.019531 C 1143.589844 1137.019531 1147.191406 1140.617188 1147.191406 1145.015625 L 1147.191406 1174.019531 C 1147.191406 1178.417969 1143.589844 1182.015625 1139.191406 1182.015625 L 1091.191406 1182.015625 C 1086.789062 1182.015625 1083.191406 1178.417969 1083.191406 1174.019531 L 1083.191406 1145.015625 C 1083.191406 1140.617188 1086.789062 1137.019531 1091.191406 1137.019531 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9885.664062 8879.804688 L 10365.703125 8879.804688 C 10409.6875 8879.804688 10445.703125 8843.828125 10445.703125 8799.84375 L 10445.703125 8509.804688 C 10445.703125 8465.820312 10409.6875 8429.84375 10365.703125 8429.84375 L 9885.664062 8429.84375 C 9841.679688 8429.84375 9805.664062 8465.820312 9805.664062 8509.804688 L 9805.664062 8799.84375 C 9805.664062 8843.828125 9841.679688 8879.804688 9885.664062 8879.804688 Z M 9885.664062 8879.804688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 10843.203125 8879.804688 L 11323.203125 8879.804688 C 11367.1875 8879.804688 11403.203125 8843.828125 11403.203125 8799.84375 L 11403.203125 8509.804688 C 11403.203125 8465.820312 11367.1875 8429.84375 11323.203125 8429.84375 L 10843.203125 8429.84375 C 10799.21875 8429.84375 10763.203125 8465.820312 10763.203125 8509.804688 L 10763.203125 8799.84375 C 10763.203125 8843.828125 10799.21875 8879.804688 10843.203125 8879.804688 Z M 10843.203125 8879.804688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1669.140625 657.390625 L 1862.140625 657.390625 L 1862.140625 401.578125 L 1669.140625 401.578125 L 1669.140625 657.390625 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16691.40625 13676.09375 L 18621.40625 13676.09375 L 18621.40625 16234.21875 L 16691.40625 16234.21875 Z M 16691.40625 13676.09375 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1700.640625 601.960938 L 1748.648438 601.960938 C 1753.039062 601.960938 1756.648438 605.558594 1756.648438 609.960938 L 1756.648438 638.960938 C 1756.648438 643.359375 1753.039062 646.949219 1748.648438 646.949219 L 1700.640625 646.949219 C 1696.25 646.949219 1692.660156 643.359375 1692.660156 638.960938 L 1692.660156 609.960938 C 1692.660156 605.558594 1696.25 601.960938 1700.640625 601.960938 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1796.398438 599.460938 L 1844.390625 599.460938 C 1848.78125 599.460938 1852.390625 603.058594 1852.390625 607.449219 L 1852.390625 636.460938 C 1852.390625 640.859375 1848.78125 644.460938 1844.390625 644.460938 L 1796.398438 644.460938 C 1791.988281 644.460938 1788.398438 640.859375 1788.398438 636.460938 L 1788.398438 607.449219 C 1788.398438 603.058594 1791.988281 599.460938 1796.398438 599.460938 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16937.5 14230.390625 L 17417.617188 14230.390625 C 17461.484375 14230.390625 17497.617188 14194.414062 17497.617188 14150.390625 L 17497.617188 13860.390625 C 17497.617188 13816.40625 17461.484375 13780.507812 17417.617188 13780.507812 L 16937.5 13780.507812 C 16893.59375 13780.507812 16857.695312 13816.40625 16857.695312 13860.390625 L 16857.695312 14150.390625 C 16857.695312 14194.414062 16893.59375 14230.390625 16937.5 14230.390625 Z M 16937.5 14230.390625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17895.117188 14230.390625 L 18375 14230.390625 C 18418.90625 14230.390625 18455 14194.414062 18455 14150.507812 L 18455 13860.507812 C 18455 13816.484375 18418.90625 13780.390625 18375 13780.390625 L 17895.117188 13780.390625 C 17851.015625 13780.390625 17815.117188 13816.484375 17815.117188 13860.507812 L 17815.117188 14150.507812 C 17815.117188 14194.414062 17851.015625 14230.390625 17895.117188 14230.390625 Z M 17895.117188 14230.390625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1346.429688 1201.894531 L 1346.429688 1076.886719 C 1272.800781 1076.886719 1213.101562 1132.855469 1213.101562 1201.894531 L 1346.429688 1201.894531 "/>
<path fill="none" stroke-width="4.9493" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M -8316.758888 13326.912489 L -9560.079115 13326.912489 C -9560.079115 12598.091223 -9003.444559 12007.185366 -8316.758888 12007.185366 Z M -8316.758888 13326.912489 " transform="matrix(0, 0.0989695, 0.10103, 0, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1213.101562 1201.894531 L 1346.429688 1201.894531 L 1346.429688 1194.859375 L 1213.101562 1194.859375 L 1213.101562 1201.894531 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12131.015625 8231.054688 L 13464.296875 8231.054688 L 13464.296875 8301.40625 L 12131.015625 8301.40625 Z M 12131.015625 8231.054688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1491.898438 1212.375 L 1611.660156 1212.375 C 1611.660156 1286.015625 1558.050781 1345.707031 1491.910156 1345.707031 L 1491.898438 1212.375 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14918.984375 8126.25 L 16116.601562 8126.25 C 16116.601562 7389.84375 15580.507812 6792.929688 14919.101562 6792.929688 Z M 14918.984375 8126.25 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1491.910156 1345.710938 L 1498.640625 1345.710938 L 1498.640625 1212.375 L 1491.910156 1212.375 L 1491.910156 1345.710938 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14919.101562 6792.890625 L 14986.40625 6792.890625 L 14986.40625 8126.25 L 14919.101562 8126.25 Z M 14919.101562 6792.890625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1482.730469 670.621094 L 1482.730469 545.621094 C 1556.371094 545.621094 1616.070312 601.578125 1616.070312 670.621094 L 1482.730469 670.621094 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14827.304688 13543.789062 L 14827.304688 14793.789062 C 15563.710938 14793.789062 16160.703125 14234.21875 16160.703125 13543.789062 Z M 14827.304688 13543.789062 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1482.730469 670.621094 L 1616.070312 670.621094 L 1616.070312 663.589844 L 1482.730469 663.589844 L 1482.730469 670.621094 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14827.304688 13543.789062 L 16160.703125 13543.789062 L 16160.703125 13614.101562 L 14827.304688 13614.101562 Z M 14827.304688 13543.789062 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 548.445312 279.710938 L 681.628906 279.710938 L 681.628906 384.398438 C 681.628906 400.070312 668.808594 412.878906 653.144531 412.878906 L 548.445312 412.878906 L 548.445312 279.710938 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 5475.703125 17445.585938 L 6907.5 17445.585938 L 6907.5 16348.59375 C 6907.5 16164.296875 6757.03125 16013.710938 6572.695312 16013.710938 L 5475.703125 16013.710938 Z M 5475.703125 17445.585938 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 5525.703125 17395.585938 L 6857.5 17395.585938 L 6857.5 16348.59375 C 6857.5 16191.796875 6729.335938 16063.710938 6572.695312 16063.710938 L 5525.703125 16063.710938 Z M 5525.703125 17395.585938 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 674.128906 677.980469 L 807.460938 677.980469 C 807.460938 604.339844 747.765625 544.648438 674.128906 544.648438 L 674.128906 677.980469 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 674.128906 677.980469 L 681.628906 677.980469 L 681.628906 544.648438 L 674.128906 544.648438 L 674.128906 677.980469 "/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7084.358058 12987.113819 L 8374.80392 12987.113819 C 8374.80392 13697.598065 7797.078864 14273.493205 7084.358058 14273.493205 Z M 7084.358058 12987.113819 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7084.358058 12987.113819 L 7156.96388 12987.113819 L 7156.96388 14273.493205 L 7084.358058 14273.493205 Z M 7084.358058 12987.113819 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1109.28125 1494.976562 L 1198.28125 1494.976562 L 1198.28125 1343.351562 L 1109.28125 1343.351562 L 1109.28125 1494.976562 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1600.089844 776.410156 L 1755.710938 776.410156 L 1755.710938 705.410156 L 1600.089844 705.410156 L 1600.089844 776.410156 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16000.898438 12485.898438 L 17557.109375 12485.898438 L 17557.109375 13195.898438 L 16000.898438 13195.898438 Z M 16000.898438 12485.898438 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1602.089844 774.410156 L 1753.710938 774.410156 L 1753.710938 707.410156 L 1602.089844 707.410156 L 1602.089844 774.410156 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16020.898438 12505.898438 L 17537.109375 12505.898438 L 17537.109375 13175.898438 L 16020.898438 13175.898438 Z M 16020.898438 12505.898438 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1275.601562 1232.300781 L 1290.21875 1232.300781 L 1290.21875 1278.082031 C 1290.21875 1281.382812 1287.519531 1284.078125 1284.21875 1284.078125 L 1281.601562 1284.078125 C 1278.300781 1284.078125 1275.601562 1281.382812 1275.601562 1278.082031 L 1275.601562 1241.410156 L 1050.839844 1241.410156 L 1050.839844 1278.082031 C 1050.839844 1281.382812 1048.140625 1284.078125 1044.839844 1284.078125 L 1042.21875 1284.078125 C 1038.921875 1284.078125 1036.21875 1281.382812 1036.21875 1278.082031 L 1036.21875 1232.300781 L 1275.601562 1232.300781 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12756.015625 7926.992188 L 12902.1875 7926.992188 L 12902.1875 7469.179688 C 12902.1875 7436.171875 12875.195312 7409.21875 12842.1875 7409.21875 L 12816.015625 7409.21875 C 12783.007812 7409.21875 12756.015625 7436.171875 12756.015625 7469.179688 L 12756.015625 7835.898438 L 10508.398438 7835.898438 L 10508.398438 7469.179688 C 10508.398438 7436.171875 10481.40625 7409.21875 10448.398438 7409.21875 L 10422.1875 7409.21875 C 10389.21875 7409.21875 10362.1875 7436.171875 10362.1875 7469.179688 L 10362.1875 7926.992188 Z M 12756.015625 7926.992188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1201.828125 1297.296875 L 1204.828125 1297.296875 L 1204.828125 1241.410156 L 1201.828125 1241.410156 L 1201.828125 1297.296875 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12018.28125 7277.03125 L 12048.28125 7277.03125 L 12048.28125 7835.898438 L 12018.28125 7835.898438 Z M 12018.28125 7277.03125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1118.671875 1297.296875 L 1121.671875 1297.296875 L 1121.671875 1241.410156 L 1118.671875 1241.410156 L 1118.671875 1297.296875 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 11186.71875 7277.03125 L 11216.71875 7277.03125 L 11216.71875 7835.898438 L 11186.71875 7835.898438 Z M 11186.71875 7277.03125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 959.34375 1538.960938 L 959.34375 1284.960938 L 1007.269531 1284.960938 C 1016.660156 1284.960938 1024.339844 1292.640625 1024.339844 1302.03125 L 1024.339844 1521.890625 C 1024.339844 1531.28125 1016.660156 1538.960938 1007.269531 1538.960938 L 959.34375 1538.960938 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9593.4375 4860.390625 L 9593.4375 7400.390625 L 10072.695312 7400.390625 C 10166.601562 7400.390625 10243.398438 7323.59375 10243.398438 7229.6875 L 10243.398438 5031.09375 C 10243.398438 4937.1875 10166.601562 4860.390625 10072.695312 4860.390625 Z M 9593.4375 4860.390625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 959.34375 1299.582031 L 959.34375 1284.960938 L 1005.121094 1284.960938 C 1008.421875 1284.960938 1011.128906 1287.65625 1011.128906 1290.960938 L 1011.128906 1293.582031 C 1011.128906 1296.878906 1008.421875 1299.582031 1005.121094 1299.582031 L 968.453125 1299.582031 L 968.453125 1524.34375 L 1005.121094 1524.34375 C 1008.421875 1524.34375 1011.128906 1527.039062 1011.128906 1530.339844 L 1011.128906 1532.964844 C 1011.128906 1536.261719 1008.421875 1538.960938 1005.121094 1538.960938 L 959.34375 1538.960938 L 959.34375 1299.582031 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9593.4375 7254.179688 L 9593.4375 7400.390625 L 10051.210938 7400.390625 C 10084.21875 7400.390625 10111.289062 7373.4375 10111.289062 7340.390625 L 10111.289062 7314.179688 C 10111.289062 7281.210938 10084.21875 7254.179688 10051.210938 7254.179688 L 9684.53125 7254.179688 L 9684.53125 5006.5625 L 10051.210938 5006.5625 C 10084.21875 5006.5625 10111.289062 4979.609375 10111.289062 4946.601562 L 10111.289062 4920.351562 C 10111.289062 4887.382812 10084.21875 4860.390625 10051.210938 4860.390625 L 9593.4375 4860.390625 Z M 9593.4375 7254.179688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 968.453125 1373.34375 L 1024.339844 1373.34375 L 1024.339844 1370.34375 L 968.453125 1370.34375 L 968.453125 1373.34375 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9684.53125 6516.5625 L 10243.398438 6516.5625 L 10243.398438 6546.5625 L 9684.53125 6546.5625 Z M 9684.53125 6516.5625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 968.453125 1456.511719 L 1024.339844 1456.511719 L 1024.339844 1453.511719 L 968.453125 1453.511719 L 968.453125 1456.511719 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9684.53125 5684.882812 L 10243.398438 5684.882812 L 10243.398438 5714.882812 L 9684.53125 5714.882812 Z M 9684.53125 5684.882812 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1550.898438 1015.191406 L 1804.898438 1015.191406 L 1804.898438 967.261719 C 1804.898438 957.871094 1797.230469 950.191406 1787.839844 950.191406 L 1567.980469 950.191406 C 1558.589844 950.191406 1550.898438 957.871094 1550.898438 967.261719 L 1550.898438 1015.191406 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15508.984375 10098.085938 L 18048.984375 10098.085938 L 18048.984375 10577.382812 C 18048.984375 10671.289062 17972.304688 10748.085938 17878.398438 10748.085938 L 15679.804688 10748.085938 C 15585.898438 10748.085938 15508.984375 10671.289062 15508.984375 10577.382812 Z M 15508.984375 10098.085938 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1790.289062 1015.191406 L 1804.898438 1015.191406 L 1804.898438 969.398438 C 1804.898438 966.101562 1802.191406 963.398438 1798.898438 963.398438 L 1796.289062 963.398438 C 1792.988281 963.398438 1790.289062 966.101562 1790.289062 969.398438 L 1790.289062 1006.070312 L 1565.53125 1006.070312 L 1565.53125 969.398438 C 1565.53125 966.101562 1562.820312 963.398438 1559.53125 963.398438 L 1556.898438 963.398438 C 1553.609375 963.398438 1550.898438 966.101562 1550.898438 969.398438 L 1550.898438 1015.191406 L 1790.289062 1015.191406 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17902.890625 10098.085938 L 18048.984375 10098.085938 L 18048.984375 10556.015625 C 18048.984375 10588.984375 18021.914062 10616.015625 17988.984375 10616.015625 L 17962.890625 10616.015625 C 17929.882812 10616.015625 17902.890625 10588.984375 17902.890625 10556.015625 L 17902.890625 10189.296875 L 15655.3125 10189.296875 L 15655.3125 10556.015625 C 15655.3125 10588.984375 15628.203125 10616.015625 15595.3125 10616.015625 L 15568.984375 10616.015625 C 15536.09375 10616.015625 15508.984375 10588.984375 15508.984375 10556.015625 L 15508.984375 10098.085938 Z M 17902.890625 10098.085938 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1716.53125 1006.070312 L 1719.53125 1006.070312 L 1719.53125 950.191406 L 1716.53125 950.191406 L 1716.53125 1006.070312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17165.3125 10189.296875 L 17195.3125 10189.296875 L 17195.3125 10748.085938 L 17165.3125 10748.085938 Z M 17165.3125 10189.296875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1633.351562 1006.070312 L 1636.351562 1006.070312 L 1636.351562 950.191406 L 1633.351562 950.191406 L 1633.351562 1006.070312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16333.515625 10189.296875 L 16363.515625 10189.296875 L 16363.515625 10748.085938 L 16333.515625 10748.085938 Z M 16333.515625 10189.296875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1647.890625 721.898438 L 1712.558594 721.898438 C 1715.871094 721.898438 1718.558594 724.609375 1718.558594 727.898438 L 1718.558594 729.238281 C 1718.558594 732.539062 1715.871094 735.238281 1712.558594 735.238281 L 1647.890625 735.238281 C 1644.601562 735.238281 1641.890625 732.539062 1641.890625 729.238281 L 1641.890625 727.898438 C 1641.890625 724.609375 1644.601562 721.898438 1647.890625 721.898438 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16478.90625 13031.015625 L 17125.585938 13031.015625 C 17158.710938 13031.015625 17185.585938 13003.90625 17185.585938 12971.015625 L 17185.585938 12957.617188 C 17185.585938 12924.609375 17158.710938 12897.617188 17125.585938 12897.617188 L 16478.90625 12897.617188 C 16446.015625 12897.617188 16418.90625 12924.609375 16418.90625 12957.617188 L 16418.90625 12971.015625 C 16418.90625 13003.90625 16446.015625 13031.015625 16478.90625 13031.015625 Z M 16478.90625 13031.015625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1613.398438 736.570312 L 1626.558594 736.570312 L 1626.558594 723.398438 L 1613.398438 723.398438 L 1613.398438 736.570312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16133.984375 12884.296875 L 16265.585938 12884.296875 L 16265.585938 13016.015625 L 16133.984375 13016.015625 Z M 16133.984375 12884.296875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1733.320312 736.570312 L 1746.488281 736.570312 L 1746.488281 723.398438 L 1733.320312 723.398438 L 1733.320312 736.570312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17333.203125 12884.296875 L 17464.882812 12884.296875 L 17464.882812 13016.015625 L 17333.203125 13016.015625 Z M 17333.203125 12884.296875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1113.03125 1488.585938 L 1194.53125 1488.585938 L 1194.53125 1349.742188 L 1113.03125 1349.742188 L 1113.03125 1488.585938 "/>
<path fill="none" stroke-width="5.4269" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M -6335.650494 11651.083993 L -7298.308035 11651.083993 L -7298.308035 10005.307223 L -6335.650494 10005.307223 Z M -6335.650494 11651.083993 " transform="matrix(0, 0.0924524, 0.107548, 0, 0, 2025)"/>
<path fill="none" stroke-width="5.4269" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7257.746622 -11581.710899 L 6376.211907 -11581.710899 L 6376.211907 -10074.716638 L 7257.746622 -10074.716638 Z M 7257.746622 -11581.710899 " transform="matrix(0, -0.0924524, -0.107548, 0, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 963.941406 1113.410156 L 1156.941406 1113.410156 L 1156.941406 939.140625 L 963.941406 939.140625 L 963.941406 1113.410156 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 9639.414062 9115.898438 L 11569.414062 9115.898438 L 11569.414062 10858.59375 L 9639.414062 10858.59375 Z M 9639.414062 9115.898438 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1677.199219 1502.742188 L 1870.210938 1502.742188 L 1870.210938 1345.164062 L 1677.199219 1345.164062 L 1677.199219 1502.742188 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16771.992188 5222.578125 L 18702.109375 5222.578125 L 18702.109375 6798.359375 L 16771.992188 6798.359375 Z M 16771.992188 5222.578125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1669.140625 574.738281 L 1862.140625 574.738281 L 1862.140625 401.578125 L 1669.140625 401.578125 L 1669.140625 574.738281 "/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 16691.40625 14502.617188 L 18621.40625 14502.617188 L 18621.40625 16234.21875 L 16691.40625 16234.21875 Z M 16691.40625 14502.617188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1409.359375 529.140625 C 1409.359375 533.75 1405.589844 537.519531 1400.980469 537.519531 L 1344.730469 537.519531 C 1340.121094 537.519531 1336.359375 533.75 1336.359375 529.140625 L 1336.359375 379.011719 C 1336.359375 374.398438 1340.121094 370.640625 1344.730469 370.640625 L 1400.980469 370.640625 C 1405.589844 370.640625 1409.359375 374.398438 1409.359375 379.011719 L 1409.359375 529.140625 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14093.59375 14958.59375 C 14093.59375 14912.5 14055.898438 14874.804688 14009.804688 14874.804688 L 13447.304688 14874.804688 C 13401.210938 14874.804688 13363.59375 14912.5 13363.59375 14958.59375 L 13363.59375 16459.882812 C 13363.59375 16506.015625 13401.210938 16543.59375 13447.304688 16543.59375 L 14009.804688 16543.59375 C 14055.898438 16543.59375 14093.59375 16506.015625 14093.59375 16459.882812 Z M 14093.59375 14958.59375 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1407.359375 529.140625 L 1407.359375 379.011719 C 1407.359375 375.511719 1404.488281 372.640625 1400.980469 372.640625 L 1344.730469 372.640625 C 1341.230469 372.640625 1338.359375 375.511719 1338.359375 379.011719 L 1338.359375 529.140625 C 1338.359375 532.648438 1341.230469 535.511719 1344.730469 535.511719 L 1400.980469 535.511719 C 1404.488281 535.511719 1407.359375 532.648438 1407.359375 529.140625 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14073.59375 14958.59375 L 14073.59375 16459.882812 C 14073.59375 16494.882812 14044.882812 16523.59375 14009.804688 16523.59375 L 13447.304688 16523.59375 C 13412.304688 16523.59375 13383.59375 16494.882812 13383.59375 16459.882812 L 13383.59375 14958.59375 C 13383.59375 14923.515625 13412.304688 14894.882812 13447.304688 14894.882812 L 14009.804688 14894.882812 C 14044.882812 14894.882812 14073.59375 14923.515625 14073.59375 14958.59375 Z M 14073.59375 14958.59375 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1465.300781 522.820312 L 1465.300781 480.339844 L 1435.550781 480.339844 C 1429.730469 480.339844 1424.960938 485.109375 1424.960938 490.929688 L 1424.960938 512.21875 C 1424.960938 518.050781 1429.730469 522.820312 1435.550781 522.820312 L 1465.300781 522.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14653.007812 15021.796875 L 14653.007812 15446.601562 L 14355.507812 15446.601562 C 14297.304688 15446.601562 14249.609375 15398.90625 14249.609375 15340.703125 L 14249.609375 15127.8125 C 14249.609375 15069.492188 14297.304688 15021.796875 14355.507812 15021.796875 Z M 14653.007812 15021.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1465.300781 522.820312 L 1465.300781 480.339844 L 1460.21875 480.339844 C 1459.230469 480.339844 1458.421875 481.148438 1458.421875 482.148438 L 1458.421875 521.011719 C 1458.421875 522 1459.230469 522.820312 1460.21875 522.820312 L 1465.300781 522.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14653.007812 15021.796875 L 14653.007812 15446.601562 L 14602.1875 15446.601562 C 14592.304688 15446.601562 14584.21875 15438.515625 14584.21875 15428.515625 L 14584.21875 15039.882812 C 14584.21875 15030 14592.304688 15021.796875 14602.1875 15021.796875 Z M 14653.007812 15021.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1465.300781 427.820312 L 1465.300781 385.339844 L 1435.550781 385.339844 C 1429.730469 385.339844 1424.960938 390.101562 1424.960938 395.929688 L 1424.960938 417.230469 C 1424.960938 423.058594 1429.730469 427.820312 1435.550781 427.820312 L 1465.300781 427.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14653.007812 15971.796875 L 14653.007812 16396.601562 L 14355.507812 16396.601562 C 14297.304688 16396.601562 14249.609375 16348.984375 14249.609375 16290.703125 L 14249.609375 16077.695312 C 14249.609375 16019.414062 14297.304688 15971.796875 14355.507812 15971.796875 Z M 14653.007812 15971.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1465.300781 427.820312 L 1465.300781 385.339844 L 1460.21875 385.339844 C 1459.230469 385.339844 1458.421875 386.148438 1458.421875 387.148438 L 1458.421875 426.011719 C 1458.421875 427.011719 1459.230469 427.820312 1460.21875 427.820312 L 1465.300781 427.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 14653.007812 15971.796875 L 14653.007812 16396.601562 L 14602.1875 16396.601562 C 14592.304688 16396.601562 14584.21875 16388.515625 14584.21875 16378.515625 L 14584.21875 15989.882812 C 14584.21875 15979.882812 14592.304688 15971.796875 14602.1875 15971.796875 Z M 14653.007812 15971.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1280.421875 522.820312 L 1280.421875 480.339844 L 1310.160156 480.339844 C 1315.988281 480.339844 1320.75 485.109375 1320.75 490.929688 L 1320.75 512.21875 C 1320.75 518.050781 1315.988281 522.820312 1310.160156 522.820312 L 1280.421875 522.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12804.21875 15021.796875 L 12804.21875 15446.601562 L 13101.601562 15446.601562 C 13159.882812 15446.601562 13207.5 15398.90625 13207.5 15340.703125 L 13207.5 15127.8125 C 13207.5 15069.492188 13159.882812 15021.796875 13101.601562 15021.796875 Z M 12804.21875 15021.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1280.421875 522.820312 L 1280.421875 480.339844 L 1285.488281 480.339844 C 1286.480469 480.339844 1287.300781 481.148438 1287.300781 482.148438 L 1287.300781 521.011719 C 1287.300781 522 1286.480469 522.820312 1285.488281 522.820312 L 1280.421875 522.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12804.21875 15021.796875 L 12804.21875 15446.601562 L 12854.882812 15446.601562 C 12864.804688 15446.601562 12873.007812 15438.515625 12873.007812 15428.515625 L 12873.007812 15039.882812 C 12873.007812 15030 12864.804688 15021.796875 12854.882812 15021.796875 Z M 12804.21875 15021.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1280.421875 427.820312 L 1280.421875 385.339844 L 1310.160156 385.339844 C 1315.988281 385.339844 1320.75 390.101562 1320.75 395.929688 L 1320.75 417.230469 C 1320.75 423.058594 1315.988281 427.820312 1310.160156 427.820312 L 1280.421875 427.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12804.21875 15971.796875 L 12804.21875 16396.601562 L 13101.601562 16396.601562 C 13159.882812 16396.601562 13207.5 16348.984375 13207.5 16290.703125 L 13207.5 16077.695312 C 13207.5 16019.414062 13159.882812 15971.796875 13101.601562 15971.796875 Z M 12804.21875 15971.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1280.421875 427.820312 L 1280.421875 385.339844 L 1285.488281 385.339844 C 1286.480469 385.339844 1287.300781 386.148438 1287.300781 387.148438 L 1287.300781 426.011719 C 1287.300781 427.011719 1286.480469 427.820312 1285.488281 427.820312 L 1280.421875 427.820312 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12804.21875 15971.796875 L 12804.21875 16396.601562 L 12854.882812 16396.601562 C 12864.804688 16396.601562 12873.007812 16388.515625 12873.007812 16378.515625 L 12873.007812 15989.882812 C 12873.007812 15979.882812 12864.804688 15971.796875 12854.882812 15971.796875 Z M 12804.21875 15971.796875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1513.390625 745.359375 L 1598.75 745.359375 L 1598.75 705.410156 L 1513.390625 705.410156 L 1513.390625 745.359375 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15133.90625 12796.40625 L 15987.5 12796.40625 L 15987.5 13195.898438 L 15133.90625 13195.898438 Z M 15133.90625 12796.40625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1515.390625 743.359375 L 1596.75 743.359375 L 1596.75 707.398438 L 1515.390625 707.398438 L 1515.390625 743.359375 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15153.90625 12816.40625 L 15967.5 12816.40625 L 15967.5 13176.015625 L 15153.90625 13176.015625 Z M 15153.90625 12816.40625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1757.039062 745.359375 L 1842.398438 745.359375 L 1842.398438 705.410156 L 1757.039062 705.410156 L 1757.039062 745.359375 "/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 1759.039062 743.359375 L 1840.398438 743.359375 L 1840.398438 707.398438 L 1759.039062 707.398438 L 1759.039062 743.359375 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17570.390625 12796.40625 L 18423.984375 12796.40625 L 18423.984375 13195.898438 L 17570.390625 13195.898438 Z M 17570.390625 12796.40625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 17590.390625 12816.40625 L 18403.984375 12816.40625 L 18403.984375 13176.015625 L 17590.390625 13176.015625 Z M 17590.390625 12816.40625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 775.921875 316.128906 C 781.371094 324.878906 783.886719 335.308594 783.886719 345.480469 C 783.886719 355.71875 781.335938 366.25 775.800781 375.039062 C 771.078125 382.53125 764 388.898438 755.023438 391.089844 C 752.878906 391.601562 750.679688 391.871094 748.46875 391.871094 C 746.257812 391.871094 744.058594 391.601562 741.914062 391.089844 C 732.9375 388.898438 725.855469 382.53125 721.136719 375.039062 C 715.601562 366.25 713.050781 355.71875 713.050781 345.480469 C 713.050781 335.308594 715.5625 324.878906 721.011719 316.128906 L 721.789062 314.878906 L 775.148438 314.878906 L 775.921875 316.128906 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7759.21875 17088.710938 C 7813.710938 17001.210938 7838.867188 16896.914062 7838.867188 16795.195312 C 7838.867188 16692.8125 7813.359375 16587.5 7758.007812 16499.609375 C 7710.78125 16424.6875 7640 16361.015625 7550.234375 16339.101562 C 7528.789062 16333.984375 7506.796875 16331.289062 7484.6875 16331.289062 C 7462.578125 16331.289062 7440.585938 16333.984375 7419.140625 16339.101562 C 7329.375 16361.015625 7258.554688 16424.6875 7211.367188 16499.609375 C 7156.015625 16587.5 7130.507812 16692.8125 7130.507812 16795.195312 C 7130.507812 16896.914062 7155.625 17001.210938 7210.117188 17088.710938 L 7217.890625 17101.210938 L 7751.484375 17101.210938 Z M 7759.21875 17088.710938 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 767.746094 323.578125 C 771.371094 329.578125 773.550781 337.328125 773.550781 345.78125 C 773.550781 364.949219 762.320312 380.5 748.46875 380.5 C 734.617188 380.5 723.386719 364.949219 723.386719 345.78125 C 723.386719 337.328125 725.5625 329.578125 729.1875 323.578125 L 767.746094 323.578125 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7677.460938 17014.21875 C 7713.710938 16954.21875 7735.507812 16876.71875 7735.507812 16792.1875 C 7735.507812 16600.507812 7623.203125 16445 7484.6875 16445 C 7346.171875 16445 7233.867188 16600.507812 7233.867188 16792.1875 C 7233.867188 16876.71875 7255.625 16954.21875 7291.875 17014.21875 Z M 7677.460938 17014.21875 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 710.96875 298.210938 L 784.296875 298.210938 C 786.3125 298.210938 787.964844 299.851562 787.964844 301.859375 L 787.964844 311.199219 C 787.964844 313.21875 786.3125 314.878906 784.296875 314.878906 L 710.96875 314.878906 C 708.949219 314.878906 707.300781 313.21875 707.300781 311.199219 L 707.300781 301.859375 C 707.300781 299.851562 708.949219 298.210938 710.96875 298.210938 "/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 7109.6875 17267.890625 L 7842.96875 17267.890625 C 7863.125 17267.890625 7879.648438 17251.484375 7879.648438 17231.40625 L 7879.648438 17138.007812 C 7879.648438 17117.8125 7863.125 17101.210938 7842.96875 17101.210938 L 7109.6875 17101.210938 C 7089.492188 17101.210938 7073.007812 17117.8125 7073.007812 17138.007812 L 7073.007812 17231.40625 C 7073.007812 17251.484375 7089.492188 17267.890625 7109.6875 17267.890625 Z M 7109.6875 17267.890625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 8936.015625 16641.015625 L 8936.015625 15402.890625 L 8086.015625 15402.890625 L 8086.015625 17502.890625 L 8886.289062 17502.890625 L 11190.3125 17490.703125 L 11190.3125 16641.015625 Z M 8936.015625 16641.015625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="4.7991" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 11140.3125 17441.015625 L 11140.3125 16691.015625 L 8886.015625 16691.015625 L 8886.015625 15452.890625 L 8136.015625 15452.890625 L 8136.015625 17452.890625 L 8886.015625 17452.890625 Z M 11140.3125 17441.015625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 815.160156 952.371094 L 540.933594 952.371094 L 540.933594 273.710938 L 1886.261719 273.710938 L 1886.261719 1077.308594 L 1874.269531 1077.308594 L 1874.269531 285.710938 L 552.9375 285.710938 L 552.9375 940.371094 L 815.160156 940.371094 Z M 1886.261719 1206.023438 L 1886.261719 1619.035156 L 1347.648438 1619.035156 L 1347.648438 1607.039062 L 1874.269531 1607.039062 L 1874.269531 1206.023438 Z M 1214.148438 1619.035156 L 942.441406 1619.035156 L 942.441406 946.371094 L 954.441406 946.371094 L 954.441406 1607.039062 L 1214.148438 1607.039062 L 1214.148438 1619.035156 "/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15523.984261 4144.673603 L 16344.417892 4144.673603 L 16344.417892 4530.161528 L 15523.984261 4530.161528 Z M 15523.984261 4144.673603 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15543.280951 4163.970613 L 16325.202281 4163.970613 L 16325.202281 4510.864518 L 15543.280951 4510.864518 Z M 15543.280951 4163.970613 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15112.5 4491.679688 C 15157.5 4491.679688 15193.984375 4455.195312 15193.984375 4410.117188 C 15193.984375 4365.078125 15157.5 4328.554688 15112.5 4328.554688 C 15067.382812 4328.554688 15030.898438 4365.078125 15030.898438 4410.117188 C 15030.898438 4455.195312 15067.382812 4491.679688 15112.5 4491.679688 Z M 15112.5 4491.679688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15231.601562 4333.242188 L 15390.898438 4333.242188 L 15390.898438 4395.117188 L 15231.601562 4395.117188 Z M 15231.601562 4333.242188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15235.3125 4407.304688 L 15312.1875 4407.304688 L 15312.1875 4459.804688 L 15235.3125 4459.804688 Z M 15235.3125 4407.304688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15323.398438 4407.304688 L 15388.984375 4407.304688 L 15388.984375 4448.554688 L 15323.398438 4448.554688 Z M 15323.398438 4407.304688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13815.700202 11582.917141 L 12995.30711 11582.917141 L 12995.30711 11197.391527 L 13815.700202 11197.391527 Z M 13815.700202 11582.917141 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13796.484591 11563.582442 L 13014.482182 11563.582442 L 13014.482182 11216.688537 L 13796.484591 11216.688537 Z M 13796.484591 11563.582442 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 13158.515625 11808.90625 C 13113.515625 11808.90625 13076.914062 11845.390625 13076.914062 11890.390625 C 13076.914062 11935.507812 13113.515625 11971.992188 13158.515625 11971.992188 C 13203.515625 11971.992188 13240.117188 11935.507812 13240.117188 11890.390625 C 13240.117188 11845.390625 13203.515625 11808.90625 13158.515625 11808.90625 Z M 13158.515625 11808.90625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12880.117188 11905.390625 L 13039.414062 11905.390625 L 13039.414062 11967.304688 L 12880.117188 11967.304688 Z M 12880.117188 11905.390625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12958.789062 11840.703125 L 13035.703125 11840.703125 L 13035.703125 11893.203125 L 12958.789062 11893.203125 Z M 12958.789062 11840.703125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 12881.914062 11851.992188 L 12947.617188 11851.992188 L 12947.617188 11893.203125 L 12881.914062 11893.203125 Z M 12881.914062 11851.992188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15510.890079 16722.51744 L 16331.283171 16722.51744 L 16331.283171 16336.916446 L 15510.890079 16336.916446 Z M 15510.890079 16722.51744 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="4.82455" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15530.10569 16703.295809 L 16312.108099 16703.295809 L 16312.108099 16356.288835 L 15530.10569 16356.288835 Z M 15530.10569 16703.295809 " transform="matrix(0.0963572, 0, 0, -0.103643, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15099.882812 17135.78125 C 15144.882812 17135.78125 15181.40625 17172.304688 15181.40625 17217.304688 C 15181.40625 17262.304688 15144.882812 17298.90625 15099.882812 17298.90625 C 15054.804688 17298.90625 15018.28125 17262.304688 15018.28125 17217.304688 C 15018.28125 17172.304688 15054.804688 17135.78125 15099.882812 17135.78125 Z M 15099.882812 17135.78125 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15218.90625 17232.304688 L 15378.28125 17232.304688 L 15378.28125 17294.21875 L 15218.90625 17294.21875 Z M 15218.90625 17232.304688 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15222.695312 17167.617188 L 15299.492188 17167.617188 L 15299.492188 17220.117188 L 15222.695312 17220.117188 Z M 15222.695312 17167.617188 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill="none" stroke-width="5.0003" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 15310.78125 17178.90625 L 15376.40625 17178.90625 L 15376.40625 17220.117188 L 15310.78125 17220.117188 Z M 15310.78125 17178.90625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
<path fill-rule="evenodd" fill="rgb(59.959412%, 59.959412%, 59.959412%)" fill-opacity="1" d="M 1177.351562 1376.386719 L 1179.820312 1378.386719 L 1151.738281 1413.117188 L 1149.269531 1411.117188 L 1177.351562 1376.386719 "/>
<path fill-rule="evenodd" fill="rgb(59.959412%, 59.959412%, 59.959412%)" fill-opacity="1" d="M 1179.570312 1386.253906 L 1181.378906 1387.71875 L 1160.859375 1413.09375 L 1159.050781 1411.632812 L 1179.570312 1386.253906 "/>
<path fill-rule="evenodd" fill="rgb(59.959412%, 59.959412%, 59.959412%)" fill-opacity="1" d="M 1166.628906 1377.078125 L 1168.429688 1378.542969 L 1147.921875 1403.921875 L 1146.109375 1402.457031 L 1166.628906 1377.078125 "/>
<path fill="none" stroke-width="119.999" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(0%, 0%, 0%)" stroke-opacity="1" stroke-miterlimit="2.61313" d="M 6818.789062 13452.890625 L 5469.375 13452.890625 L 5469.375 17452.890625 L 8136.015625 17452.890625 L 8136.015625 13452.890625 " transform="matrix(0.1, 0, 0, -0.1, 0, 2025)"/>
</svg>

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" xmlns:bx="https://boxy-svg.com" width="500px" height="500px">
<defs>
<bx:export>
<bx:file format="svg" href="#object-0"/>
<bx:file format="svg" href="#object-1" path="Untitled 2.svg"/>
<bx:file format="svg" href="#object-2" path="Untitled 3.svg"/>
<bx:file format="svg" path="Untitled 4.svg"/>
</bx:export>
</defs>
<rect x="37.923" y="35.346" width="85.788" height="68.851" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);" cx="203.056" cy="81.186" rx="33.689" ry="33.689"/>
<path d="M 152.982 124.448 L 176.73 156.849 L 129.234 156.849 L 152.982 124.448 Z"
bx:shape="triangle 129.234 124.448 47.496 32.401 0.5 0 1@619c0e74" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"
id="object-0"/>
<polygon style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"
points="166.053 12.15 135.493 79.529 189.985 141.016 193.299 198.085 138.071 183.726 179.676 173.785 180.044 143.962 128.498 88.365 129.234 53.019"
id="object-1"/>
<path style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"
d="M 62.224 188.881 C 74.811 188.881 83.645 176.475 79.529 164.58 C 74.501 150.049 82.032 134.168 96.465 128.866" id="object-2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB