auto: 2026-09-02 16:48 (EOMSANGDON-HOME)

This commit is contained in:
2026-09-02 16:48:22 +09:00
parent b4c7a7eedb
commit 397a135073
192 changed files with 6816 additions and 6014 deletions
@@ -11,7 +11,7 @@ import { HtmlEvent } from '../App.types';
import type { Entity, JsonEntity } from '../entities/Entity';
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
import { getActiveLayerId } from '../state';
import { getActiveLayerId, notifyWindow } from '../state';
const STORAGE_KEY = 'aislo-cad-block-library';
@@ -24,7 +24,7 @@ export interface BlockDefinition {
let blocks: BlockDefinition[] | null = null;
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
function load(): BlockDefinition[] {
if (blocks) return blocks;
@@ -18,10 +18,7 @@ import {
wipeoutToolStateMachine,
xlineToolStateMachine,
} from '../tools/draw/construction-tools';
import {
divideToolStateMachine,
measureLengthToolStateMachine,
} from '../tools/draw/divide-tools';
import { divideToolStateMachine, measureLengthToolStateMachine } from '../tools/draw/divide-tools';
import {
boundaryToolStateMachine,
gradientToolStateMachine,
@@ -2,7 +2,7 @@
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent } from '../App.types';
import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state';
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
import type { CadCommand } from './command.types';
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
@@ -19,7 +19,7 @@ function log(line: string) {
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
@@ -1,6 +1,6 @@
import {noop} from 'es-toolkit';
import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react';
import {Icon, type IconName} from './Icon/Icon.tsx';
import { noop } from 'es-toolkit';
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
import { Icon, type IconName } from './Icon/Icon.tsx';
interface ButtonProps {
label?: string;
@@ -1,9 +1,9 @@
import type {CSSProperties, FC, ReactNode} from 'react';
import type { CSSProperties, FC, ReactNode } from 'react';
import useLocalStorageState from 'use-local-storage-state';
import {LOCAL_STORAGE_KEY} from '../App.types.ts';
import {keyboardHandler} from '../helpers/keyboard-handler.ts';
import {Button} from './Button.tsx';
import {Icon, IconName} from './Icon/Icon.tsx';
import { LOCAL_STORAGE_KEY } from '../App.types.ts';
import { keyboardHandler } from '../helpers/keyboard-handler.ts';
import { Button } from './Button.tsx';
import { Icon, IconName } from './Icon/Icon.tsx';
interface DropdownButtonProps {
label?: string;
@@ -1,4 +1,4 @@
import type {FC} from 'react';
import type { FC } from 'react';
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
@@ -3,12 +3,7 @@ import type { FC } from 'react';
import { LayerManager } from './LayerManager';
import { PropertiesEditor } from './PropertiesEditor';
import { getInspectorTab, openInspector } from './ui-state';
import {
getActiveLayerId,
getLayers,
setActiveLayerId,
setLayers,
} from '../state';
import { getActiveLayerId, getLayers, setActiveLayerId, setLayers } from '../state';
interface InspectorPanelProps {
collapsed: boolean;
@@ -2,12 +2,7 @@
import type { FC } from 'react';
import type { Entity } from '../entities/Entity';
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
import {
getEntities,
getLayers,
getSelectedEntities,
setEntities,
} from '../state';
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
interface PropertiesEditorProps {
@@ -139,9 +134,7 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
</div>
<div>
<dt>시작점</dt>
<dd>
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
</dd>
<dd>{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}</dd>
</div>
<div>
<dt>그룹</dt>
@@ -3,6 +3,7 @@
* 리액트 컴포넌트 바깥에 둔다. 값이 바뀌면 UPDATE_STATE로 다시 그린다.
*/
import { HtmlEvent } from '../App.types';
import { notifyWindow } from '../state';
export type InspectorTab = 'properties' | 'layers';
@@ -12,7 +13,7 @@ let quickPropertiesVisible = false;
let activeRibbonTab = 'home';
let blockLibraryVisible = false;
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
export const getInspectorTab = () => inspectorTab;
export const isInspectorCollapsed = () => inspectorCollapsed;
@@ -69,7 +69,6 @@ export class ScreenCanvasDrawController implements DrawController {
}
public setScreenScale(newScreenScale: number) {
console.log(`set screen scale: ${newScreenScale}`);
this.screenScale = newScreenScale;
triggerReactUpdate(StateVariable.screenZoom);
}
@@ -597,16 +596,14 @@ export class ScreenCanvasDrawController implements DrawController {
angle: number
): void {
if (this.batching) this.flushBatch();
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
new Point(xMin, yMin),
new Point(width, height),
]);
const screenXMin = screenBasePoint.x;
const screenYMin = screenBasePoint.y;
const screenWidth = screenDimensions.x;
const screenHeight = screenDimensions.y;
const screenCenterX = screenXMin + screenWidth / 2;
const screenCenterY = screenYMin + screenHeight / 2;
// 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과
// y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02
// 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다.
const screenWidth = width * this.screenScale;
const screenHeight = height * this.screenScale;
const screenCenter = this.worldToTarget(new Point(xMin + width / 2, yMin + height / 2));
const screenCenterX = screenCenter.x;
const screenCenterY = screenCenter.y;
// Rotate and translate context
this.context.translate(screenCenterX, screenCenterY);
@@ -1,11 +1,11 @@
import {Point, Vector} from '@flatten-js/core';
import {toast} from 'react-toastify';
import {SVG_MARGIN, TO_DEGREES} from '../App.consts.ts';
import type {TextOptions} from '../entities/TextEntity.ts';
import {isLengthEqual} from '../helpers/is-length-equal.ts';
import {StateVariable} from '../helpers/undo-stack.ts';
import {triggerReactUpdate} from '../state.ts';
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
import { Point, Vector } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { SVG_MARGIN, TO_DEGREES } from '../App.consts.ts';
import type { TextOptions } from '../entities/TextEntity.ts';
import { isLengthEqual } from '../helpers/is-length-equal.ts';
import { StateVariable } from '../helpers/undo-stack.ts';
import { triggerReactUpdate } from '../state.ts';
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
export class SvgDrawController implements DrawController {
private lineColor = '#000';
@@ -1,7 +1,7 @@
import {type Arc, Point} from '@flatten-js/core';
import {describe, expect, it} from 'vitest';
import {EPSILON} from "../App.consts.ts";
import {ArcEntity} from './ArcEntity.ts';
import { type Arc, Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import { EPSILON } from '../App.consts.ts';
import { ArcEntity } from './ArcEntity.ts';
describe('ArcEntity.distanceTo', () => {
/**
@@ -86,6 +86,8 @@ export interface JsonEntity<TShapeJsonData = ShapeJsonData> {
lineWidth: number;
lineDash?: number[];
layerId: string;
/** GROUP 묶음 식별자 — 저장·복원에서 그대로 실어 나른다 */
groupId?: string;
shapeData: TShapeJsonData | null;
children?: JsonEntity<ShapeJsonData>[];
}
@@ -89,7 +89,13 @@ export class HatchEntity implements Entity {
}
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
drawController.setLineStyles(
highlighted,
selected,
this.lineColor,
this.lineWidth,
this.lineDash
);
for (let index = 1; index < this.points.length; index++) {
drawController.drawLine(this.points[index - 1], this.points[index]);
}
@@ -0,0 +1,40 @@
import { Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import type { DrawController } from '../drawControllers/DrawController';
import { ImageEntity } from './ImageEntity';
/** drawLine·drawImage 호출만 세는 최소 컨트롤러. */
function countingController() {
const calls = { lines: 0, images: 0 };
const controller = {
setLineStyles: () => {},
drawLine: () => {
calls.lines += 1;
},
drawImage: () => {
calls.images += 1;
},
} as unknown as DrawController;
return { controller, calls };
}
function entity(): ImageEntity {
const image = { currentSrc: 'data:image/png;base64,AAAA' } as HTMLImageElement;
return new ImageEntity('b08-frame', image, new Point(0, 0), new Point(10, 5));
}
describe('ImageEntity.draw', () => {
it('집지 않은 그림은 테두리를 그리지 않는다', () => {
const { controller, calls } = countingController();
entity().draw(controller, false, false);
expect(calls.lines).toBe(0);
expect(calls.images).toBe(1);
});
it('집은 그림만 테두리를 보인다', () => {
const { controller, calls } = countingController();
entity().draw(controller, false, true);
expect(calls.lines).toBe(4);
expect(calls.images).toBe(1);
});
});
@@ -53,15 +53,21 @@ export class ImageEntity implements Entity {
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
const selected = parentSelected ?? isEntitySelected(this);
drawController.setLineStyles(
parentHighlighted ?? isEntityHighlighted(this),
parentSelected ?? isEntitySelected(this),
highlighted,
selected,
this.lineColor,
this.lineWidth,
this.lineDash
);
for (const edge of polygonToSegments(this.polygon)) {
drawController.drawLine(edge.start, edge.end);
// 테두리는 **집었을 때만** 그린다. 늘 그리면 도각의 로고·서명 자리에 흰 사각형이
// 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
if (highlighted || selected) {
for (const edge of polygonToSegments(this.polygon)) {
drawController.drawLine(edge.start, edge.end);
}
}
const width = this.polygon.box.width;
@@ -1,8 +1,8 @@
import {describe, expect, it} from 'vitest';
import {Point} from "@flatten-js/core";
import {LineEntity} from "./LineEntity.ts";
import {TO_DEGREES} from "../App.consts.ts";
import {getActiveLayerId} from "../state.ts";
import { describe, expect, it } from 'vitest';
import { Point } from '@flatten-js/core';
import { LineEntity } from './LineEntity.ts';
import { TO_DEGREES } from '../App.consts.ts';
import { getActiveLayerId } from '../state.ts';
describe('getAngle', () => {
it('should return 0 for a horizontal line', () => {
@@ -1,10 +1,15 @@
import {type Box, Line, Point, Vector} from '@flatten-js/core'; // Added Box, Segment for completeness
import {round} from 'es-toolkit'; // 1. Mocking for ../state.ts
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest';
import {EPSILON, MEASUREMENT_DECIMAL_PLACES, MEASUREMENT_FONT_SIZE, MEASUREMENT_LABEL_OFFSET,} from '../App.consts';
import type {DrawController} from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
import {isEntityHighlighted, isEntitySelected} from '../state.ts';
import {MeasurementEntity} from './MeasurementEntity';
import { type Box, Line, Point, Vector } from '@flatten-js/core'; // Added Box, Segment for completeness
import { round } from 'es-toolkit'; // 1. Mocking for ../state.ts
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import {
EPSILON,
MEASUREMENT_DECIMAL_PLACES,
MEASUREMENT_FONT_SIZE,
MEASUREMENT_LABEL_OFFSET,
} from '../App.consts';
import type { DrawController } from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
import { isEntityHighlighted, isEntitySelected } from '../state.ts';
import { MeasurementEntity } from './MeasurementEntity';
// 1. Mocking for ../state.ts
vi.mock('../state.ts', () => ({
@@ -301,9 +301,7 @@ export class MeasurementEntity implements Entity {
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
const distance = String(
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const originalTextDirection = normalUnit.rotate90CW();
let finalTextDirection = originalTextDirection;
if (
@@ -418,9 +416,7 @@ export class MeasurementEntity implements Entity {
];
// Calculate text properties
const distance = String(
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const worldFactor = annotationWorldFactor();
const textHeight = getDimTextHeight() / worldFactor;
// Estimate width: textString.length * fontSize * aspectRatioFactor
@@ -1,19 +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)),
]);
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)),
]);
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)),
]);
}
@@ -46,7 +46,10 @@ export function pasteFromClipboard(target?: Point): Entity[] {
copy.lineDash = entity.lineDash;
copy.layerId = entity.layerId;
if (target) {
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
copy.move(
target.x - (clipboard as ClipboardContent).basePoint.x,
target.y - (clipboard as ClipboardContent).basePoint.y
);
}
return copy;
});
@@ -1,14 +1,14 @@
import {
getAngleGuideOriginPoint,
getAngleStep,
getHoveredSnapPoints,
getLayerById,
getScreenCanvasDrawController,
getShouldDrawHelpers,
getSnapTrackingEnabled,
setAngleGuideEntities,
setSnapPoint,
setSnapPointOnAngleGuide,
getAngleGuideOriginPoint,
getAngleStep,
getHoveredSnapPoints,
getLayerById,
getScreenCanvasDrawController,
getShouldDrawHelpers,
getSnapTrackingEnabled,
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';
@@ -19,42 +19,41 @@ 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 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();
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
? hoveredSnapPoints.filter(
hoveredSnapPoint =>
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
)
: [];
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
? hoveredSnapPoints.filter(
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
)
: [];
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
);
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);
}
if (getShouldDrawHelpers()) {
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
entities,
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
maxSnapDistance
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
setSnapPointOnAngleGuide(angleSnapPoint);
}
}
@@ -2,167 +2,167 @@ 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 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('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 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('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('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('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('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 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 });
});
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 });
});
});
@@ -1,52 +1,49 @@
export function containRectangle(
containedRectMinX: number,
containedRectMinY: number,
containedRectMaxX: number,
containedRectMaxY: number,
wrapperRectMinX: number,
wrapperRectMinY: number,
wrapperRectMaxX: number,
wrapperRectMaxY: number,
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 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;
// 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,
};
}
// 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 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 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;
// 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,
};
// Return the final coordinates of the scaled and centered rectangle
return {
minX: offsetX,
minY: offsetY,
maxX: offsetX + displayWidth,
maxY: offsetY + displayHeight,
};
}
@@ -5,17 +5,13 @@ 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 },
]);
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 },
]);
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
});
it('should handle horizontal and vertical lines', () => {
@@ -1,4 +1,4 @@
import {toast} from 'react-toastify';
import { toast } from 'react-toastify';
// A small type alias for clarity.
type Point = { x: number; y: number };
@@ -8,10 +8,12 @@ import {
getHighlightedEntityIds,
getLayers,
getSelectedEntityIds,
getScreenCanvasDrawController,
getSnapPoint,
isDrawingDirty,
isDrawingReadOnly,
} from '../state';
import { Point } from '@flatten-js/core';
import { isEntityHidden } from './visibility';
export function registerCadDebugHook(): void {
@@ -45,5 +47,10 @@ export function registerCadDebugHook(): void {
readOnly: () => isDrawingReadOnly(),
dirty: () => isDrawingDirty(),
meta: () => getDesignMeta(),
// 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다.
screen: (x: number, y: number) => {
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
return { x: point.x, y: point.y };
},
};
}
@@ -1,5 +1,5 @@
import {EntityName} from '../entities/Entity.ts';
import type {JsonDrawingFileSerialized} from './import-export-handlers/export-entities-to-json.ts';
import { EntityName } from '../entities/Entity.ts';
import type { JsonDrawingFileSerialized } from './import-export-handlers/export-entities-to-json.ts';
export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
entities: [
@@ -33,8 +33,9 @@ export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
},
radius: 156.92367603040066,
startAngle: 0,
// endAngle: (2 * Math.PI * 3) / 4,
endAngle: 1.5707963267948966,
// 클릭점(393,1108)이 호 위(중심각 ≈145°)에 놓이도록 3/4바퀴를 쓴다 —
// 90°까지만 돌면 호가 클릭점에서 147px 떨어져 직선(64px)이 더 가깝다.
endAngle: (2 * Math.PI * 3) / 4,
counterClockwise: true,
},
},
@@ -1,8 +1,8 @@
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';
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 () => {
@@ -11,34 +11,28 @@ import { sortPointsOnArc } from './sort-points-on-arc';
* @param pointsOnShape
*/
export function findNeighboringPointsOnArc(
clickedPointOnShape: Point,
arc: ArcEntity,
pointsOnShape: Point[],
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,
);
// 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',
);
}
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
],
];
// 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],
];
}
@@ -11,33 +11,27 @@ import { sortPointsOnCircle } from './sort-points-on-circle';
* @param pointsOnShape
*/
export function findNeighboringPointsOnCircle(
clickedPointOnShape: Point,
circle: CircleEntity,
pointsOnShape: Point[],
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,
);
// 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',
);
}
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
],
];
// 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],
];
}
@@ -11,31 +11,26 @@ import { pointDistance } from './distance-between-points';
* @param pointsOnLine
*/
export function findNeighboringPointsOnLine(
clickedPointOnLine: Point,
lineStartPoint: Point,
lineEndPoint: Point,
pointsOnLine: Point[],
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)],
);
// 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',
);
}
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,
];
return [
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
];
}
@@ -28,8 +28,7 @@ export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE
while (remaining.length) {
const tail = loop[loop.length - 1];
const index = remaining.findIndex(
(chain) =>
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
(chain) => near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
);
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
const [chain] = remaining.splice(index, 1);
@@ -33,7 +33,10 @@ function sampleCircle(circle: Circle, segments: number): Point[] {
for (let index = 0; index <= segments; index++) {
const angle = (2 * Math.PI * index) / segments;
points.push(
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
new Point(
circle.center.x + circle.r * Math.cos(angle),
circle.center.y + circle.r * Math.sin(angle)
)
);
}
return points;
@@ -48,9 +51,7 @@ export function dedupeConsecutive(points: Point[]): Point[] {
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
if (entity.getType() === EntityName.PolyLine) {
const children = (entity as PolyLineEntity).getEntities();
return dedupeConsecutive(
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
);
return dedupeConsecutive(children.flatMap((child) => sampleEntityPoints(child, curveSegments)));
}
const shape = entity.getShape();
@@ -71,7 +71,9 @@ export function regularPolygonPoints(center: Point, vertex: Point, sides: number
const points: Point[] = [];
for (let index = 0; index < count; index++) {
const angle = startAngle + (2 * Math.PI * index) / count;
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
points.push(
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
);
}
points.push(points[0].clone());
return points;
@@ -101,11 +103,7 @@ export function ellipsePoints(
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
if (controlPoints.length < 3) return [...controlPoints];
const extended = [
controlPoints[0],
...controlPoints,
controlPoints[controlPoints.length - 1],
];
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
const result: Point[] = [];
for (let index = 1; index < extended.length - 2; index++) {
const p0 = extended[index - 1];
@@ -158,7 +156,9 @@ function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
const points: Point[] = [];
for (let index = 0; index <= segments; index++) {
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
points.push(
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
);
}
return points;
}
@@ -1,32 +1,30 @@
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";
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
});
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;
});
}
@@ -1,7 +1,7 @@
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';
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', () => {
@@ -1,4 +1,4 @@
import type {Point} from '@flatten-js/core';
import type { Point } from '@flatten-js/core';
export function getAngleWithXAxis(start: Point, end: Point): number {
const dx = end.x - start.x;
@@ -1,4 +1,4 @@
import type {Entity} from "../entities/Entity.ts";
import type { Entity } from '../entities/Entity.ts';
export interface BoundingBox {
minX: number;
@@ -1,6 +1,6 @@
import type {Point} from '@flatten-js/core';
import {type SnapPoint, SnapPointType} from '../App.types';
import {pointDistance} from './distance-between-points';
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
@@ -1,13 +1,13 @@
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';
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
@@ -3,20 +3,20 @@ 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[] = [];
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)
// 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
if (i === j) continue; // Do not check for intersections with yourself
const entity2 = entities[j];
intersectionPoints.push(...entity1.getIntersections(entity2));
}
}
const entity2 = entities[j];
intersectionPoints.push(...entity1.getIntersections(entity2));
}
}
return intersectionPoints;
return intersectionPoints;
}
@@ -1,4 +1,4 @@
import {type Point, Vector} from '@flatten-js/core';
import { type Point, Vector } from '@flatten-js/core';
import {
type AbsolutePointInputEvent,
ActorEvent,
@@ -1,6 +1,6 @@
import type { Point } from '@flatten-js/core';
export interface PointWithAngle {
point: Point;
angle: number;
point: Point;
angle: number;
}
@@ -1,8 +1,8 @@
import {compact} from 'es-toolkit';
import {saveAs} from 'file-saver';
import type {Layer} from '../../App.types.ts';
import type {Entity, JsonEntity} from '../../entities/Entity';
import {getEntities, getLayers} from '../../state';
import { compact } from 'es-toolkit';
import { saveAs } from 'file-saver';
import type { Layer } from '../../App.types.ts';
import type { Entity, JsonEntity } from '../../entities/Entity';
import { getEntities, getLayers } from '../../state';
export async function exportEntitiesToJsonFile() {
const json = await exportEntitiesAndLayersToJsonString();
@@ -14,7 +14,11 @@ export async function exportEntitiesToJsonFile() {
export async function exportEntitiesAndLayersToJsonString() {
const entities = getEntities();
const jsonEntities = entities.map((entity) => entity.toJson());
// groupId는 엔티티 13종의 toJson을 다 고치는 대신 여기 한 곳에서 붙인다.
const jsonEntities = entities.map(async (entity) => {
const json = await entity.toJson();
return json && entity.groupId ? { ...json, groupId: entity.groupId } : json;
});
const jsonDrawingFile: JsonDrawingFileSerialized = {
entities: compact(await Promise.all(jsonEntities)), // TODO use a mapLimit to avoid overloading the event loop
layers: getLayers(),
@@ -1,5 +1,5 @@
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts';
import { LOCAL_STORAGE_KEY } from '../../App.types.ts';
import { exportEntitiesAndLayersToJsonString } from './export-entities-to-json.ts';
export async function exportEntitiesToLocalStorage() {
const json = await exportEntitiesAndLayersToJsonString();
@@ -12,57 +12,52 @@ import { getEntities } from '../../state';
* @param margin
*/
export function convertSvgToPngBlob(
svgLines: string[],
width: number,
height: number,
margin: number,
svgLines: string[],
width: number,
height: number,
margin: number
): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
if (!ctx) {
throw new Error('Could not get canvas context');
}
const img = new Image();
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
const url = URL.createObjectURL(svg);
const img = new Image();
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
const url = URL.createObjectURL(svg);
img.onload = () => {
canvas.width = width + margin * 2;
canvas.height = height + margin * 2;
img.onload = () => {
canvas.width = width + margin * 2;
canvas.height = height + margin * 2;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, margin, margin);
ctx.drawImage(img, margin, margin);
URL.revokeObjectURL(url);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Could not convert canvas to blob'));
}
}, 'image/png');
};
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Could not convert canvas to blob'));
}
}, 'image/png');
};
img.src = url;
});
img.src = url;
});
}
export async function exportEntitiesToPngFile() {
const entities = getEntities();
const entities = getEntities();
const svg = convertEntitiesToSvgString(entities);
const pngDataBlob: Blob = await convertSvgToPngBlob(
svg.svgLines,
svg.width,
svg.height,
20,
);
const svg = convertEntitiesToSvgString(entities);
const pngDataBlob: Blob = await convertSvgToPngBlob(svg.svgLines, svg.width, svg.height, 20);
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
}
@@ -1,9 +1,9 @@
import {saveAs} from 'file-saver';
import {SVG_MARGIN} from '../../App.consts';
import {SvgDrawController} from '../../drawControllers/svg.drawController.ts';
import type {Entity} from '../../entities/Entity';
import {getEntities} from '../../state';
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
import { saveAs } from 'file-saver';
import { SVG_MARGIN } from '../../App.consts';
import { SvgDrawController } from '../../drawControllers/svg.drawController.ts';
import type { Entity } from '../../entities/Entity';
import { getEntities } from '../../state';
import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts';
export function convertEntitiesToSvgString(entities: Entity[]): {
svgLines: string[];
@@ -0,0 +1,30 @@
import { Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import { LineEntity } from '../../entities/LineEntity.ts';
import { setEntities, setLayers } from '../../state.ts';
import { exportEntitiesAndLayersToJsonString } from './export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts';
describe('groupId 저장·복원', () => {
it('GROUP 묶음이 JSON 왕복 뒤에도 한 덩어리로 남는다', async () => {
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
const groupId = 'group-1';
const first = new LineEntity(layer.id, new Point(0, 0), new Point(1, 0));
const second = new LineEntity(layer.id, new Point(1, 0), new Point(1, 1));
const loner = new LineEntity(layer.id, new Point(2, 2), new Point(3, 3));
first.groupId = groupId;
second.groupId = groupId;
setLayers([layer]);
setEntities([first, second, loner]);
const restored = await getEntitiesAndLayersFromJsonString(
await exportEntitiesAndLayersToJsonString()
);
const byId = new Map(restored.entities.map((entity) => [entity.id, entity]));
expect(byId.get(first.id)?.groupId).toBe(groupId);
expect(byId.get(second.id)?.groupId).toBe(groupId);
expect(byId.get(loner.id)?.groupId).toBeUndefined();
});
});
@@ -77,6 +77,12 @@ export async function getEntitiesAndLayersFromJsonObject(
);
const entities = compact(await Promise.all(entityPromises));
// 순번이 아니라 id로 되돌린다 — compact가 null을 걷어 index가 어긋날 수 있다.
const groupIdById = new Map(data.entities.map((entity) => [entity.id, entity.groupId]));
for (const entity of entities) {
const groupId = groupIdById.get(entity.id);
if (groupId) entity.groupId = groupId;
}
let layers = data.layers;
if (data.layers.length === 0) {
layers = [getNewLayer()];
@@ -1,8 +1,8 @@
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
import {setActiveLayerId, setEntities, setLayers} from '../../state.ts';
import {getNewLayer} from '../get-new-layer.ts';
import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts';
import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts";
import { LOCAL_STORAGE_KEY } from '../../App.types.ts';
import { setActiveLayerId, setEntities, setLayers } from '../../state.ts';
import { getNewLayer } from '../get-new-layer.ts';
import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts';
import type { JsonDrawingFileDeserialized } from './export-entities-to-json.ts';
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
const file = await getEntitiesAndLayersFromLocalStorage();
@@ -1,15 +1,15 @@
import {toast} from 'react-toastify';
import {CircleEntity} from '../../entities/CircleEntity';
import type {Entity} from '../../entities/Entity';
import {LineEntity} from '../../entities/LineEntity';
import {RectangleEntity} from '../../entities/RectangleEntity';
import {getActiveLayerId, getEntities, setEntities} from '../../state';
import { toast } from 'react-toastify';
import { CircleEntity } from '../../entities/CircleEntity';
import type { Entity } from '../../entities/Entity';
import { LineEntity } from '../../entities/LineEntity';
import { RectangleEntity } from '../../entities/RectangleEntity';
import { getActiveLayerId, getEntities, setEntities } from '../../state';
import {Point} from '@flatten-js/core';
import {type Node, parse, type RootNode} from 'svg-parser';
import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts';
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
import {middle} from '../middle.ts';
import { Point } from '@flatten-js/core';
import { type Node, parse, type RootNode } from 'svg-parser';
import { svgPathToSegments } from '../convert-svg-path-to-line-segments.ts';
import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts';
import { middle } from '../middle.ts';
function svgChildrenToEntities(root: RootNode): Entity[] {
if (!root.children || !root.children?.[0]) {
@@ -1,12 +1,12 @@
export interface SvgParseResult {
type: string
children: Children[]
type: string;
children: Children[];
}
export interface Children {
type: string
tagName: string
properties: Record<string, string>
children: Children[]
metadata?: string
type: string;
tagName: string;
properties: Record<string, string>;
children: Children[];
metadata?: string;
}
@@ -3,16 +3,14 @@
* Load the image data
* Convert it to a base64 string
*/
export function importImageFromFile(
file: File | null | undefined,
): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>(resolve => {
if (!file) return;
export function importImageFromFile(file: File | null | undefined): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve) => {
if (!file) return;
const img = new Image();
img.onload = () => {
resolve(img);
};
img.src = URL.createObjectURL(file);
});
const img = new Image();
img.onload = () => {
resolve(img);
};
img.src = URL.createObjectURL(file);
});
}
@@ -1,7 +1,7 @@
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
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 {
@@ -1,6 +1,6 @@
import type {Point} from "@flatten-js/core";
import type {StartAndEndpointEntity} from "../App.types.ts";
import {isPointEqual} from "./is-point-equal.ts";
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
@@ -1,5 +1,5 @@
import { EPSILON } from '../App.consts';
export function isLengthEqual(length1: number, length2: number): boolean {
return Math.abs(length1 - length2) < EPSILON;
return Math.abs(length1 - length2) < EPSILON;
}
@@ -2,8 +2,5 @@ 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
);
return Math.abs(point1.x - point2.x) < EPSILON && Math.abs(point1.y - point2.y) < EPSILON;
}
@@ -1,4 +1,4 @@
import type {KeyboardEvent} from "react";
import type { KeyboardEvent } from 'react';
export function keyboardHandler(clickHandler: () => void) {
return (evt: KeyboardEvent) => {
@@ -2,60 +2,60 @@ 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 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 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);
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);
});
// 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 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 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 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 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 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 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);
});
it('should handle world coordinates to screen correctly', () => {
expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100);
});
});
@@ -3,20 +3,20 @@
* 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,
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;
}
// Handle the case where source range has zero length
if (startSourceRange === endSourceRange) {
return startTargetRange;
}
return (
startTargetRange +
((num - startSourceRange) * (endTargetRange - startTargetRange)) /
(endSourceRange - startSourceRange)
);
return (
startTargetRange +
((num - startSourceRange) * (endTargetRange - startTargetRange)) /
(endSourceRange - startSourceRange)
);
}
@@ -1,4 +1,4 @@
import type {LineEntity} from "../entities/LineEntity.ts";
import type { LineEntity } from '../entities/LineEntity.ts';
export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) {
const mirrorAngle = mirrorAxis.getAngle();
@@ -1,10 +1,10 @@
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";
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", () => {
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));
@@ -13,7 +13,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirroredPoint.y).toBe(0);
});
it("mirrors a point over a vertical axis", () => {
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);
@@ -21,7 +21,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(4);
});
it("mirrors a point over the diagonal line y = x", () => {
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);
@@ -30,7 +30,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(3);
});
it("returns the same point if the point lies on the mirror axis", () => {
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);
@@ -38,7 +38,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(1);
});
it("returns the original point when mirrored twice", () => {
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);
@@ -1,5 +1,5 @@
import { Point, type Segment } from '@flatten-js/core';
import type {LineEntity} from "../entities/LineEntity.ts";
import type { LineEntity } from '../entities/LineEntity.ts';
export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) {
const mirrorAxisSegment = mirrorAxis.getShape() as Segment;
@@ -1,4 +1,4 @@
import type {Polygon, Segment} from '@flatten-js/core';
import type { Polygon, Segment } from '@flatten-js/core';
export function polygonToSegments(polygon: Polygon): Segment[] {
const segments: Segment[] = [];
@@ -1,11 +1,7 @@
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);
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);
}
@@ -1,11 +1,7 @@
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);
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);
}
@@ -1,9 +1,5 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import {
getGridEnabled,
getHighlightedEntityIds,
setHighlightedEntityIds,
} from '../state';
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
@@ -130,7 +126,11 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
// 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)) {
if (
paramsChanged ||
getGridEnabled() ||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
) {
rebuildScene(drawController);
}
@@ -10,24 +10,22 @@ import { ArcEntity } from '../entities/ArcEntity';
* @param startPoint
*/
export function sortPointsOnArc(
pointsOnArc: Point[],
centerPoint: Point,
startPoint: Point,
pointsOnArc: Point[],
centerPoint: Point,
startPoint: Point
): Point[] {
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
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);
// 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
);
}
@@ -7,17 +7,14 @@ import type { PointWithAngle } from './helpers.types';
* @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);
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
);
}
@@ -1,12 +1,9 @@
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;
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;
}
@@ -7,71 +7,64 @@ import { HOVERED_SNAP_POINT_TIME, MAX_MARKED_SNAP_POINTS } from '../App.consts';
* 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,
worldSnapPoint: SnapPoint | null,
worldHoveredSnapPoints: HoverPoint[],
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
maxHoverDistance: number,
elapsedTime: number
) {
if (!worldSnapPoint) {
return;
}
if (!worldSnapPoint) {
return;
}
const lastHoveredPoint = worldHoveredSnapPoints.at(-1);
let newHoverSnapPoints: HoverPoint[];
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,
},
];
}
// 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);
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(0, MAX_MARKED_SNAP_POINTS);
setHoveredSnapPoints(newHoverSnapPointsTruncated);
}
@@ -4,6 +4,7 @@
*/
import { HtmlEvent } from '../App.types';
import type { Entity } from '../entities/Entity';
import { notifyWindow } from '../state';
import { bumpSceneVersion } from './scene-version';
let hiddenEntityIds = new Set<string>();
@@ -14,7 +15,7 @@ export const getHiddenEntityCount = (): number => hiddenEntityIds.size;
function apply(ids: Set<string>): void {
hiddenEntityIds = ids;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
/** 지정 객체를 숨긴다 */
@@ -7,5 +7,5 @@
// 3 => 0
// 4 => 1
export function wrapModule(index: number, length: number) {
return (index + length) % length;
return (index + length) % length;
}
+15 -9
View File
@@ -309,9 +309,15 @@ export const setActiveToolActor = (
triggerReactUpdate(StateVariable.activeTool);
}
};
/** 화면에 알린다 — `window`가 없는 Node 시험에서는 통지를 건너뛴다.
* (`triggerReactUpdate`가 이미 같은 이유로 시험 환경을 건너뛴다.) */
export const notifyWindow = (event: HtmlEvent) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(event));
};
export const setLastStateInstructions = (newInstructions: string | null) => {
lastStateInstructions = newInstructions;
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => {
if (trackInUndoStack) {
@@ -321,7 +327,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
bumpSceneVersion();
if (trackInUndoStack) {
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
};
/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */
@@ -348,7 +354,7 @@ export const setSelectedEntityIds = (newEntityIds: string[]) => {
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setShouldDrawCursor = (newValue: boolean) => {
shouldDrawCursor = newValue;
@@ -462,19 +468,19 @@ export const setSnapEnabled = (enabled: boolean) => {
setHoveredSnapPoints([]);
setAngleGuideEntities([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setSnapTrackingEnabled = (enabled: boolean) => {
snapTrackingEnabled = enabled;
if (!enabled) {
setHoveredSnapPoints([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
@@ -556,7 +562,7 @@ export function undo() {
updateStates(undoState);
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function redo() {
@@ -565,7 +571,7 @@ export function redo() {
updateStates(redoState);
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function triggerReactUpdate(variable: StateVariable) {
@@ -577,5 +583,5 @@ export function triggerReactUpdate(variable: StateVariable) {
return;
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
@@ -1,8 +1,8 @@
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 { 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
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
* 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);
})
GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin);
})
);
@@ -1,9 +1,9 @@
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";
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
@@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts";
* 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);
})
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);
})
);
@@ -1,8 +1,8 @@
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 { 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
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
* 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);
})
GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0);
})
);
@@ -1,9 +1,9 @@
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";
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
@@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts";
* 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);
})
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);
})
);
@@ -1,8 +1,8 @@
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 { 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
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
* 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);
})
GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0);
})
);
@@ -1,6 +1,9 @@
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 { 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,
@@ -9,9 +12,15 @@ import {
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';
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 {}
@@ -1,8 +1,8 @@
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 { 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
@@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts";
* 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));
})
GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP),
GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => {
entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY));
})
);
@@ -97,7 +97,7 @@ export const dimAngularToolStateMachine = createSequenceTool({
const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x);
const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x);
const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI));
const sweep = (endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI);
const midAngle = startAngle + sweep / 2;
const degrees = (sweep * 180) / Math.PI;
@@ -1,9 +1,9 @@
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 { 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,
@@ -14,9 +14,9 @@ import {
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {CopyAction} from './copy-tool.ts';
import {selectToolStateMachine} from './select-tool.ts';
import { Tool } from '../tools';
import { CopyAction } from './copy-tool.ts';
import { selectToolStateMachine } from './select-tool.ts';
import type {
AbsolutePointInputEvent,
DrawEvent,
+250 -265
View File
@@ -1,48 +1,48 @@
import type {Point} from '@flatten-js/core';
import type { Point } from '@flatten-js/core';
import {
addEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
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';
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;
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',
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',
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',
}
/**
@@ -55,242 +55,227 @@ export enum CopyAction {
* 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();
{
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);
// 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
// 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',
);
}
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();
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,
);
// 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',
);
}
// // 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,
);
// 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,
},
},
// 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,
},
}
);
@@ -9,7 +9,13 @@ import {
} from '../../helpers/geometry/shape-points';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity, pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
import {
arcEntity,
lineEntity,
pointEntity,
polyLineEntity,
styled,
} from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
export const plineToolStateMachine = createSequenceTool({
@@ -138,7 +144,11 @@ export const donutToolStateMachine = createSequenceTool({
},
});
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
function donutEntities(
innerDiameter: number,
outerDiameter: number,
center: Parameters<typeof pointEntity>[0]
): Entity[] {
const circles: Entity[] = [];
for (const diameter of [innerDiameter, outerDiameter]) {
if (diameter > 0) {
@@ -1,6 +1,10 @@
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
import { toast } from 'react-toastify';
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import {
dividePoints,
measurePoints,
sampleEntityPoints,
} from '../../helpers/geometry/sample-entity';
import { addEntities } from '../../state';
import { Tool } from '../../tools';
import { pointEntity } from '../factories/entity-factory';
@@ -57,9 +57,7 @@ export const hatchToolStateMachine = createSequenceTool({
export const gradientToolStateMachine = createSequenceTool({
tool: Tool.GRADIENT,
helpers: false,
steps: [
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
],
steps: [{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
@@ -80,9 +78,7 @@ export const gradientToolStateMachine = createSequenceTool({
export const boundaryToolStateMachine = createSequenceTool({
tool: Tool.BOUNDARY,
helpers: false,
steps: [
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
],
steps: [{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
@@ -1,15 +1,15 @@
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';
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
@@ -1,13 +1,13 @@
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';
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', () => {
/**
@@ -1,4 +1,4 @@
import {Box, type Point} from '@flatten-js/core';
import { Box, type Point } from '@flatten-js/core';
export function getContainRectangleInsideRectangle(
imageWidth: number,
@@ -1,245 +1,250 @@
import type {Point} from '@flatten-js/core';
import type { Point } from '@flatten-js/core';
import {
addEntities,
getActiveLayerId,
setActiveToolActor,
setAngleGuideEntities,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
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';
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;
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',
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',
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',
);
}
{
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,
);
const containRectangle = getContainRectangleInsideRectangle(
context.imageElement.naturalWidth,
context.imageElement.naturalHeight,
context.startPoint,
(event as MouseClickEvent).worldMouseLocation
);
if (!containRectangle) {
return;
}
if (!containRectangle) {
return;
}
const activeImage = new ImageEntity(
getActiveLayerId(),
context.imageElement,
boxToPolygon(containRectangle),
);
addEntities([activeImage], true);
},
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
},
const activeImage = new ImageEntity(
getActiveLayerId(),
context.imageElement,
boxToPolygon(containRectangle)
);
addEntities([activeImage], true);
},
[ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
}
);
@@ -50,14 +50,7 @@ export const chamferToolStateMachine = createSequenceTool({
applyCorner(
first,
second,
chamferLines(
first,
input.pick(2),
second,
input.pick(3),
input.number(0),
input.number(1)
)
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
);
},
});
@@ -80,14 +80,8 @@ export function filletLines(
}
const tangentDistance = radius / Math.tan(angle / 2);
const tangentA = new Point(
corner.x + ua.x * tangentDistance,
corner.y + ua.y * tangentDistance
);
const tangentB = new Point(
corner.x + ub.x * tangentDistance,
corner.y + ub.y * tangentDistance
);
const tangentA = new Point(corner.x + ua.x * tangentDistance, corner.y + ua.y * tangentDistance);
const tangentB = new Point(corner.x + ub.x * tangentDistance, corner.y + ub.y * tangentDistance);
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
const centerDistance = radius / Math.sin(angle / 2);
@@ -129,7 +129,11 @@ export const lengthenToolStateMachine = createSequenceTool({
tool: Tool.LENGTHEN,
steps: [
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
{
kind: 'number',
instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.',
defaultValue: 10,
},
],
commit: (input) => {
const entity = input.entity(0);
@@ -1,4 +1,4 @@
import type {Entity} from '../entities/Entity';
import type { Entity } from '../entities/Entity';
/**
* Move entities by the difference between the start and end points
+254 -267
View File
@@ -1,49 +1,49 @@
import type {Point} from '@flatten-js/core';
import type { Point } from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
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';
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;
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',
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',
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',
}
/**
@@ -56,243 +56,230 @@ export enum MoveAction {
* 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();
{
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);
// 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
// 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',
);
}
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();
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,
);
// 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',
);
}
// // 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,
);
// 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,
},
},
// 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,
},
}
);
@@ -1,6 +1,6 @@
import {toast} from 'react-toastify';
import {assign, createMachine, sendTo} from 'xstate';
import {PolyLineEntity} from '../entities/PolyLineEntity.ts';
import { toast } from 'react-toastify';
import { assign, createMachine, sendTo } from 'xstate';
import { PolyLineEntity } from '../entities/PolyLineEntity.ts';
import {
getActiveLayerId,
getNotSelectedEntities,
@@ -12,9 +12,9 @@ import {
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import {Tool} from '../tools';
import {selectToolStateMachine} from './select-tool';
import type {StateEvent, ToolContext} from './tool.types';
import { Tool } from '../tools';
import { selectToolStateMachine } from './select-tool';
import type { StateEvent, ToolContext } from './tool.types';
export interface PeditContext extends ToolContext {}
@@ -1,5 +1,5 @@
import {Line, type Point} from '@flatten-js/core';
import type {Entity} from '../entities/Entity';
import { Line, type Point } from '@flatten-js/core';
import type { Entity } from '../entities/Entity';
/**
* Rotate entities round a base point by a certain angle
@@ -1,47 +1,47 @@
import type {Point} from '@flatten-js/core';
import type { Point } from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
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';
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[];
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',
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',
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',
}
/**
@@ -55,271 +55,251 @@ export enum RotateAction {
* 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();
{
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);
// 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
// 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',
);
}
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();
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,
);
// 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;
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,
);
// 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,
},
},
// 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,
},
}
);

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