From 068eac7f6a7790f55fb4c8aa83c89cfb31bc0178 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 16:17:51 +0900 Subject: [PATCH] auto: 2026-08-29 16:17 (EOMSANGDON-HOME) --- .../openwebcad/.github/workflows/deploy.yml | 46 ++ .../openwebcad/src/components/Toolbar.tsx | 2 +- .../src/entities/RectangleEntity.ts | 213 +++++++ .../openwebcad/src/entities/TextEntity.ts | 217 +++++++ .../openwebcad/src/helpers/box-to-polygon.ts | 19 + .../calculate-angle-guides-and-snap-points.ts | 56 ++ .../src/helpers/contain-rect.test.ts | 168 ++++++ .../openwebcad/src/helpers/contain-rect.ts | 52 ++ .../convert-svg-path-to-line-segments.test.ts | 116 ++++ .../convert-svg-path-to-line-segments.ts | 471 ++++++++++++++++ .../src/helpers/distance-between-points.ts | 3 + .../openwebcad/src/helpers/draw-functions.ts | 214 +++++++ .../openwebcad/src/helpers/draw.ts | 69 +++ .../src/helpers/find-closest-entity.mocks.ts | 50 ++ .../src/helpers/find-closest-entity.test.ts | 16 + .../src/helpers/find-closest-entity.ts | 24 + .../helpers/find-neighboring-points-on-arc.ts | 44 ++ .../find-neighboring-points-on-circle.ts | 43 ++ .../find-neighboring-points-on-line.ts | 41 ++ .../src/helpers/get-angle-guide-lines.ts | 32 ++ .../src/helpers/get-angle-with-x-axis.test.ts | 26 + .../src/helpers/get-angle-with-x-axis.ts | 12 + .../get-bounding-box-of-multiple-entities.ts | 32 ++ .../src/helpers/get-closest-snap-point.ts | 76 +++ .../openwebcad/src/helpers/get-draw-guides.ts | 91 +++ .../src/helpers/get-export-color.ts | 14 + .../src/helpers/get-intersection-points.ts | 22 + .../openwebcad/src/helpers/get-new-layer.ts | 11 + .../src/helpers/get-point-from-event.ts | 49 ++ .../openwebcad/src/helpers/helpers.types.ts | 6 + .../src/helpers/is-closed-polygon.test.ts | 87 +++ .../src/helpers/is-closed-polygon.ts | 84 +++ .../openwebcad/src/helpers/is-length-equal.ts | 5 + .../openwebcad/src/helpers/is-point-equal.ts | 9 + .../src/helpers/keyboard-handler.ts | 9 + .../src/helpers/map-number-range.test.ts | 61 ++ .../src/helpers/map-number-range.ts | 22 + .../openwebcad/src/helpers/middle.ts | 3 + .../src/helpers/mirror-angle-over-axis.ts | 6 + .../helpers/mirror-point-over-axis.test.ts | 49 ++ .../src/helpers/mirror-point-over-axis.ts | 22 + .../openwebcad/src/helpers/normalise-angle.ts | 3 + .../openwebcad/src/helpers/paper-sizes.ts | 3 + .../src/helpers/polygon-to-segments.ts | 9 + .../src/helpers/rgb-to-hex-color.ts | 3 + .../openwebcad/src/helpers/rotate-point.ts | 11 + .../openwebcad/src/helpers/scale-point.ts | 11 + .../openwebcad/src/helpers/scene-cache.ts | 143 +++++ .../openwebcad/src/helpers/scene-version.ts | 13 + .../src/helpers/sort-points-on-arc.ts | 33 ++ .../src/helpers/sort-points-on-circle.ts | 23 + .../openwebcad/src/helpers/spatial-index.ts | 140 +++++ .../openwebcad/src/helpers/times.ts | 12 + .../src/helpers/track-hovered-snap-points.ts | 77 +++ .../openwebcad/src/helpers/undo-stack.ts | 84 +++ .../openwebcad/src/helpers/wrap-module.ts | 11 + .../src/inputController/input-controller.ts | 528 ++++++++++++++++++ .../src/integration/aislo-drawing-bridge.ts | 141 +++++ .../openwebcad/src/tools/align-bottom-tool.ts | 18 + .../src/tools/align-center-horizontal-tool.ts | 22 + .../openwebcad/src/tools/align-left-tool.ts | 18 + .../src/tools/align-middle-vertical-tool.ts | 22 + .../openwebcad/src/tools/align-right-tool.ts | 18 + .../src/tools/align-tool.helpers.ts | 144 +++++ .../openwebcad/src/tools/align-top-tool.ts | 18 + .../openwebcad/src/tools/array-tool.ts | 368 ++++++++++++ .../openwebcad/src/tools/circle-tool.ts | 164 ++++++ .../openwebcad/src/tools/copy-tool.ts | 296 ++++++++++ .../src/tools/eraser-tool.helpers.ts | 126 +++++ .../openwebcad/src/tools/eraser-tool.test.ts | 83 +++ .../openwebcad/src/tools/eraser-tool.ts | 152 +++++ .../src/tools/image-import-tool.helpers.ts | 46 ++ .../openwebcad/src/tools/image-import-tool.ts | 245 ++++++++ .../openwebcad/src/tools/line-tool.ts | 169 ++++++ .../openwebcad/src/tools/measurement-tool.ts | 221 ++++++++ .../openwebcad/src/tools/move-tool.helpers.ts | 13 + .../openwebcad/src/tools/move-tool.ts | 298 ++++++++++ .../openwebcad/src/tools/pedit-tool.ts | 138 +++++ .../openwebcad/src/tools/rectangle-tool.ts | 152 +++++ .../src/tools/rotate-tool.helpers.ts | 22 + .../openwebcad/src/tools/rotate-tool.ts | 325 +++++++++++ .../src/tools/scale-tool.helpers.ts | 24 + .../openwebcad/src/tools/scale-tool.ts | 326 +++++++++++ .../src/tools/select-tool.helpers.ts | 151 +++++ .../openwebcad/src/tools/select-tool.ts | 180 ++++++ .../openwebcad/src/tools/tool.consts.ts | 75 +++ .../openwebcad/src/tools/tool.types.ts | 117 ++++ .../openwebcad/test/helpers/click.ts | 25 + .../test/helpers/init-application.ts | 25 + .../test/helpers/replay-recording.ts | 76 +++ .../test/helpers/replay-recording.types.ts | 28 + .../test/helpers/set-active-tool.ts | 11 + .../openwebcad/test/helpers/tests.consts.ts | 4 + .../openwebcad/test/mocks/floorplan.svg | 159 ++++++ .../openwebcad/test/mocks/test-svg-import.svg | 21 + 95 files changed, 8136 insertions(+), 1 deletion(-) create mode 100644 B07_DesignDetail/openwebcad/.github/workflows/deploy.yml create mode 100644 B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts create mode 100644 B07_DesignDetail/openwebcad/src/entities/TextEntity.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/distance-between-points.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/draw.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-export-color.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-new-layer.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/middle.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/normalise-angle.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/paper-sizes.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/rgb-to-hex-color.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/scale-point.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/scene-version.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/times.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-controller.ts create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/array-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/circle-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/copy-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/line-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/move-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/scale-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/select-tool.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/tool.consts.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/tool.types.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/click.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/init-application.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts create mode 100644 B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts create mode 100644 B07_DesignDetail/openwebcad/test/mocks/floorplan.svg create mode 100644 B07_DesignDetail/openwebcad/test/mocks/test-svg-import.svg diff --git a/B07_DesignDetail/openwebcad/.github/workflows/deploy.yml b/B07_DesignDetail/openwebcad/.github/workflows/deploy.yml new file mode 100644 index 00000000..a642ce00 --- /dev/null +++ b/B07_DesignDetail/openwebcad/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Test, Build, and Deploy + +on: + push: + branches: + - master + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@v4 + + - name: ๐ŸŸข Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: โ™ป๏ธ Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: ๐Ÿ“ฆ Install dependencies + run: npm ci + + - name: ๐Ÿงช Run tests + run: npm run test + + - name: ๐Ÿ› ๏ธ Build project + run: npm run build + + - name: ๐Ÿ›‘ Disable Jekyll + run: echo > dist/.nojekyll + + - name: ๐Ÿš€ Deploy + if: success() && github.ref == 'refs/heads/master' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 59958023..e10d1b97 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -225,7 +225,7 @@ export const Toolbar: FC = () => {
Aislo CAD - B08 ์ƒ์„ธ ์„ค๊ณ„ + B07 ์ƒ์„ธ ์„ค๊ณ„
diff --git a/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts b/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts new file mode 100644 index 00000000..69d86bd5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts @@ -0,0 +1,213 @@ +import type * as Flatten from '@flatten-js/core'; +import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core'; +import { type Shape, type SnapPoint, SnapPointType } from '../App.types'; +import type { DrawController } from '../drawControllers/DrawController'; +import { twoPointBoxToPolygon } from '../helpers/box-to-polygon'; +import { getExportColor } from '../helpers/get-export-color'; +import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts'; +import { polygonToSegments } from '../helpers/polygon-to-segments'; +import { scalePoint } from '../helpers/scale-point'; +import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts'; +import { type Entity, EntityName, type JsonEntity } from './Entity'; +import type { LineEntity } from './LineEntity.ts'; + +export class RectangleEntity implements Entity { + public id: string = crypto.randomUUID(); + public lineColor = '#fff'; + public lineWidth = 1; + public lineDash: number[] | undefined = undefined; + public layerId: string; + + private polygon: Polygon; + + constructor(layerId: string, startPointOrPolygon?: Point | Polygon, endPoint?: Point) { + this.layerId = layerId; + if (startPointOrPolygon instanceof Polygon) { + this.polygon = startPointOrPolygon as Polygon; + } else { + this.polygon = twoPointBoxToPolygon(startPointOrPolygon as Point, endPoint as Point); + } + } + + public draw( + drawController: DrawController, + parentHighlighted?: boolean, + parentSelected?: boolean + ): void { + drawController.setLineStyles( + parentHighlighted ?? isEntityHighlighted(this), + parentSelected ?? isEntitySelected(this), + this.lineColor, + this.lineWidth, + this.lineDash + ); + for (const edge of polygonToSegments(this.polygon)) { + const startPoint = new Point(edge.start.x, edge.start.y); + const endPoint = new Point(edge.end.x, edge.end.y); + drawController.drawLine(startPoint, endPoint); + } + } + + public move(x: number, y: number) { + this.polygon = this.polygon.translate(new Vector(x, y)); + } + + public scale(scaleOrigin: Point, scaleFactor: number) { + const center = this.polygon.box.center; + const newCenter = scalePoint(center, scaleOrigin, scaleFactor); + this.polygon = this.polygon.translate( + new Vector(newCenter.x - center.x, newCenter.y - center.y) + ); + } + + public rotate(rotateOrigin: Point, angle: number) { + this.polygon = this.polygon.rotate(angle, rotateOrigin); + } + + public mirror(mirrorAxis: LineEntity) { + const mirroredVertices = this.polygon.vertices.map((p) => mirrorPointOverAxis(p, mirrorAxis)); + this.polygon = new Polygon(mirroredVertices); + } + + public clone(): RectangleEntity { + return new RectangleEntity(getActiveLayerId(), this.polygon.clone()); + } + + public intersectsWithBox(selectionBox: Box): boolean { + return Relations.relate(this.polygon, selectionBox).B2B.length > 0; + } + + public isContainedInBox(selectionBox: Box): boolean { + return selectionBox.contains(this.polygon); + } + + public distanceTo(shape: Shape): [number, Segment] | null { + const distanceInfos: [number, Segment][] = polygonToSegments(this.polygon).map((segment) => { + return segment.distanceTo(shape); + }); + let shortestDistanceInfo: [number, Segment | null] = [Number.MAX_VALUE, null]; + for (const distanceInfo of distanceInfos) { + if (distanceInfo[0] < shortestDistanceInfo[0]) { + shortestDistanceInfo = distanceInfo; + } + } + return shortestDistanceInfo as [number, Segment]; + } + + public getBoundingBox(): Box { + return this.polygon.box; + } + + public getShape(): Shape | null { + return this.polygon; + } + + public getSnapPoints(): SnapPoint[] { + const corners = this.polygon.vertices; + const edges = polygonToSegments(this.polygon); + return [ + { + point: corners[0], + type: SnapPointType.LineEndPoint, + }, + { + point: corners[1], + type: SnapPointType.LineEndPoint, + }, + { + point: corners[2], + type: SnapPointType.LineEndPoint, + }, + { + point: corners[3], + type: SnapPointType.LineEndPoint, + }, + { + point: edges[0].middle(), + type: SnapPointType.LineMidPoint, + }, + { + point: edges[1].middle(), + type: SnapPointType.LineMidPoint, + }, + { + point: edges[2].middle(), + type: SnapPointType.LineMidPoint, + }, + { + point: edges[3].middle(), + type: SnapPointType.LineMidPoint, + }, + ]; + } + + public getIntersections(entity: Entity): Point[] { + const otherShape = entity.getShape(); + if (!otherShape) { + return []; + } + return polygonToSegments(this.polygon).flatMap((segment) => { + return segment.intersect(otherShape); + }); + } + + public getFirstPoint(): Point | null { + return this.polygon?.vertices[0] || null; + } + + public getSvgString(): string | null { + return this.polygon.svg({ + strokeWidth: this.lineWidth, + stroke: getExportColor(this.lineColor), + }); + } + + public getType(): EntityName { + return EntityName.Rectangle; + } + + public containsPointOnShape(point: Flatten.Point): boolean { + return polygonToSegments(this.polygon).some((segment) => segment.contains(point)); + } + + public async toJson(): Promise | null> { + return { + id: this.id, + type: EntityName.Rectangle, + lineColor: this.lineColor, + lineWidth: this.lineWidth, + lineDash: this.lineDash, + layerId: this.layerId, + shapeData: { + points: this.polygon.vertices.map((vertex) => ({ + x: vertex.x, + y: vertex.y, + })), + }, + }; + } + + public static async fromJson( + jsonEntity: JsonEntity + ): Promise { + if (!jsonEntity.shapeData) { + throw new Error('Invalid JSON entity of type Rectangle: missing shapeData'); + } + const rectangle = new Polygon( + jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y)) + ); + const rectangleEntity = new RectangleEntity( + jsonEntity.layerId || getActiveLayerId(), + rectangle + ); + rectangleEntity.id = jsonEntity.id; + rectangleEntity.lineColor = jsonEntity.lineColor; + rectangleEntity.lineWidth = jsonEntity.lineWidth; + rectangleEntity.lineDash = jsonEntity.lineDash; + return rectangleEntity; + } +} + +export interface RectangleJsonData { + points: { x: number; y: number }[]; +} diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts new file mode 100644 index 00000000..33a8fb92 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -0,0 +1,217 @@ +import { Box, Point, type Segment, Vector } from '@flatten-js/core'; +import { cloneDeep } from 'es-toolkit/compat'; +import type { Shape, SnapPoint } from '../App.types'; +import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController'; +import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts'; +import { scalePoint } from '../helpers/scale-point.ts'; +import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts'; +import { type Entity, EntityName, type JsonEntity } from './Entity'; +import type { LineEntity } from './LineEntity.ts'; + +export interface TextOptions { + textDirection: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; +} + +export class TextEntity implements Entity { + public id: string = crypto.randomUUID(); + public lineColor = '#fff'; + public lineWidth = 1; + public lineDash: number[] = []; + public layerId: string; + private readonly options: TextOptions; + + constructor( + layerId: string, + private label: string, + private basePoint: Point, + options?: Partial + ) { + this.layerId = layerId; + this.options = { + ...DEFAULT_TEXT_OPTIONS, + ...options, + }; + } + + public draw( + drawController: DrawController, + parentHighlighted?: boolean, + parentSelected?: boolean + ): void { + drawController.setLineStyles( + parentHighlighted ?? isEntityHighlighted(this), + parentSelected ?? isEntitySelected(this), + this.lineColor, + this.lineWidth, + this.lineDash + ); + drawController.drawText(this.label, this.basePoint, this.options); + } + + public move(x: number, y: number) { + this.basePoint = this.basePoint.translate(x, y); + } + + public scale(scaleOrigin: Point, scaleFactor: number) { + this.basePoint = scalePoint(this.basePoint, scaleOrigin, scaleFactor); + this.options.fontSize = this.options.fontSize * scaleFactor; // TODO discuss if text should scale or not? + } + + public rotate(rotateOrigin: Point, angle: number) { + this.basePoint = this.basePoint.rotate(angle, rotateOrigin); + this.options.textDirection = this.options.textDirection.rotate(angle); + } + + public mirror(mirrorAxis: LineEntity) { + this.basePoint = mirrorPointOverAxis(this.basePoint, mirrorAxis); + this.options.textDirection = new Vector( + new Point(0, 0), + new Point(this.options.textDirection.x, this.options.textDirection.y) + ); + } + + public clone(): TextEntity { + return new TextEntity( + getActiveLayerId(), + this.label, + this.basePoint.clone(), + cloneDeep(this.options) + ); + } + + public intersectsWithBox(box: Box): boolean { + return box.contains(this.basePoint); + } + + public isContainedInBox(box: Box): boolean { + return box.contains(this.basePoint); + } + + public getBoundingBox(): Box { + // TODO find better way of determining the text bounding box + return new Box( + this.basePoint.x, + this.basePoint.y, + this.basePoint.x + this.options.fontSize * this.label.length, + this.basePoint.y + this.options.fontSize + ); + } + + public getTextOptions(): TextOptions { + return this.options; + } + + public setTextOptions(newOptions: Partial>): void { + Object.assign(this.options, newOptions); + } + + public getLabel(): string { + return this.label; + } + + /** ํ…Œ์ด๋ธ” ๊ฐ’ ์…€ ๋“ฑ ๊ธฐ์กด ํ…์ŠคํŠธ ๋‚ด์šฉ์„ ์ง์ ‘ ์ˆ˜์ •ํ•œ๋‹ค (aislo ๋”๋ธ”ํด๋ฆญ ํŽธ์ง‘์šฉ). */ + public setLabel(newLabel: string): void { + this.label = newLabel; + } + + public getShape(): Shape | null { + return null; // TODO see why we need to get the shape out of an entity + } + + public getSnapPoints(): SnapPoint[] { + return []; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getIntersections(_entity: Entity): Point[] { + return []; + } + + public getFirstPoint(): Point | null { + return this.basePoint; + } + + public distanceTo(shape: Shape): [number, Segment] | null { + return this.basePoint.distanceTo(shape); + } + + public getSvgString(): string | null { + return null; + } + + public getType(): EntityName { + return EntityName.Text; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public containsPointOnShape(_point: Point): boolean { + return false; + } + + public async toJson(): Promise | null> { + return { + id: this.id, + type: EntityName.Text, + lineColor: this.lineColor, + lineWidth: this.lineWidth, + lineDash: this.lineDash, + layerId: this.layerId, + shapeData: { + label: this.label, + basePoint: { x: this.basePoint.x, y: this.basePoint.y }, + options: { + textDirection: { + x: this.options.textDirection.x, + y: this.options.textDirection.y, + }, + textAlign: this.options.textAlign, + textColor: this.options.textColor, + fontSize: this.options.fontSize, + fontFamily: this.options.fontFamily, + }, + }, + }; + } + + public static async fromJson(jsonEntity: JsonEntity): Promise { + if (!jsonEntity.shapeData) { + throw new Error('Invalid JSON entity of type Text: missing shapeData'); + } + const textEntity = new TextEntity( + jsonEntity.layerId || getActiveLayerId(), + jsonEntity.shapeData.label, + new Point(jsonEntity.shapeData.basePoint.x, jsonEntity.shapeData.basePoint.y), + { + textDirection: new Vector( + jsonEntity.shapeData.options.textDirection.x, + jsonEntity.shapeData.options.textDirection.y + ), + textAlign: jsonEntity.shapeData.options.textAlign, + textColor: jsonEntity.shapeData.options.textColor, + fontSize: jsonEntity.shapeData.options.fontSize, + fontFamily: jsonEntity.shapeData.options.fontFamily, + } + ); + textEntity.id = jsonEntity.id; + textEntity.lineColor = jsonEntity.lineColor; + textEntity.lineWidth = jsonEntity.lineWidth; + textEntity.lineDash = jsonEntity.lineDash ?? []; + return textEntity; + } +} + +export interface TextJsonData { + label: string; + basePoint: { x: number; y: number }; + options: { + textDirection: { x: number; y: number }; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts new file mode 100644 index 00000000..5e6a74d5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts @@ -0,0 +1,19 @@ +import { type Box, Point, Polygon } from '@flatten-js/core'; + +export function boxToPolygon(box: Box): Polygon { + return new Polygon([ + new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)), + new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)), + new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)), + new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)), + ]); +} + +export function twoPointBoxToPolygon(first: Point, second: Point): Polygon { + return new Polygon([ + new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)), + new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)), + new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)), + new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)), + ]); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts new file mode 100644 index 00000000..60af8821 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -0,0 +1,56 @@ +import { + getAngleGuideOriginPoint, + getAngleStep, + getHoveredSnapPoints, + getLayerById, + getScreenCanvasDrawController, + getShouldDrawHelpers, + setAngleGuideEntities, + setSnapPoint, + setSnapPointOnAngleGuide, +} from '../state.ts'; +import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts'; +import { getDrawHelpers } from './get-draw-guides.ts'; +import { queryEntitiesNearPoint } from './spatial-index.ts'; +import { compact } from 'es-toolkit'; + +/** + * Calculate angle guides and snap points + */ +export function calculateAngleGuidesAndSnapPoints() { + const angleStep = getAngleStep(); + const screenCanvasDrawController = getScreenCanvasDrawController(); + const screenScale = screenCanvasDrawController.getScreenScale(); + const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); + // ์Šค๋ƒ… ํ›„๋ณด: ๊ณต๊ฐ„ ์ธ๋ฑ์Šค๋กœ ๋งˆ์šฐ์Šค ์ฃผ๋ณ€๋งŒ ์กฐํšŒ (์ „ ์—”ํ‹ฐํ‹ฐ O(nยฒ) ๊ต์ฐจ ๊ณ„์‚ฐ ์ œ๊ฑฐ), + // ์ž ๊ธˆ ๋ ˆ์ด์–ด(b08-frame ๋“ฑ ์ฐธ์กฐ์šฉ)๋Š” ์Šค๋ƒ… ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•œ๋‹ค. + const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale; + const entities = queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + maxSnapDistance * 2, + ).filter(entity => !getLayerById(entity.layerId)?.isLocked); + const hoveredSnapPoints = getHoveredSnapPoints(); + + const eligibleHoveredSnapPoints = hoveredSnapPoints.filter( + hoveredSnapPoint => + hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME, + ); + + const eligibleHoveredPoints = eligibleHoveredSnapPoints.map( + hoveredSnapPoint => hoveredSnapPoint.snapPoint.point, + ); + + if (getShouldDrawHelpers()) { + const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers( + entities, + compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]), + worldMouseLocation, + angleStep, + maxSnapDistance, + ); + setAngleGuideEntities(angleGuides); + setSnapPoint(entitySnapPoint); + setSnapPointOnAngleGuide(angleSnapPoint); + } +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts new file mode 100644 index 00000000..e5188651 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { containRectangle } from './contain-rect.ts'; + +describe('containRectangle', () => { + it('scales down a larger rectangle to fit into a smaller wrapper', () => { + const result = containRectangle( + 0, + 0, + 200, + 200, // contained: a 200x200 square + 0, + 0, + 100, + 100, // wrapper: a 100x100 square + ); + // Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted, + // but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100. + expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + }); + + it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => { + const result = containRectangle( + 0, + 0, + 50, + 50, // contained: 50x50 + 0, + 0, + 200, + 200, // wrapper: 200x200 + ); + // Expected: scale up by factor of 4 to fill as much space as possible while containing + // But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0). + expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 }); + }); + + it('maintains aspect ratio when wrapper is rectangular and contained is square', () => { + const result = containRectangle( + 0, + 0, + 50, + 50, // contained: 50x50 square + 0, + 0, + 200, + 100, // wrapper: 200x100 + ); + // Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2. + // Min scale = 2, so final size = 100x100. + // Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset. + // Result = (50,0) to (150,100) + expect(result.minX).toBeCloseTo(50); + expect(result.minY).toBeCloseTo(0); + expect(result.maxX).toBeCloseTo(150); + expect(result.maxY).toBeCloseTo(100); + }); + + it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => { + const result = containRectangle( + 0, + 0, + 200, + 50, // contained: 200x50 + 0, + 0, + 300, + 100, // wrapper: 300x100 + ); + // Contained AR = 200/50 = 4:1 + // Wrapper AR = 300/100 = 3:1 + // To fit inside 300x100: + // Scale factors: width scale = 300/200=1.5, height scale=100/50=2. + // min scale = 1.5 + // Final size: 200*1.5=300 width, 50*1.5=75 height + // Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully + expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 }); + }); + + it('handles zero-width/height contained rectangle gracefully', () => { + // Contained rectangle is essentially a line or point + const result = containRectangle( + 10, + 10, + 10, + 10, // contained has 0 width/height + 0, + 0, + 200, + 200, // wrapper + ); + // Center as a single point at (100,100) + expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 }); + }); + + it('does not scale if contained rectangle already fits', () => { + const result = containRectangle( + 0, + 0, + 100, + 100, // contained fits easily + 0, + 0, + 300, + 300, // wrapper + ); + // Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3. + // But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding, + // So final size is 300x300, centered at (0,0). + expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 }); + }); + + it('correctly centers when wrapper and contained have different origins', () => { + const result = containRectangle( + 5, + 5, + 15, + 35, // contained: 10 wide x 30 tall + 10, + 20, + 110, + 220, // wrapper: 100x200 + ); + // Wrapper size: 100x200 + // Contained size: 10x30 + // Scale factors: width scale = 100/10=10, height scale=200/30 โ‰ˆ 6.666... + // min scale = 6.666... + // Final size: width = 10 * 6.666... โ‰ˆ 66.666..., height = 30 * 6.666... โ‰ˆ 200 + // After scaling, top-left corner should be placed so it centers: + // Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minXโ‰ˆ26.666... + // Vertical center: fits height exactly, so minY=20, maxY=20+200=220 + expect(result.minX).toBeCloseTo(26.6667); + expect(result.minY).toBeCloseTo(20); + expect(result.maxX).toBeCloseTo(93.3333); + expect(result.maxY).toBeCloseTo(220); + }); + + it('handles negative coordinates in wrapper and contained rectangles', () => { + const result = containRectangle( + -50, + -25, + 50, + 25, // contained: 100 wide x 50 tall + -100, + -50, + 100, + 50, // wrapper: 200 wide x 100 tall + ); + // Scale factors: width scale = 200/100=2, height scale=100/50=2 + // min scale = 2, final size: 200x100 exactly. + // Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y) + // After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50 + expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 }); + }); + + it('handles negative coordinates in contained rectangles', () => { + const result = containRectangle( + -50, + -25, + 50, + 25, // contained: 100 wide x 50 tall + 0, + 0, + 100, + 100, // wrapper: 100 wide x 100 tall + ); + expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 }); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts new file mode 100644 index 00000000..3e8d8f56 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts @@ -0,0 +1,52 @@ +export function containRectangle( + containedRectMinX: number, + containedRectMinY: number, + containedRectMaxX: number, + containedRectMaxY: number, + wrapperRectMinX: number, + wrapperRectMinY: number, + wrapperRectMaxX: number, + wrapperRectMaxY: number, +): { minX: number; minY: number; maxX: number; maxY: number } { + // Calculate the width and height of the wrapper rectangle + const wrapperWidth = wrapperRectMaxX - wrapperRectMinX; + const wrapperHeight = wrapperRectMaxY - wrapperRectMinY; + + // Calculate the width and height of the contained rectangle + const containedWidth = containedRectMaxX - containedRectMinX; + const containedHeight = containedRectMaxY - containedRectMinY; + + // Edge case: if contained dimensions are zero, just center as a point + if (containedWidth === 0 || containedHeight === 0) { + const centerX = wrapperRectMinX + wrapperWidth / 2; + const centerY = wrapperRectMinY + wrapperHeight / 2; + return { + minX: centerX, + minY: centerY, + maxX: centerX, + maxY: centerY, + }; + } + + // Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio + const scale = Math.min( + wrapperWidth / containedWidth, + wrapperHeight / containedHeight, + ); + + // Compute final displayed dimensions + const displayWidth = containedWidth * scale; + const displayHeight = containedHeight * scale; + + // Compute offsets to center the scaled rectangle + const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2; + const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2; + + // Return the final coordinates of the scaled and centered rectangle + return { + minX: offsetX, + minY: offsetY, + maxX: offsetX + displayWidth, + maxY: offsetY + displayHeight, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts new file mode 100644 index 00000000..846d08b5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { svgPathToSegments } from './convert-svg-path-to-line-segments'; + +describe('svgPathToSegments', () => { + it('should handle simple move and line commands', () => { + const path = 'M 10 10 L 20 20'; + const segments = svgPathToSegments(path); + expect(segments).toEqual([ + { x1: 10, y1: 10, x2: 20, y2: 20 }, + ]); + }); + + it('should handle relative line commands', () => { + const path = 'M 10 10 l 10 10'; + const segments = svgPathToSegments(path); + expect(segments).toEqual([ + { x1: 10, y1: 10, x2: 20, y2: 20 }, + ]); + }); + + it('should handle horizontal and vertical lines', () => { + const path = 'M 10 10 H 20 V 30'; + const segments = svgPathToSegments(path); + expect(segments).toEqual([ + { x1: 10, y1: 10, x2: 20, y2: 10 }, + { x1: 20, y1: 10, x2: 20, y2: 30 }, + ]); + }); + + it('should handle the close path (Z) command', () => { + const path = 'M 10 10 L 20 10 L 20 20 Z'; + const segments = svgPathToSegments(path); + expect(segments).toEqual([ + { x1: 10, y1: 10, x2: 20, y2: 10 }, + { x1: 20, y1: 10, x2: 20, y2: 20 }, + // The close command draws a segment back to the starting point. + { x1: 20, y1: 20, x2: 10, y2: 10 }, + ]); + }); + + it('should approximate cubic bezier curves', () => { + const path = 'M 10 10 C 20 20 30 20 40 10'; + const segments = svgPathToSegments(path); + // Since the cubic curve is subdivided into multiple segments, + // we expect more than one segment. + expect(segments.length).toBeGreaterThan(1); + + // Check that the approximation starts at (10,10) + expect(segments[0].x1).toBeCloseTo(10, 5); + expect(segments[0].y1).toBeCloseTo(10, 5); + + // Check that the approximation ends at (40,10) + const lastSegment = segments[segments.length - 1]; + expect(lastSegment.x2).toBeCloseTo(40, 5); + expect(lastSegment.y2).toBeCloseTo(10, 5); + }); + + it('should approximate quadratic bezier curves', () => { + const path = 'M 10 10 Q 20 20 30 10'; + const segments = svgPathToSegments(path); + expect(segments.length).toBeGreaterThan(1); + + // Check that the approximation starts at (10,10) + expect(segments[0].x1).toBeCloseTo(10, 5); + expect(segments[0].y1).toBeCloseTo(10, 5); + + // And that it ends at (30,10) + const lastSegment = segments[segments.length - 1]; + expect(lastSegment.x2).toBeCloseTo(30, 5); + expect(lastSegment.y2).toBeCloseTo(10, 5); + }); + + it('should approximate arcs', () => { + // This arc command goes from (10,10) to (20,10) with radii of 10. + const path = 'M 10 10 A 10 10 0 0 1 20 10'; + const segments = svgPathToSegments(path); + expect(segments.length).toBeGreaterThan(1); + + // Verify that the arc approximation starts at (10,10) + expect(segments[0].x1).toBeCloseTo(10, 5); + expect(segments[0].y1).toBeCloseTo(10, 5); + + // And ends at (20,10) + const lastSegment = segments[segments.length - 1]; + expect(lastSegment.x2).toBeCloseTo(20, 5); + expect(lastSegment.y2).toBeCloseTo(10, 5); + }); + + it('should handle triangles', () => { + // This arc command goes from (10,10) to (20,10) with radii of 10. + const path = 'M 152.982 124.448 L 176.73 156.849 L 129.234 156.849 L 152.982 124.448 Z'; + const segments = svgPathToSegments(path); + expect(segments.length).toBe(3); + + // Verify first point of triangle + expect(segments[2].x2).toBeCloseTo(152.982, 5); + expect(segments[2].y2).toBeCloseTo(124.448, 5); + + expect(segments[0].x1).toBeCloseTo(152.982, 5); + expect(segments[0].y1).toBeCloseTo(124.448, 5); + + // Verify second point of triangle + expect(segments[0].x2).toBeCloseTo(176.73, 5); + expect(segments[0].y2).toBeCloseTo(156.849, 5); + + expect(segments[1].x1).toBeCloseTo(176.73, 5); + expect(segments[1].y1).toBeCloseTo(156.849, 5); + + // Verify third point of triangle + expect(segments[1].x2).toBeCloseTo(129.234, 5); + expect(segments[1].y2).toBeCloseTo(156.849, 5); + + expect(segments[2].x1).toBeCloseTo(129.234, 5); + expect(segments[2].y1).toBeCloseTo(156.849, 5); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts new file mode 100644 index 00000000..cd8b83cc --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts @@ -0,0 +1,471 @@ +import {toast} from 'react-toastify'; + +// A small type alias for clarity. +type Point = { x: number; y: number }; + +// Helper: returns the midpoint between two points. +function midpoint(a: Point, b: Point): Point { + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +} + +// Helper: distance from point p to the line defined by points a and b. +function distancePointToLine(p: Point, a: Point, b: Point): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const mag = Math.sqrt(dx * dx + dy * dy); + if (mag === 0) return Math.hypot(p.x - a.x, p.y - a.y); + return Math.abs(dy * p.x - dx * p.y + b.x * a.y - b.y * a.x) / mag; +} + +/** + * Recursively subdivides a cubic Bezier until the control points lie + * close enough (within tolerance) to the chord. + */ +function approximateCubicBezier( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + tolerance: number +): Point[] { + function recursive(a: Point, b: Point, c: Point, d: Point, tol: number): Point[] { + // Check the โ€œflatnessโ€ by measuring the distance from the two control points + // to the line connecting the endpoints. + const d1 = distancePointToLine(b, a, d); + const d2 = distancePointToLine(c, a, d); + if (Math.max(d1, d2) < tol) { + return [a, d]; + } + // Subdivide using de Casteljauโ€™s algorithm. + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const cd = midpoint(c, d); + const abc = midpoint(ab, bc); + const bcd = midpoint(bc, cd); + const abcd = midpoint(abc, bcd); + const left = recursive(a, ab, abc, abcd, tol); + const right = recursive(abcd, bcd, cd, d, tol); + // Avoid duplicating the middle point. + return left.slice(0, -1).concat(right); + } + return recursive(p0, p1, p2, p3, tolerance); +} + +/** + * Recursively subdivides a quadratic Bezier curve. + */ +function approximateQuadraticBezier(p0: Point, p1: Point, p2: Point, tolerance: number): Point[] { + function recursive(a: Point, b: Point, c: Point, tol: number): Point[] { + const d = distancePointToLine(b, a, c); + if (d < tol) { + return [a, c]; + } + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const abc = midpoint(ab, bc); + const left = recursive(a, ab, abc, tol); + const right = recursive(abc, bc, c, tol); + return left.slice(0, -1).concat(right); + } + return recursive(p0, p1, p2, tolerance); +} + +/** + * Approximates an elliptical arc defined by the SVG โ€œAโ€ command. + * + * This function uses the standard SVG algorithm to compute the arcโ€™s + * center and angles and then divides the arc into small segments so that + * the chord error is below the given tolerance. + */ +function approximateArc( + p0: Point, + rx: number, + ry: number, + phi: number, + largeArcFlag: boolean, + sweepFlag: boolean, + p2: Point, + tolerance: number +): Point[] { + const phiRad = (phi * Math.PI) / 180; + const dx = (p0.x - p2.x) / 2; + const dy = (p0.y - p2.y) / 2; + let rxInternal = rx; + let ryInternal = ry; + + // Step 1: Compute the transformed start point. + const x1p = Math.cos(phiRad) * dx + Math.sin(phiRad) * dy; + const y1p = -Math.sin(phiRad) * dx + Math.cos(phiRad) * dy; + + // Ensure the radii are large enough. + let rxSq = rxInternal * rxInternal; + let rySq = ryInternal * ryInternal; + const x1pSq = x1p * x1p; + const y1pSq = y1p * y1p; + const lambda = x1pSq / rxSq + y1pSq / rySq; + if (lambda > 1) { + const factor = Math.sqrt(lambda); + rxInternal *= factor; + ryInternal *= factor; + rxSq = rxInternal * rxInternal; + rySq = ryInternal * ryInternal; + } + + // Step 2: Compute the center. + const sign = largeArcFlag === sweepFlag ? -1 : 1; + const numerator = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq; + const denominator = rxSq * y1pSq + rySq * x1pSq; + const coefficient = sign * Math.sqrt(Math.max(0, numerator / denominator)); + const cxp = (coefficient * (rxInternal * y1p)) / ryInternal; + const cyp = (coefficient * (-ryInternal * x1p)) / rxInternal; + + // Step 3: Transform back to original coordinates. + const cx = Math.cos(phiRad) * cxp - Math.sin(phiRad) * cyp + (p0.x + p2.x) / 2; + const cy = Math.sin(phiRad) * cxp + Math.cos(phiRad) * cyp + (p0.y + p2.y) / 2; + + // Step 4: Compute the start and delta angles. + function angle(u: Point, v: Point): number { + const dot = u.x * v.x + u.y * v.y; + const len = Math.sqrt((u.x * u.x + u.y * u.y) * (v.x * v.x + v.y * v.y)); + let ang = Math.acos(Math.max(-1, Math.min(1, dot / len))); + if (u.x * v.y - u.y * v.x < 0) ang = -ang; + return ang; + } + + const v1 = { x: (x1p - cxp) / rx, y: (y1p - cyp) / ry }; + const v2 = { x: (-x1p - cxp) / rx, y: (-y1p - cyp) / ry }; + const startAngle = angle({ x: 1, y: 0 }, v1); + let deltaAngle = angle(v1, v2); + if (!sweepFlag && deltaAngle > 0) { + deltaAngle -= 2 * Math.PI; + } else if (sweepFlag && deltaAngle < 0) { + deltaAngle += 2 * Math.PI; + } + const totalAngle = deltaAngle; + + // Choose the number of segments so that the chord error is below tolerance. + const rApprox = Math.max(rx, ry); + const segCount = Math.max( + 1, + Math.ceil(Math.abs(totalAngle) / (2 * Math.acos(1 - tolerance / rApprox))) + ); + const points: Point[] = []; + for (let i = 0; i <= segCount; i++) { + const theta = startAngle + (totalAngle * i) / segCount; + const x = + cx + rx * Math.cos(phiRad) * Math.cos(theta) - ry * Math.sin(phiRad) * Math.sin(theta); + const y = + cy + rx * Math.sin(phiRad) * Math.cos(theta) + ry * Math.cos(phiRad) * Math.sin(theta); + points.push({ x, y }); + } + return points; +} + +// A simple SVG path command type. +interface SvgCommand { + type: string; + args: number[]; +} + +/** + * A basic parser for an SVG path string. It splits the string into commands + * (like "M", "L", "C", etc.) and extracts the numeric parameters. + */ +function parseSvgPath(path: string): SvgCommand[] { + const commands: SvgCommand[] = []; + const re = /([MmLlHhVvCcQqAaZz])([^MmLlHhVvCcQqAaZz]*)/g; + let match: RegExpExecArray | null = re.exec(path); + while (match !== null) { + const type = match[1]; + const argsStr = match[2].trim(); + const args: number[] = []; + if (argsStr.length > 0) { + // Match numbers (including decimals, negatives, exponents) + const numberRe = /-?\d*\.?\d+(?:e[-+]?\d+)?/gi; + let numberMatch: RegExpExecArray | null = numberRe.exec(argsStr); + while (numberMatch !== null) { + args.push(Number.parseFloat(numberMatch[0])); + numberMatch = numberRe.exec(argsStr); + } + } + commands.push({ type, args }); + match = re.exec(path); + } + return commands; +} + +/** + * Converts an SVG path (a string) into a list of straight-line segments. + * + * Each segment is represented as an object with start point (x1,y1) + * and end point (x2,y2). Curved path segments (cubic, quadratic, arc) + * are approximated with a polyline whose error is below a given tolerance. + * + * @param svgPath - An SVG path string (for example, "M 152.982 124.448 L 176.73 156.849 โ€ฆ") + * @returns An array of line segments. + */ +export function svgPathToSegments( + svgPath: string +): { x1: number; y1: number; x2: number; y2: number }[] { + const segments: { x1: number; y1: number; x2: number; y2: number }[] = []; + let current: Point = { x: 0, y: 0 }; + let startPoint: Point = { x: 0, y: 0 }; + const tolerance = 0.5; // adjust this value to get a closer or looser approximation + + const commands = parseSvgPath(svgPath); + + for (const command of commands) { + // Destructure the command type and its numeric arguments. + let type: string = command.type; + const args: number[] = command.args; + let idx = 0; + if (type.toLowerCase() === 'z') { + // Close the current subpath. + if (current.x === startPoint.x && current.y === startPoint.y) { + idx++; + continue; + } + segments.push({ x1: current.x, y1: current.y, x2: startPoint.x, y2: startPoint.y }); + current = { ...startPoint }; + // "Z" has no arguments so exit the loop. + idx++; + continue; // end of the line + } + // Some commands allow multiple coordinate pairs. + while (idx < args.length || type.toLowerCase() === 'z') { + switch (type) { + case 'M': { + // Absolute moveto. + const x = args[idx++]; + const y = args[idx++]; + current = { x, y }; + startPoint = { x, y }; + // If extra pairs follow, treat them as implicit "L" commands. + type = 'L'; + break; + } + case 'm': { + // Relative moveto. + const x = current.x + args[idx++]; + const y = current.y + args[idx++]; + current = { x, y }; + startPoint = { x, y }; + type = 'l'; + break; + } + case 'L': { + // Absolute lineto. + const x = args[idx++]; + const y = args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: x, y2: y }); + current = { x, y }; + break; + } + case 'l': { + // Relative lineto. + const x = current.x + args[idx++]; + const y = current.y + args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: x, y2: y }); + current = { x, y }; + break; + } + case 'H': { + // Absolute horizontal lineto. + const x = args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: x, y2: current.y }); + current = { x, y: current.y }; + break; + } + case 'h': { + // Relative horizontal lineto. + const x = current.x + args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: x, y2: current.y }); + current = { x, y: current.y }; + break; + } + case 'V': { + // Absolute vertical lineto. + const y = args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: current.x, y2: y }); + current = { x: current.x, y }; + break; + } + case 'v': { + // Relative vertical lineto. + const y = current.y + args[idx++]; + segments.push({ x1: current.x, y1: current.y, x2: current.x, y2: y }); + current = { x: current.x, y }; + break; + } + case 'C': { + // Cubic Bezier: parameters are x1, y1, x2, y2, x, y. + const x1 = args[idx++]; + const y1 = args[idx++]; + const x2 = args[idx++]; + const y2 = args[idx++]; + const x = args[idx++]; + const y = args[idx++]; + const curvePoints = approximateCubicBezier( + current, + { x: x1, y: y1 }, + { x: x2, y: y2 }, + { x, y }, + tolerance + ); + // Convert the polyline into segments. + for (let i = 0; i < curvePoints.length - 1; i++) { + segments.push({ + x1: curvePoints[i].x, + y1: curvePoints[i].y, + x2: curvePoints[i + 1].x, + y2: curvePoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + case 'c': { + // Relative cubic Bezier. + const x1 = current.x + args[idx++]; + const y1 = current.y + args[idx++]; + const x2 = current.x + args[idx++]; + const y2 = current.y + args[idx++]; + const x = current.x + args[idx++]; + const y = current.y + args[idx++]; + const curvePoints = approximateCubicBezier( + current, + { x: x1, y: y1 }, + { x: x2, y: y2 }, + { x, y }, + tolerance + ); + for (let i = 0; i < curvePoints.length - 1; i++) { + segments.push({ + x1: curvePoints[i].x, + y1: curvePoints[i].y, + x2: curvePoints[i + 1].x, + y2: curvePoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + case 'Q': { + // Quadratic Bezier: parameters are x1, y1, x, y. + const x1 = args[idx++]; + const y1 = args[idx++]; + const x = args[idx++]; + const y = args[idx++]; + const curvePoints = approximateQuadraticBezier( + current, + { x: x1, y: y1 }, + { x, y }, + tolerance + ); + for (let i = 0; i < curvePoints.length - 1; i++) { + segments.push({ + x1: curvePoints[i].x, + y1: curvePoints[i].y, + x2: curvePoints[i + 1].x, + y2: curvePoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + case 'q': { + // Relative quadratic Bezier. + const x1 = current.x + args[idx++]; + const y1 = current.y + args[idx++]; + const x = current.x + args[idx++]; + const y = current.y + args[idx++]; + const curvePoints = approximateQuadraticBezier( + current, + { x: x1, y: y1 }, + { x, y }, + tolerance + ); + for (let i = 0; i < curvePoints.length - 1; i++) { + segments.push({ + x1: curvePoints[i].x, + y1: curvePoints[i].y, + x2: curvePoints[i + 1].x, + y2: curvePoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + case 'A': { + // Arc: parameters are rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y. + const rx = args[idx++]; + const ry = args[idx++]; + const xAxisRotation = args[idx++]; + const largeArcFlag = !!args[idx++]; + const sweepFlag = !!args[idx++]; + const x = args[idx++]; + const y = args[idx++]; + const arcPoints = approximateArc( + current, + rx, + ry, + xAxisRotation, + largeArcFlag, + sweepFlag, + { x, y }, + tolerance + ); + for (let i = 0; i < arcPoints.length - 1; i++) { + segments.push({ + x1: arcPoints[i].x, + y1: arcPoints[i].y, + x2: arcPoints[i + 1].x, + y2: arcPoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + case 'a': { + // Relative arc. + const rx = args[idx++]; + const ry = args[idx++]; + const xAxisRotation = args[idx++]; + const largeArcFlag = !!args[idx++]; + const sweepFlag = !!args[idx++]; + const x = current.x + args[idx++]; + const y = current.y + args[idx++]; + const arcPoints = approximateArc( + current, + rx, + ry, + xAxisRotation, + largeArcFlag, + sweepFlag, + { x, y }, + tolerance + ); + for (let i = 0; i < arcPoints.length - 1; i++) { + segments.push({ + x1: arcPoints[i].x, + y1: arcPoints[i].y, + x2: arcPoints[i + 1].x, + y2: arcPoints[i + 1].y, + }); + } + current = { x, y }; + break; + } + default: { + toast.error(`Unsupported SVG command type: ${type} ${args.join(' ')}`); + console.error(`unsupported SVG command type: ${type} ${args.join(' ')}`); + // Unsupported commands can be skipped. + idx = args.length; + break; + } + } + } + } + + return segments; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/distance-between-points.ts b/B07_DesignDetail/openwebcad/src/helpers/distance-between-points.ts new file mode 100644 index 00000000..1898a5ac --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/distance-between-points.ts @@ -0,0 +1,3 @@ +export function pointDistance(point1: { x: number; y: number }, point2: { x: number; y: number }) { + return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts b/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts new file mode 100644 index 00000000..71ff2abe --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts @@ -0,0 +1,214 @@ +import {Point} from '@flatten-js/core'; +import {CURSOR_SIZE, GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, SNAP_POINT_COLOR, SNAP_POINT_SIZE,} from '../App.consts'; +import {type SnapPoint, SnapPointType} from '../App.types'; +import type {DrawController} from '../drawControllers/DrawController'; +import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController'; +import type {Entity} from '../entities/Entity'; +import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state'; +import {toast} from 'react-toastify'; + +export function drawEntities(drawController: DrawController, entities: Entity[]) { + for (const entity of entities) { + const layer = getLayerById(entity.layerId); + if (!layer) { + toast.error(`Failed to find layer for entity: ${entity?.id}`); + console.error('Failed to find layer for entity: ', entity); + continue; + } + if (!layer?.isVisible) { + continue; // Layer not visible, skip drawing + } + drawController.setLineStyles( + isEntityHighlighted(entity), + isEntitySelected(entity), + entity.lineColor, + entity.lineWidth, + [] + ); + entity.draw(drawController); + } +} + +export function drawDebugEntities(drawController: DrawController, debugEntities: Entity[]) { + for (const debugEntity of debugEntities) { + drawController.setLineStyles( + isEntityHighlighted(debugEntity), + isEntitySelected(debugEntity), + '#FF5500', + 1, + [] + ); + debugEntity.draw(drawController); + } +} + +/** + * Draw the point to which the mouse will snap when the user clicks to draw the next point + * @param drawController + * @param snapPointInfo + * @param isMarked indicates that the point has been hovered lang enough to draw guides from this point + */ +export function drawSnapPoint( + drawController: ScreenCanvasDrawController, + snapPointInfo: SnapPoint | null, + isMarked: boolean +) { + if (!snapPointInfo) return; + const snapPoint = snapPointInfo.point; + const screenSnapPoint = drawController.worldToTarget(snapPoint); + + drawController.setLineStyles(false, false, SNAP_POINT_COLOR, 1, []); + + if (isMarked) { + // We will draw a plus sign inside the current snap point to indicate that it is marked + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y) + ); + drawController.drawLineScreen( + new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + } + + switch (snapPointInfo.type) { + case SnapPointType.LineEndPoint: + // Endpoint is marked with a square + + // top + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2) + ); + + // right + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + // bottom + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + // left + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + break; + + case SnapPointType.LineMidPoint: + // Midpoint is shown with a triangle + + drawController.drawLineScreen( + new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2) + ); + break; + + case SnapPointType.AngleGuide: + // Angle guide is shown with an hourglass + + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2) + ); + break; + + case SnapPointType.Intersection: + // Intersection is shown with a cross + + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + break; + + case SnapPointType.CircleCenter: + // Circle center is shown with a circle + drawController.drawArcScreen(screenSnapPoint, SNAP_POINT_SIZE / 2, 0, 2 * Math.PI, true); + break; + + case SnapPointType.CircleCardinal: + // Circle cardinal is shown with a diamond + drawController.drawLineScreen( + new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y) + ); + drawController.drawLineScreen( + new Point(screenSnapPoint.x - SNAP_POINT_SIZE / 2, screenSnapPoint.y), + new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2) + ); + drawController.drawLineScreen( + new Point(screenSnapPoint.x, screenSnapPoint.y + SNAP_POINT_SIZE / 2), + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y) + ); + drawController.drawLineScreen( + new Point(screenSnapPoint.x + SNAP_POINT_SIZE / 2, screenSnapPoint.y), + new Point(screenSnapPoint.x, screenSnapPoint.y - SNAP_POINT_SIZE / 2) + ); + break; + } +} + +export function drawHelpers(drawController: DrawController, helperEntities: Entity[]) { + for (const entity of helperEntities) { + drawController.setLineStyles( + isEntityHighlighted(entity), + isEntitySelected(entity), + GUIDE_LINE_COLOR, + GUIDE_LINE_WIDTH, + GUIDE_LINE_STYLE + ); + entity.draw(drawController); + } +} + +export function drawCursor(drawController: ScreenCanvasDrawController) { + drawController.setLineStyles(false, false, '#FFF', 1, []); + + const screenMouseLocation = drawController.getScreenMouseLocation(); + + drawController.drawLineScreen( + new Point(screenMouseLocation.x, screenMouseLocation.y - CURSOR_SIZE), + new Point(screenMouseLocation.x, screenMouseLocation.y + CURSOR_SIZE) + ); + drawController.drawLineScreen( + new Point(screenMouseLocation.x - CURSOR_SIZE, screenMouseLocation.y), + new Point(screenMouseLocation.x + CURSOR_SIZE, screenMouseLocation.y) + ); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/draw.ts b/B07_DesignDetail/openwebcad/src/helpers/draw.ts new file mode 100644 index 00000000..337be55c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/draw.ts @@ -0,0 +1,69 @@ +import { + drawCursor, + drawDebugEntities, + drawEntities, + drawHelpers, + drawSnapPoint, +} from './draw-functions'; +import { getClosestSnapPoint } from './get-closest-snap-point'; +import { isPointEqual } from './is-point-equal'; +import { HOVERED_SNAP_POINT_TIME } from '../App.consts'; +import { compact } from 'es-toolkit'; +import { + getAngleGuideEntities, + getDebugEntities, + getEntities, + getGhostHelperEntities, + getHighlightedEntityIds, + getHoveredSnapPoints, + getInputController, + getShouldDrawCursor, + getSnapPoint, + getSnapPointOnAngleGuide, +} from '../state'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { drawScene } from './scene-cache'; + +/** + * Hover highlight is excluded from the static scene cache (it changes every + * mouse move) โ€” re-draw the few highlighted entities on top of the blit. + */ +function drawHighlightedEntities(drawController: ScreenCanvasDrawController) { + const highlightedIds = getHighlightedEntityIds(); + if (!highlightedIds.length) return; + const idSet = new Set(highlightedIds); + drawEntities( + drawController, + getEntities().filter(entity => idSet.has(entity.id)), + ); +} + +export function draw(drawController: ScreenCanvasDrawController) { + drawController.clear(); + + // Static scene (all entities): cached bitmap blit, rebuilt only when needed. + drawScene(drawController, performance.now()); + drawHighlightedEntities(drawController); + + drawHelpers(drawController, getAngleGuideEntities()); + drawEntities(drawController, getGhostHelperEntities()); + drawDebugEntities(drawController, getDebugEntities()); + + const { snapPoint: closestSnapPoint } = getClosestSnapPoint( + compact([getSnapPoint(), getSnapPointOnAngleGuide()]), + drawController.getWorldMouseLocation(), + ); + const isMarked = + !!closestSnapPoint && + getHoveredSnapPoints().some( + hoveredSnapPoint => + hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME && + isPointEqual(hoveredSnapPoint.snapPoint.point, closestSnapPoint.point), + ); + drawSnapPoint(drawController, closestSnapPoint, isMarked); + + if (getShouldDrawCursor()) { + drawCursor(drawController); + getInputController().draw(drawController); + } +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts new file mode 100644 index 00000000..efd39457 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts @@ -0,0 +1,50 @@ +import {EntityName} from '../entities/Entity.ts'; +import type {JsonDrawingFileSerialized} from './import-export-handlers/export-entities-to-json.ts'; + +export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = { + entities: [ + { + id: 'ef6a4059-b477-4f53-af00-42241efae328', + type: EntityName.Line, + lineColor: '#fff', + lineWidth: 1, + layerId: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50', + shapeData: { + startPoint: { + x: 276.92367603039963, + y: 1172.4901562767805, + }, + endPoint: { + x: 524.8487532291128, + y: 1172.4901562767805, + }, + }, + }, + { + id: '8656dec4-00ae-4042-ba67-1f7d0056079b', + type: EntityName.Arc, + lineColor: '#fff', + lineWidth: 1, + layerId: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50', + shapeData: { + center: { + x: 524.8487532291128, + y: 1015.5664802463798, + }, + radius: 156.92367603040066, + startAngle: 0, + // endAngle: (2 * Math.PI * 3) / 4, + endAngle: 1.5707963267948966, + counterClockwise: true, + }, + }, + ], + layers: [ + { + id: 'e9d841dd-7ee4-4bd8-8cfd-b8381c73fd50', + isLocked: false, + isVisible: true, + name: 'Default', + }, + ], +}; diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts new file mode 100644 index 00000000..6df72399 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts @@ -0,0 +1,16 @@ +import {Point} from "@flatten-js/core"; +import {describe, expect, it} from 'vitest'; +import {findClosestEntity} from './find-closest-entity'; +import {arcAndLineEntitiesMock} from "./find-closest-entity.mocks.ts"; +import {getEntitiesAndLayersFromJsonObject,} from './import-export-handlers/import-entities-from-json.ts'; + +describe('findClosestEntity', () => { + it('should return the arc as the closest entity', async () => { + const mockEntitiesAndLayers = await getEntitiesAndLayersFromJsonObject(arcAndLineEntitiesMock); + const clickPoint = new Point(393, 1108); + const closestEntityInfo = findClosestEntity(clickPoint, mockEntitiesAndLayers.entities); + expect(closestEntityInfo).toBeDefined(); + if (!closestEntityInfo) return; + expect(closestEntityInfo.entity).toEqual(mockEntitiesAndLayers.entities.at(-1)); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.ts new file mode 100644 index 00000000..dfef8d27 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.ts @@ -0,0 +1,24 @@ +import type {Point, Segment} from '@flatten-js/core'; +import type {Entity} from '../entities/Entity'; + +export function findClosestEntity( + worldPoint: Point, + entities: Entity[] +): { distance: number; segment: Segment; entity: EntityType } { + let closestEntity = null; + let closestDistanceInfo: [number, Segment | null] = [Number.MAX_SAFE_INTEGER, null]; + for (const entity1 of entities) { + const distanceInfo = entity1.distanceTo(worldPoint); + if (!distanceInfo) continue; + if (distanceInfo[0] < closestDistanceInfo[0]) { + closestDistanceInfo = distanceInfo; + closestEntity = entity1; + } + } + + return { + distance: closestDistanceInfo[0], + segment: closestDistanceInfo[1] as Segment, + entity: closestEntity as EntityType, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts new file mode 100644 index 00000000..9b13a698 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts @@ -0,0 +1,44 @@ +import type { Arc, Point } from '@flatten-js/core'; +import { uniqWith } from 'es-toolkit'; +import { isPointEqual } from './is-point-equal'; +import type { ArcEntity } from '../entities/ArcEntity'; +import { sortPointsOnArc } from './sort-points-on-arc'; + +/** + * Find the closest points on the arc on both sides of the clicked point + * @param clickedPointOnShape + * @param arc + * @param pointsOnShape + */ +export function findNeighboringPointsOnArc( + clickedPointOnShape: Point, + arc: ArcEntity, + pointsOnShape: Point[], +): [Point, Point] { + // Sort points from start point to endpoint + const sortedPoints = sortPointsOnArc( + uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual), + (arc.getShape() as Arc).center, + (arc.getShape() as Arc).start, + ); + + const indexOfClickedPoint: number = sortedPoints.findIndex(point => + isPointEqual(clickedPointOnShape, point), + ); + if (indexOfClickedPoint === -1) { + throw new Error( + 'Clicked point not found on line in function findNeighboringPointsOnArc', + ); + } + + // We must make sure that points lying on both sides of the 0 angle are still considered neighbors + // So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1) + return [ + sortedPoints[ + (indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length + ], + sortedPoints[ + (indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length + ], + ]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts new file mode 100644 index 00000000..b8dda812 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts @@ -0,0 +1,43 @@ +import type { Circle, Point } from '@flatten-js/core'; +import { uniqWith } from 'es-toolkit'; +import { isPointEqual } from './is-point-equal'; +import type { CircleEntity } from '../entities/CircleEntity'; +import { sortPointsOnCircle } from './sort-points-on-circle'; + +/** + * Find the closest points on the circle on both sides of the clicked point + * @param clickedPointOnShape + * @param circle + * @param pointsOnShape + */ +export function findNeighboringPointsOnCircle( + clickedPointOnShape: Point, + circle: CircleEntity, + pointsOnShape: Point[], +): [Point, Point] { + // Sort points from start point to endpoint + const sortedPoints = sortPointsOnCircle( + uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual), + (circle.getShape() as Circle).center, + ); + + const indexOfClickedPoint: number = sortedPoints.findIndex(point => + isPointEqual(clickedPointOnShape, point), + ); + if (indexOfClickedPoint === -1) { + throw new Error( + 'Clicked point not found on line in function findNeighboringPointsOnCircle', + ); + } + + // We must make sure that points lying on both sides of the 0 angle are still considered neighbors + // So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1) + return [ + sortedPoints[ + (indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length + ], + sortedPoints[ + (indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length + ], + ]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts new file mode 100644 index 00000000..c6b38a91 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts @@ -0,0 +1,41 @@ +import type { Point } from '@flatten-js/core'; +import { sortBy, uniqWith } from 'es-toolkit'; +import { isPointEqual } from './is-point-equal'; +import { pointDistance } from './distance-between-points'; + +/** + * Find the closest points on both sides of the clicked point + * @param clickedPointOnLine + * @param lineStartPoint + * @param lineEndPoint + * @param pointsOnLine + */ +export function findNeighboringPointsOnLine( + clickedPointOnLine: Point, + lineStartPoint: Point, + lineEndPoint: Point, + pointsOnLine: Point[], +): [Point, Point] { + // Sort points from start point to endpoint + const sortedPoints = sortBy( + uniqWith( + [lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint], + isPointEqual, + ), + [(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)], + ); + + const indexOfClickedPoint: number = sortedPoints.findIndex(point => + isPointEqual(clickedPointOnLine, point), + ); + if (indexOfClickedPoint === -1) { + throw new Error( + 'Clicked point not found on line in function findNeighboringPointsOnLine', + ); + } + + return [ + sortedPoints[indexOfClickedPoint - 1] || lineStartPoint, + sortedPoints[indexOfClickedPoint + 1] || lineEndPoint, + ]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts new file mode 100644 index 00000000..0ab99254 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts @@ -0,0 +1,32 @@ +import {LineEntity} from '../entities/LineEntity'; +import {times} from './times'; +import {Point} from '@flatten-js/core'; +import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts"; +import {getActiveLayerId} from "../state.ts"; + +export function getAngleGuideLines( + firstPoint: Point, + angleStep: number, +): LineEntity[] { + // Only for 180 degrees since we draw lines that are infinite in both directions, + // so we only need to fill half a circle to fill the complete circle + return times(180 / angleStep, i => { + const angle = i * angleStep; + const angleRad = angle * (Math.PI / 180); + const x = firstPoint.x + Math.cos(angleRad); + const y = firstPoint.y + Math.sin(angleRad); + const angleLine = new LineEntity(getActiveLayerId(), + new Point( + firstPoint.x - 10000 * (x - firstPoint.x), + firstPoint.y - 10000 * (y - firstPoint.y), + ), + new Point( + firstPoint.x + 10000 * (x - firstPoint.x), + firstPoint.y + 10000 * (y - firstPoint.y), + ), + ); + angleLine.lineColor = ANGLE_GUIDES_COLOR; + angleLine.lineDash = ANGLE_GUIDES_DASH; + return angleLine + }); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts new file mode 100644 index 00000000..5df7cd6e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts @@ -0,0 +1,26 @@ +import {Point} from '@flatten-js/core'; +import {describe, expect, it} from 'vitest'; +import {TO_DEGREES} from '../App.consts.ts'; +import {getAngleWithXAxis} from './get-angle-with-x-axis.ts'; + +describe('getAngleWithXAxis', () => { + it('should return 90 degrees in radians', () => { + const angle = getAngleWithXAxis(new Point(0, 0), new Point(0, 10)); + expect(angle * TO_DEGREES).toBeCloseTo(90); + }); + + it('should return 0 degrees in radians', () => { + const angle = getAngleWithXAxis(new Point(0, 0), new Point(10, 0)); + expect(angle * TO_DEGREES).toBeCloseTo(0); + }); + + it('should return 45 degrees in radians', () => { + const angle = getAngleWithXAxis(new Point(0, 0), new Point(10, 10)); + expect(angle * TO_DEGREES).toBeCloseTo(45); + }); + + it('should return 270 degrees in radians', () => { + const angle = getAngleWithXAxis(new Point(0, 0), new Point(0, -10)); + expect(angle * TO_DEGREES).toBeCloseTo(270); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts new file mode 100644 index 00000000..dd1ed291 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts @@ -0,0 +1,12 @@ +import type {Point} from '@flatten-js/core'; + +export function getAngleWithXAxis(start: Point, end: Point): number { + const dx = end.x - start.x; + const dy = end.y - start.y; + + let radians = Math.atan2(dy, dx); // Y difference is the first parameter + if (radians < 0) { + radians += Math.PI * 2; + } + return radians; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts new file mode 100644 index 00000000..b5731a1f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts @@ -0,0 +1,32 @@ +import type {Entity} from "../entities/Entity.ts"; + +export interface BoundingBox { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export function getBoundingBoxOfMultipleEntities(entities: Entity[]): BoundingBox { + let minX = Number.MAX_VALUE; + let minY = Number.MAX_VALUE; + let maxX = Number.MIN_VALUE; + let maxY = Number.MIN_VALUE; + + for (const entity of entities) { + const boundingBox = entity.getBoundingBox(); + if (boundingBox) { + minX = Math.min(minX, boundingBox.xmin); + minY = Math.min(minY, boundingBox.ymin); + maxX = Math.max(maxX, boundingBox.xmax); + maxY = Math.max(maxY, boundingBox.ymax); + } + } + + return { + minX, + minY, + maxX, + maxY, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts new file mode 100644 index 00000000..826442a3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts @@ -0,0 +1,76 @@ +import type {Point} from '@flatten-js/core'; +import {type SnapPoint, SnapPointType} from '../App.types'; +import {pointDistance} from './distance-between-points'; + +// /** +// * Some points need to take priority over others when snapping to them. This multiplier is used to give a higher score to the points that should take priority +// */ +// const SNAP_POINT_PRIORITY: Record = { +// [SnapPointType.AngleGuide]: 1, +// [SnapPointType.LineEndPoint]: 5, +// [SnapPointType.Intersection]: 2, +// [SnapPointType.CircleCenter]: 3, +// [SnapPointType.CircleCardinal]: 3, +// [SnapPointType.CircleTangent]: 2, +// [SnapPointType.LineMidPoint]: 4, +// [SnapPointType.Point]: 5, +// }; + +/** + * Finds the closest snap point to the target point + * @param worldSnapPoints + * @param worldMouseLocation + */ +export function getClosestSnapPoint( + worldSnapPoints: SnapPoint[], + worldMouseLocation: Point +): { distance: number; snapPoint: SnapPoint | null } { + let closestSnapPoint: SnapPoint | null = null; + let closestDistance: number = Number.POSITIVE_INFINITY; + + for (const snapPoint1 of worldSnapPoints) { + const distance = pointDistance(snapPoint1.point, worldMouseLocation); + + if (distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint1; + } + } + + return { + distance: closestDistance, + snapPoint: closestSnapPoint, + }; +} + +/** + * First checks non angle guide snap points, then checks angle guide snap points + * @param worldSnapPoints + * @param worldMouseLocation + * @param maxDistance + */ +export function getClosestSnapPointWithinRadius( + worldSnapPoints: SnapPoint[], + worldMouseLocation: Point, + maxDistance: number +): SnapPoint | null { + const { distance: closestDistance, snapPoint: closestSnapPoint } = getClosestSnapPoint( + worldSnapPoints.filter((snapPoint) => snapPoint.type !== SnapPointType.AngleGuide), + worldMouseLocation + ); + + if (closestDistance < maxDistance) { + return closestSnapPoint; + } + + const { distance: angleGuideDistance, snapPoint: angleGuideSnapPoint } = getClosestSnapPoint( + worldSnapPoints.filter((snapPoint) => snapPoint.type === SnapPointType.AngleGuide), + worldMouseLocation + ); + + if (angleGuideDistance < maxDistance) { + return angleGuideSnapPoint; + } + + return null; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts new file mode 100644 index 00000000..d816e73f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts @@ -0,0 +1,91 @@ +import type {Point} from '@flatten-js/core'; +import {compact} from 'es-toolkit'; +import {SNAP_ANGLE_DISTANCE} from '../App.consts'; +import {type SnapPoint, SnapPointType} from '../App.types'; +import type {Entity} from '../entities/Entity'; +import type {LineEntity} from '../entities/LineEntity'; +import {findClosestEntity} from './find-closest-entity'; +import {getAngleGuideLines} from './get-angle-guide-lines'; +import {getClosestSnapPointWithinRadius} from './get-closest-snap-point'; +import {getIntersectionPoints} from './get-intersection-points'; + +/** + * Gets the angle guides from the angle point to the mouse if the mouse is close to one of the angle steps and also returns the closest snap point + * @param entities entities that are drawn on the canvas + * @param anglePoints the points that should get angle guides + * @param worldMouseLocation the current mouse location + * @param angleStep the angle in degrees at which the angle guides should be drawn + * @param maxSnapDistance The distance that the mouse can snap to a snap point or angle guide + */ +export function getDrawHelpers( + entities: Entity[], + anglePoints: Point[], + worldMouseLocation: Point, + angleStep: number, + maxSnapDistance: number +): { + angleGuides: LineEntity[]; + entitySnapPoint: SnapPoint | null; + angleSnapPoint: SnapPoint | null; +} { + let entitySnapPoint: SnapPoint | null = null; + let angleSnapPoint: SnapPoint | null = null; + const nearestAngleSnapPoints: SnapPoint[] = []; + const angleGuides: LineEntity[] = []; + + // draw angle guide + for (const anglePoint of anglePoints) { + const angleGuideLines = getAngleGuideLines(anglePoint, angleStep); + + const closestLineInfo = findClosestEntity(worldMouseLocation, angleGuideLines); + + if (closestLineInfo.distance < SNAP_ANGLE_DISTANCE) { + angleGuides.push(closestLineInfo.entity); + nearestAngleSnapPoints.push({ + point: closestLineInfo.segment.start, + type: SnapPointType.AngleGuide, + }); + } + } + + // Calculate snap points + const entitySnapPoints = [ + ...entities.flatMap((entity) => { + return entity.getSnapPoints(); + }), + ...getIntersectionPoints(compact(entities)).map((point) => ({ + point, + type: SnapPointType.Intersection, + })), + ]; + + const closestSnapPoint = getClosestSnapPointWithinRadius( + entitySnapPoints, + worldMouseLocation, + maxSnapDistance + ); + + if (closestSnapPoint) { + entitySnapPoint = closestSnapPoint; + } + + const angleSnapPoints = [ + ...nearestAngleSnapPoints, + // TODO only search for intersections between angle guides and other angle guides and between angle guides and entities, but not between entities + ...getIntersectionPoints([...compact(entities), ...angleGuides]).map((point) => ({ + point, + type: SnapPointType.Intersection, + })), + ]; + const closestAngleSnapPoint = getClosestSnapPointWithinRadius( + angleSnapPoints, + worldMouseLocation, + maxSnapDistance + ); + + if (closestAngleSnapPoint) { + angleSnapPoint = closestAngleSnapPoint; + } + + return { angleGuides, entitySnapPoint, angleSnapPoint }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-export-color.ts b/B07_DesignDetail/openwebcad/src/helpers/get-export-color.ts new file mode 100644 index 00000000..63aeeeb0 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-export-color.ts @@ -0,0 +1,14 @@ +/** + * If the color is white return black since the canvas background is black, it makes sense to invert the color for white + * @param color + */ +export function getExportColor(color: string): string { + if ( + color.toLowerCase() === 'white' || + color.toLowerCase() === '#fff' || + color.toLowerCase() === '#ffffff' + ) { + return '#000'; + } + return color; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts new file mode 100644 index 00000000..22eb2703 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts @@ -0,0 +1,22 @@ +import type { Entity } from '../entities/Entity'; +import type { Point } from '@flatten-js/core'; + +// TODO in the future we could optimize this by only calculating intersection points near the mouse +export function getIntersectionPoints(entities: Entity[]): Point[] { + const intersectionPoints: Point[] = []; + + // Calculate all intersections between all entities + for (let i = 0; i < entities.length; i++) { + const entity1 = entities[i]; + for (let j = i; j < entities.length; j++) { + // intersections are symmetric, so we only need to calculate them in one direction (let j = i) + + if (i === j) continue; // Do not check for intersections with yourself + + const entity2 = entities[j]; + intersectionPoints.push(...entity1.getIntersections(entity2)); + } + } + + return intersectionPoints; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-new-layer.ts b/B07_DesignDetail/openwebcad/src/helpers/get-new-layer.ts new file mode 100644 index 00000000..2815b1e7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-new-layer.ts @@ -0,0 +1,11 @@ +import type {Layer} from '../App.types.ts'; +import {getLayers} from '../state.ts'; + +export function getNewLayer(): Layer { + return { + id: crypto.randomUUID(), + isLocked: false, + isVisible: true, + name: `New layer ${getLayers().length}${1}`, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts new file mode 100644 index 00000000..3856a546 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -0,0 +1,49 @@ +import {type Point, Vector} from '@flatten-js/core'; +import { + type AbsolutePointInputEvent, + ActorEvent, + type DrawEvent, + type MouseClickEvent, + type NumberInputEvent, + type PointInputEvent, + type RelativePointInputEvent, +} from '../tools/tool.types.ts'; + +/** + * Various tools need to convert user input into a point + * This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events + * @param startPoint + * @param event + */ +export function getPointFromEvent(startPoint: Point | null, event: PointInputEvent): Point { + if (event.type === ActorEvent.DRAW) { + return (event as DrawEvent).drawController.getWorldMouseLocation(); + } + if (event.type === ActorEvent.MOUSE_CLICK) { + return (event as MouseClickEvent).worldMouseLocation; + } + if (event.type === ActorEvent.NUMBER_INPUT) { + if (!startPoint) { + throw new Error('Cannot get relative point by distance if no start point is provided'); + } + const distance = (event as NumberInputEvent).value; + // Direction indicated by the startPoint and the mouse location + const direction = new Vector( + event.worldMouseLocation.x - startPoint.x, + event.worldMouseLocation.y - startPoint.y + ); + const unitDirection = direction.normalize(); + return startPoint.translate(unitDirection.multiply(distance)); + } + if (event.type === ActorEvent.ABSOLUTE_POINT_INPUT) { + return (event as AbsolutePointInputEvent).value; + } + if (event.type === ActorEvent.RELATIVE_POINT_INPUT) { + if (!startPoint) { + throw new Error('Cannot get relative point by coordinates if no start point is provided'); + } + const relativeCoordinates = (event as RelativePointInputEvent).value; + return startPoint.clone().translate(relativeCoordinates.x, relativeCoordinates.y); + } + throw new Error('Received unexpected event type in DRAW_FINAL_LINE of LineEntity'); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts new file mode 100644 index 00000000..fc56a4da --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts @@ -0,0 +1,6 @@ +import type { Point } from '@flatten-js/core'; + +export interface PointWithAngle { + point: Point; + angle: number; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts new file mode 100644 index 00000000..9d46988d --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts @@ -0,0 +1,87 @@ +import {Point} from '@flatten-js/core'; +import {describe, expect, it} from 'vitest'; +import type {StartAndEndpointEntity} from '../App.types.ts'; +import {isClosedPolygon} from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity + +// Mock implementation for StartAndEndpointEntity +class MockEntity implements StartAndEndpointEntity { + constructor( + private start: Point, + private end: Point + ) {} + + getStartPoint(): Point { + return this.start; + } + + getEndPoint(): Point { + return this.end; + } +} + +describe('PolygonChecker.isClosedPolygon', () => { + it('should return true for a simple triangle', () => { + const A = new Point(0, 0); + const B = new Point(1, 0); + const C = new Point(0, 1); + const entities = [new MockEntity(A, B), new MockEntity(B, C), new MockEntity(C, A)]; + expect(isClosedPolygon(entities)).toBe(true); + }); + + it('should return true for a square with mixed ordering and reversed segments', () => { + const P1 = new Point(0, 0); + const P2 = new Point(1, 0); + const P3 = new Point(1, 1); + const P4 = new Point(0, 1); + const entities = [ + new MockEntity(P2, P3), + new MockEntity(P4, P1), + new MockEntity(P3, P4), + new MockEntity(P1, P2), + ]; + expect(isClosedPolygon(entities)).toBe(true); + }); + + it('should return false for an open chain of segments', () => { + const A = new Point(0, 0); + const B = new Point(1, 0); + const C = new Point(2, 0); + const entities = [new MockEntity(A, B), new MockEntity(B, C)]; + expect(isClosedPolygon(entities)).toBe(false); + }); + + it('should return false when there is a zero-length segment', () => { + const A = new Point(0, 0); + const entities = [new MockEntity(A, A)]; + expect(isClosedPolygon(entities)).toBe(false); + }); + + it('should return false when three segments share the same point', () => { + const A = new Point(0, 0); + const B = new Point(1, 0); + const C = new Point(0, 1); + const D = new Point(-1, 0); + const entities = [new MockEntity(A, B), new MockEntity(A, C), new MockEntity(A, D)]; + expect(isClosedPolygon(entities)).toBe(false); + }); + + it('should return false for two disjoint loops', () => { + const A = new Point(0, 0); + const B = new Point(1, 0); + const C = new Point(0, 1); + const D = new Point(2, 2); + const E = new Point(3, 2); + const F = new Point(2, 3); + const entities = [ + // First triangle + new MockEntity(A, B), + new MockEntity(B, C), + new MockEntity(C, A), + // Second triangle + new MockEntity(D, E), + new MockEntity(E, F), + new MockEntity(F, D), + ]; + expect(isClosedPolygon(entities)).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts new file mode 100644 index 00000000..37d9aed4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts @@ -0,0 +1,84 @@ +import type {Point} from "@flatten-js/core"; +import type {StartAndEndpointEntity} from "../App.types.ts"; +import {isPointEqual} from "./is-point-equal.ts"; + +/** + * Check if entities form a closed loop polygon + */ +export function isClosedPolygon(entities: StartAndEndpointEntity[]): boolean { + const uniquePoints: Point[] = []; + const counts: number[] = []; + const edges: Array<[number, number]> = []; + + // Helper to find or add a point to uniquePoints, returning its index + const findOrAdd = (pt: Point): number => { + for (let i = 0; i < uniquePoints.length; i++) { + if (isPointEqual(uniquePoints[i], pt)) { + return i; + } + } + uniquePoints.push(pt); + counts.push(0); + return uniquePoints.length - 1; + }; + + // 1) Process each segment + for (const entity of entities) { + const start = entity.getStartPoint(); + const end = entity.getEndPoint(); + + // 1a) no zeroโ€length segments + if (isPointEqual(start, end)) { + return false; + } + + const si = findOrAdd(start); + const ei = findOrAdd(end); + + counts[si]++; + counts[ei]++; + edges.push([si, ei]); + } + + const N = entities.length; + + // 2) must have exactly N unique points + if (uniquePoints.length !== N) { + return false; + } + + // 3) each point must appear exactly twice + if (counts.some((c) => c !== 2)) { + return false; + } + + // 4) build undirected adjacency + const adj: number[][] = Array.from({ length: N }, () => []); + for (const [u, v] of edges) { + adj[u].push(v); + adj[v].push(u); + } + + // 5) each vertex must have degree 2 + if (adj.some((neigh) => neigh.length !== 2)) { + return false; + } + + // 6) connectivity: traverse from 0 + const visited = new Set(); + const stack = [0]; + while (stack.length) { + const u = stack.pop(); + if (typeof u === 'undefined') { + break; + } + if (!visited.has(u)) { + visited.add(u); + for (const v of adj[u]) { + if (!visited.has(v)) stack.push(v); + } + } + } + + return visited.size === N; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts new file mode 100644 index 00000000..93e6675d --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts @@ -0,0 +1,5 @@ +import { EPSILON } from '../App.consts'; + +export function isLengthEqual(length1: number, length2: number): boolean { + return Math.abs(length1 - length2) < EPSILON; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts new file mode 100644 index 00000000..43eb67d4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts @@ -0,0 +1,9 @@ +import type { Point } from '@flatten-js/core'; +import { EPSILON } from '../App.consts'; + +export function isPointEqual(point1: Point, point2: Point): boolean { + return ( + Math.abs(point1.x - point2.x) < EPSILON && + Math.abs(point1.y - point2.y) < EPSILON + ); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts new file mode 100644 index 00000000..9d25cd4d --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts @@ -0,0 +1,9 @@ +import type {KeyboardEvent} from "react"; + +export function keyboardHandler(clickHandler: () => void) { + return (evt: KeyboardEvent) => { + if (evt.key === 'Enter' || evt.key === 'Space') { + clickHandler(); + } + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts new file mode 100644 index 00000000..83dddaec --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { mapNumberRange } from './map-number-range'; + +describe('mapNumberRange', () => { + it('should map a value from the source range to the target range (normal range)', () => { + expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50); + expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0); + expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100); + }); + + it('should map values outside the source range', () => { + expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range + expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range + }); + + it('should handle inverted source ranges', () => { + // Source range is 10 to 0, mapping 5 should be halfway + // Target range is 100 to 0, so halfway is 50 + expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50); + + // Outside inverted range + expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150); + expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50); + }); + + it('should handle inverted target ranges', () => { + // Normal source range, but inverted target + expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50); + expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100); + expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0); + }); + + it('should handle zero-length source range', () => { + // If the source range is a single point + expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range + expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range + }); + + it('should handle negative numbers and other ranges', () => { + expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50); + // Here: num = -10, source = [-20,0], target = [0,100] + // Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50 + }); + + it('should handle floating point values', () => { + expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input + expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check + }); + + it('should handle large ranges', () => { + expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000); + }); + + it('should handle screen coordinates to world correctly', () => { + expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900); + }); + + it('should handle world coordinates to screen correctly', () => { + expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts new file mode 100644 index 00000000..47c1d601 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts @@ -0,0 +1,22 @@ +/** + * Convert numbers in a specific range to another range + * This is moslty used to convert screen space coordinates to world space coordinates and vice versa + */ +export function mapNumberRange( + num: number, + startSourceRange: number, + endSourceRange: number, + startTargetRange: number, + endTargetRange: number, +): number { + // Handle the case where source range has zero length + if (startSourceRange === endSourceRange) { + return startTargetRange; + } + + return ( + startTargetRange + + ((num - startSourceRange) * (endTargetRange - startTargetRange)) / + (endSourceRange - startSourceRange) + ); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/middle.ts b/B07_DesignDetail/openwebcad/src/helpers/middle.ts new file mode 100644 index 00000000..c02eb1ad --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/middle.ts @@ -0,0 +1,3 @@ +export function middle(numMin: number, numMax: number) { + return numMin + (numMax - numMin) / 2; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts new file mode 100644 index 00000000..5cf8e11e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts @@ -0,0 +1,6 @@ +import type {LineEntity} from "../entities/LineEntity.ts"; + +export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) { + const mirrorAngle = mirrorAxis.getAngle(); + return mirrorAngle * 2 - angle; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts new file mode 100644 index 00000000..8bc9c478 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, it} from "vitest"; +import {Point} from "@flatten-js/core"; +import {mirrorPointOverAxis} from './mirror-point-over-axis'; +import {LineEntity} from "../entities/LineEntity.ts"; +import {getActiveLayerId} from "../state.ts"; + +describe("mirrorPointOverAxis", () => { + it('should mirror if the axis is horizontal', () => { + const point = new Point(100, 100); + const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50)); + const mirroredPoint = mirrorPointOverAxis(point, axis); + expect(mirroredPoint.x).toBe(100); + expect(mirroredPoint.y).toBe(0); + }); + + it("mirrors a point over a vertical axis", () => { + const point = new Point(3, 4); + const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0 + const mirrored = mirrorPointOverAxis(point, axis); + expect(mirrored.x).toBeCloseTo(-3); + expect(mirrored.y).toBeCloseTo(4); + }); + + it("mirrors a point over the diagonal line y = x", () => { + const point = new Point(3, 4); + const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x + const mirrored = mirrorPointOverAxis(point, axis); + // The mirror of (3,4) over y=x is (4,3) + expect(mirrored.x).toBeCloseTo(4); + expect(mirrored.y).toBeCloseTo(3); + }); + + it("returns the same point if the point lies on the mirror axis", () => { + const point = new Point(1, 1); + const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line + const mirrored = mirrorPointOverAxis(point, axis); + expect(mirrored.x).toBeCloseTo(1); + expect(mirrored.y).toBeCloseTo(1); + }); + + it("returns the original point when mirrored twice", () => { + const point = new Point(5, 7); + const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis + const mirrored = mirrorPointOverAxis(point, axis); + const doubleMirrored = mirrorPointOverAxis(mirrored, axis); + expect(doubleMirrored.x).toBeCloseTo(point.x); + expect(doubleMirrored.y).toBeCloseTo(point.y); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts new file mode 100644 index 00000000..b7ea62db --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts @@ -0,0 +1,22 @@ +import { Point, type Segment } from '@flatten-js/core'; +import type {LineEntity} from "../entities/LineEntity.ts"; + +export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) { + const mirrorAxisSegment = mirrorAxis.getShape() as Segment; + const A = mirrorAxisSegment.start; + const B = mirrorAxisSegment.end; + + // Compute the vector components for the mirror axis + const dx = B.x - A.x; + const dy = B.y - A.y; + + // Compute the projection factor t + const t = ((point.x - A.x) * dx + (point.y - A.y) * dy) / (dx * dx + dy * dy); + + // Compute the projection of the point onto the line + const projX = A.x + t * dx; + const projY = A.y + t * dy; + + // Reflect the point: new point = 2 * projection - original point + return new Point(2 * projX - point.x, 2 * projY - point.y); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/normalise-angle.ts b/B07_DesignDetail/openwebcad/src/helpers/normalise-angle.ts new file mode 100644 index 00000000..509d3451 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/normalise-angle.ts @@ -0,0 +1,3 @@ +export function normaliseAngleRadians(angle: number): number { + return (angle + 2 * Math.PI) % (2 * Math.PI); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/paper-sizes.ts b/B07_DesignDetail/openwebcad/src/helpers/paper-sizes.ts new file mode 100644 index 00000000..ddbd7f92 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/paper-sizes.ts @@ -0,0 +1,3 @@ +import { Point } from '@flatten-js/core'; + +export const A4Format = new Point(210, 297); diff --git a/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts new file mode 100644 index 00000000..5b042adf --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts @@ -0,0 +1,9 @@ +import type {Polygon, Segment} from '@flatten-js/core'; + +export function polygonToSegments(polygon: Polygon): Segment[] { + const segments: Segment[] = []; + for (const edge of polygon.edges) { + segments.push(edge.shape); + } + return segments; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/rgb-to-hex-color.ts b/B07_DesignDetail/openwebcad/src/helpers/rgb-to-hex-color.ts new file mode 100644 index 00000000..7d9499eb --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/rgb-to-hex-color.ts @@ -0,0 +1,3 @@ +export function toHex(red: number, green: number, blue: number, alpha: number): string { + return (blue | (green << 8) | (red << 16) | (1 << 24)).toString(16).slice(1) + alpha; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts new file mode 100644 index 00000000..f4597109 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts @@ -0,0 +1,11 @@ +import { Point, Vector } from '@flatten-js/core'; + +export function rotatePoint( + point: Point, + rotateOrigin: Point, + angle: number, +): Point { + const vector = new Vector(rotateOrigin, point); + const rotatedVector = vector.rotate(angle); + return new Point(rotatedVector.x, rotatedVector.y); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts new file mode 100644 index 00000000..57a8eedf --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts @@ -0,0 +1,11 @@ +import { Point, Vector } from '@flatten-js/core'; + +export function scalePoint( + point: Point, + scaleOrigin: Point, + scaleFactor: number, +): Point { + const vector = new Vector(scaleOrigin, point); + const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1); + return new Point(point.x + scaledVector.x, point.y + scaledVector.y); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts new file mode 100644 index 00000000..b2eb832b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -0,0 +1,143 @@ +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { + getGridEnabled, + getHighlightedEntityIds, + setHighlightedEntityIds, +} from '../state'; +import { drawEntities } from './draw-functions'; +import { getSceneVersion } from './scene-version'; +import { queryEntitiesInBox } from './spatial-index'; + +/** + * Static scene cache: all entities are rendered once into an offscreen canvas. + * While panning, the cached bitmap is blitted at a pixel offset instead of + * re-stroking every entity each frame. The cache is rebuilt when the scene + * version bumps (entities/layers/selection changed), when zoom or canvas size + * changes, or after the pan offset has settled for PAN_SETTLE_MS. + * + * Highlight is intentionally NOT baked into the cache (it changes on every + * mouse move) โ€” draw.ts re-draws highlighted entities on top each frame. + */ +const PAN_SETTLE_MS = 120; + +interface RenderedParams { + version: number; + scale: number; + offsetX: number; + offsetY: number; + sizeX: number; + sizeY: number; +} + +let offscreenCanvas: HTMLCanvasElement | null = null; +let rendered: RenderedParams | null = null; +let prevOffsetX = Number.NaN; +let prevOffsetY = Number.NaN; +let lastOffsetChangeAt = 0; + +export const scenePerf = { + sceneRebuilds: 0, + lastRebuildMs: 0, + avgFrameMs: 0, +}; +(window as unknown as Record).__aisloCadPerf = scenePerf; + +export function invalidateSceneCache(): void { + rendered = null; +} + +/** + * Viewport culling: only entities whose bbox intersects the current view + * (expanded by one viewport on each side, so short pans stay covered by the + * blit before the settle-rebuild) are rendered into the scene cache. + */ +function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + const viewWidth = size.x / scale; + const viewHeight = size.y / scale; + return queryEntitiesInBox( + offset.x - viewWidth, + offset.y - viewHeight, + offset.x + 2 * viewWidth, + offset.y + 2 * viewHeight + ); +} + +function rebuildScene(drawController: ScreenCanvasDrawController): void { + const size = drawController.getCanvasSize(); + if (!offscreenCanvas) { + offscreenCanvas = document.createElement('canvas'); + } + if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) { + offscreenCanvas.width = Math.max(1, size.x); + offscreenCanvas.height = Math.max(1, size.y); + } + const offscreenContext = offscreenCanvas.getContext('2d'); + if (!offscreenContext) return; + + const startedAt = performance.now(); + // Exclude the (rapidly changing) hover highlight from the baked bitmap. + const savedHighlight = getHighlightedEntityIds(); + if (savedHighlight.length) setHighlightedEntityIds([]); + drawController.withContext(offscreenContext, () => { + drawController.clear(); + // Style-run batching + sub-pixel decimation: one stroke per style run + drawController.beginBatch(); + drawEntities(drawController, sceneEntitiesForViewport(drawController)); + drawController.endBatch(); + }); + if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); + + scenePerf.lastRebuildMs = performance.now() - startedAt; + scenePerf.sceneRebuilds++; + + const offset = drawController.getScreenOffset(); + rendered = { + version: getSceneVersion(), + scale: drawController.getScreenScale(), + offsetX: offset.x, + offsetY: offset.y, + sizeX: size.x, + sizeY: size.y, + }; +} + +/** + * Draw the static scene: rebuild the cache when needed, otherwise blit the + * cached bitmap (shifted by the pan delta). Called once per frame by draw(). + */ +export function drawScene(drawController: ScreenCanvasDrawController, now: number): void { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + + if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) { + lastOffsetChangeAt = now; + prevOffsetX = offset.x; + prevOffsetY = offset.y; + } + + const paramsChanged = + !rendered || + rendered.version !== getSceneVersion() || + rendered.scale !== scale || + rendered.sizeX !== size.x || + rendered.sizeY !== size.y; + const offsetChanged = + !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); + + // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted + // bitmap would drag the grid along โ€” always re-render while grid is on. + if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { + rebuildScene(drawController); + } + + if (!offscreenCanvas || !rendered) return; + // screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards: + // content shifts left when offset.x grows, down when offset.y grows. + const dx = (rendered.offsetX - offset.x) * scale; + const dy = (offset.y - rendered.offsetY) * scale; + drawController.blitImage(offscreenCanvas, dx, dy); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-version.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-version.ts new file mode 100644 index 00000000..5d64ca06 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-version.ts @@ -0,0 +1,13 @@ +/** + * Scene version counter โ€” incremented whenever content that is baked into the + * cached static scene bitmap changes (entities, layers, selection, grid). + * Kept dependency-free so both state.ts and scene-cache.ts can import it + * without a cycle. + */ +let sceneVersion = 0; + +export const bumpSceneVersion = (): void => { + sceneVersion++; +}; + +export const getSceneVersion = (): number => sceneVersion; diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts new file mode 100644 index 00000000..39a2941a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts @@ -0,0 +1,33 @@ +import { Line, type Point } from '@flatten-js/core'; +import { sortBy } from 'es-toolkit'; +import type { PointWithAngle } from './helpers.types'; +import { ArcEntity } from '../entities/ArcEntity'; + +/** + * Sorts points that lie on an arc by angle around the arc, from start point to end point. + * @param pointsOnArc + * @param centerPoint + * @param startPoint + */ +export function sortPointsOnArc( + pointsOnArc: Point[], + centerPoint: Point, + startPoint: Point, +): Point[] { + const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint); + + // Angles calculated from start point (0 degrees) and up + const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => { + return { + point, + // Ensure all angles are between 0 (start point) and < 2PI, + // so we can sort them starting at the start point angle + angle: + (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % + (2 * Math.PI), + }; + }); + return sortBy(pointsWithAngles, [ + (pointWithAngle: PointWithAngle) => pointWithAngle.angle, + ]).map(pointsWithAngle => pointsWithAngle.point); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts new file mode 100644 index 00000000..d3c71b23 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts @@ -0,0 +1,23 @@ +import { Line, type Point } from '@flatten-js/core'; +import { sortBy } from 'es-toolkit'; +import type { PointWithAngle } from './helpers.types'; + +/** + * Sorts points that lie on a circle by angle around the circle, angle from 0 => 360 + * @param pointsOnCircle + * @param centerPoint + */ +export function sortPointsOnCircle( + pointsOnCircle: Point[], + centerPoint: Point, +): Point[] { + const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => { + return { + point, + angle: new Line(centerPoint, point).slope, + }; + }); + return sortBy(pointsWithAngles, [ + (pointWithAngle: PointWithAngle) => pointWithAngle.angle, + ]).map(pointsWithAngle => pointsWithAngle.point); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts new file mode 100644 index 00000000..2c9c8453 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -0,0 +1,140 @@ +import type { Entity } from '../entities/Entity'; +import { getEntities } from '../state'; +import { getSceneVersion } from './scene-version'; + +/** + * Uniform-grid spatial index over top-level entity bounding boxes. + * Rebuilt lazily whenever the scene version changes (entity edits bump it). + * Queries return entities in original array order so z-order is preserved. + */ +const GRID_CELLS_PER_AXIS = 64; + +interface IndexedEntity { + entity: Entity; + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +let indexVersion = -1; +let indexedEntities: IndexedEntity[] = []; +let unindexedEntities: Entity[] = []; // bbox unavailable โ€” always included in results +let cells: Map = new Map(); +let cellSize = 1; +let gridMinX = 0; +let gridMinY = 0; +let gridCols = 1; + +function cellRange(min: number, max: number, gridMin: number): [number, number] { + return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; +} + +function ensureIndex(): void { + const version = getSceneVersion(); + if (version === indexVersion) return; + + indexedEntities = []; + unindexedEntities = []; + cells = new Map(); + + const entities = getEntities(); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (const entity of entities) { + let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null; + try { + box = entity.getBoundingBox(); + } catch { + box = null; + } + if ( + !box || + !Number.isFinite(box.xmin) || + !Number.isFinite(box.ymin) || + !Number.isFinite(box.xmax) || + !Number.isFinite(box.ymax) + ) { + unindexedEntities.push(entity); + continue; + } + indexedEntities.push({ + entity, + minX: box.xmin, + minY: box.ymin, + maxX: box.xmax, + maxY: box.ymax, + }); + if (box.xmin < minX) minX = box.xmin; + if (box.ymin < minY) minY = box.ymin; + if (box.xmax > maxX) maxX = box.xmax; + if (box.ymax > maxY) maxY = box.ymax; + } + + if (indexedEntities.length) { + const extent = Math.max(maxX - minX, maxY - minY, 1e-9); + cellSize = extent / GRID_CELLS_PER_AXIS; + gridMinX = minX; + gridMinY = minY; + gridCols = GRID_CELLS_PER_AXIS + 2; + + indexedEntities.forEach((item, index) => { + const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX); + const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const key = cy * gridCols + cx; + const bucket = cells.get(key); + if (bucket) bucket.push(index); + else cells.set(key, [index]); + } + } + }); + } + + indexVersion = version; +} + +/** ๋ทฐํฌํŠธ/์‚ฌ๊ฐ ์˜์—ญ๊ณผ bbox๊ฐ€ ๊ฒน์น˜๋Š” ์—”ํ‹ฐํ‹ฐ (์›๋ณธ ๋ฐฐ์—ด ์ˆœ์„œ ์œ ์ง€). */ +export function queryEntitiesInBox( + minX: number, + minY: number, + maxX: number, + maxY: number +): Entity[] { + ensureIndex(); + if (!indexedEntities.length) return [...unindexedEntities]; + + const seen = new Set(); + const [cx0, cx1] = cellRange(minX, maxX, gridMinX); + const [cy0, cy1] = cellRange(minY, maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const bucket = cells.get(cy * gridCols + cx); + if (!bucket) continue; + for (const index of bucket) seen.add(index); + } + } + + const result: Entity[] = []; + let unindexedCursor = 0; + for (let index = 0; index < indexedEntities.length; index++) { + if (!seen.has(index)) continue; + const item = indexedEntities[index]; + if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue; + result.push(item.entity); + } + // bbox ๋ถˆ๋ช… ์—”ํ‹ฐํ‹ฐ๋Š” ํ•ญ์ƒ ํฌํ•จ (๋’ค์— ๋ถ™์—ฌ๋„ ์†Œ์ˆ˜๋ผ ์‹œ๊ฐ ์˜ํ–ฅ ์—†์Œ) + for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) { + result.push(unindexedEntities[unindexedCursor]); + } + return result; +} + +/** ์  ์ฃผ๋ณ€ ๋ฐ˜๊ฒฝ ํ›„๋ณด ์—”ํ‹ฐํ‹ฐ (์Šค๋ƒ…ยทํ˜ธ๋ฒ„์šฉ). */ +export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] { + return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/times.ts b/B07_DesignDetail/openwebcad/src/helpers/times.ts new file mode 100644 index 00000000..aa57cb19 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/times.ts @@ -0,0 +1,12 @@ +export function times( + 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; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts new file mode 100644 index 00000000..e7d96204 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts @@ -0,0 +1,77 @@ +import type { HoverPoint, SnapPoint } from '../App.types'; +import { pointDistance } from './distance-between-points'; +import { HOVERED_SNAP_POINT_TIME, MAX_MARKED_SNAP_POINTS } from '../App.consts'; + +/** + * Checks the current snap point every 100ms to mark certain snap points when they are hovered for a certain amount of time (marked) + * So we can show extra angle guides for the ones that are marked + */ +export function trackHoveredSnapPoint( + worldSnapPoint: SnapPoint | null, + worldHoveredSnapPoints: HoverPoint[], + setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void, + maxHoverDistance: number, + elapsedTime: number, +) { + if (!worldSnapPoint) { + return; + } + + const lastHoveredPoint = worldHoveredSnapPoints.at(-1); + let newHoverSnapPoints: HoverPoint[]; + + // Angle guide points should never be marked + if (lastHoveredPoint) { + if ( + pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) < + maxHoverDistance + ) { + // Last hovered snap point is still the current closest snap point + // Increase the hover time + newHoverSnapPoints = [ + ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), + { + ...lastHoveredPoint, + milliSecondsHovered: + lastHoveredPoint.milliSecondsHovered + elapsedTime, + }, + ]; + } else { + // The closest snap point has changed + // Check if the last snap point was hovered for long enough to be considered a marked snap point + if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) { + // Append the new point to the list + newHoverSnapPoints = [ + ...worldHoveredSnapPoints, + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } else { + // Replace the last point with the new point + newHoverSnapPoints = [ + ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } + } + } else { + // No snap points were hovered before + newHoverSnapPoints = [ + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } + + const newHoverSnapPointsTruncated = newHoverSnapPoints.slice( + 0, + MAX_MARKED_SNAP_POINTS, + ); + setHoveredSnapPoints(newHoverSnapPointsTruncated); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts new file mode 100644 index 00000000..dbbe75cd --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/undo-stack.ts @@ -0,0 +1,84 @@ +export interface UndoState { + variable: StateVariable; + + // biome-ignore lint/suspicious/noExplicitAny: TODO list all undo state types + value: any; +} + +export enum StateVariable { + canvasSize = 'canvasSize', + canvas = 'canvas', + context = 'context', + screenMouseLocation = 'screenMouseLocation', + activeTool = 'activeTool', + entities = 'entities', + activeEntity = 'activeEntity', + shouldDrawCursor = 'shouldDrawCursor', + helperEntities = 'helperEntities', + debugEntities = 'debugEntities', + angleStep = 'angleStep', + screenOffset = 'screenOffset', + screenZoom = 'screenZoom', + panStartLocation = 'panStartLocation', + snapPoint = 'snapPoint', + snapPointOnAngleGuide = 'snapPointOnAngleGuide', + hoveredSnapPoints = 'hoveredSnapPoints', + lastDrawTimestamp = 'lastDrawTimestamp', + activeLineColor = 'activeLineColor', + activeLineWidth = 'activeLineWidth', + activeLineDash = 'activeLineDash', + activeTextStyle = 'activeTextStyle', + designMeta = 'designMeta', + layers = 'layers', +} + +/** + * Based on https://github.com/wobsoriano/undo-stacker + */ +export function createStack() { + let stack: UndoState[] = []; + + let index = stack.length; + + function peek() { + return stack[index - 1]; + } + + return { + push: (value: UndoState) => { + stack.length = index; + stack[index++] = value; + + // console.log('stack push', JSON.stringify(stack, null, 2)); + return peek(); + }, + replace: (value: UndoState) => { + stack[index - 1] = value; + + // console.log('stack replace', JSON.stringify(stack, null, 2)); + return peek(); + }, + peek: () => { + return peek(); + }, + undo: () => { + if (index > 1) index -= 1; + + // console.log('stack undo', JSON.stringify(stack, null, 2)); + return peek(); + }, + redo: () => { + if (index < stack.length) index += 1; + + // console.log('stack redo', JSON.stringify(stack, null, 2)); + return peek(); + }, + // Clear certain states from the undo stack + clear: (variable: StateVariable) => { + // Update the index be reducing it by the number of states that are removed to the left of the index + index = index - stack.slice(0, index).filter((state) => state.variable === variable).length; + + stack = stack.filter((state) => state.variable !== variable); + }, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts new file mode 100644 index 00000000..f3319ac2 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts @@ -0,0 +1,11 @@ +// Wraps the index so that if you go past the length it resets to 0 +// ["one", "two", "three"] +// index maps like this +// 0 => 0 +// 1 => 1 +// 2 => 2 +// 3 => 0 +// 4 => 1 +export function wrapModule(index: number, length: number) { + return (index + length) % length; +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts new file mode 100644 index 00000000..55c0592a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -0,0 +1,528 @@ +import { Point } from '@flatten-js/core'; +import { compact, round } from 'es-toolkit'; +import { Actor } from 'xstate'; +import { + CANVAS_INPUT_FIELD_BACKGROUND_COLOR, + CANVAS_INPUT_FIELD_HEIGHT, + CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, + CANVAS_INPUT_FIELD_MOUSE_OFFSET, + CANVAS_INPUT_FIELD_TEXT_COLOR, + CANVAS_INPUT_FIELD_WIDTH, + HIGHLIGHT_ENTITY_DISTANCE, + SNAP_POINT_DISTANCE, +} from '../App.consts.ts'; +import { MouseButton } from '../App.types.ts'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; +import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts'; +import { findClosestEntity } from '../helpers/find-closest-entity.ts'; +import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts'; +import { + getActiveToolActor, + getCanvas, + getEntities, + getLastStateInstructions, + getPanStartLocation, + getSnapEnabled, + getScreenCanvasDrawController, + getSelectedEntities, + getSnapPoint, + getSnapPointOnAngleGuide, + redo, + setActiveToolActor, + setGhostHelperEntities, + setHighlightedEntityIds, + setPanStartLocation, + setSelectedEntityIds, + setShouldDrawCursor, + undo, +} from '../state.ts'; +import { Tool } from '../tools.ts'; +import { TOOL_STATE_MACHINES } from '../tools/tool.consts.ts'; +import { + type AbsolutePointInputEvent, + ActorEvent, + type MouseClickEvent, + type NumberInputEvent, + type RelativePointInputEvent, + type TextInputEvent, +} from '../tools/tool.types.ts'; + +const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; +const ABSOLUTE_POINT_REGEXP = /^([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/; +const RELATIVE_POINT_REGEXP = /^@([0-9]+([.][0-9]+)?)\s*,\s*([0-9]+([.][0-9]+)?)$/; + +export class InputController { + private text = ''; + + constructor() { + if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { + return; // used during unit testing + } + // Listen for keystrokes + document.addEventListener('keydown', (evt) => { + this.handleKeyStroke(evt); + }); + // Listen for right mouse button click => perform the same action as ENTER + const canvas = getCanvas(); + canvas?.addEventListener('mousedown', (evt: MouseEvent) => this.handleMouseDown(evt)); + canvas?.addEventListener('mousemove', (evt: MouseEvent) => this.handleMouseMove(evt)); + canvas?.addEventListener('mouseup', (evt: MouseEvent) => this.handleMouseUp(evt)); + canvas?.addEventListener('wheel', (evt: WheelEvent) => this.handleMouseWheel(evt)); + canvas?.addEventListener('mouseout', () => this.handleMouseOut()); + canvas?.addEventListener('mouseenter', () => this.handleMouseEnter()); + // Stop the context menu from appearing when right-clicking + canvas?.addEventListener('contextmenu', (evt) => { + evt.preventDefault(); + }); + } + + public draw(drawController: ScreenCanvasDrawController) { + const screenMouseLocation = drawController.getScreenMouseLocation(); + + // draw input field + drawController.fillRectScreen( + screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET, + screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET, + CANVAS_INPUT_FIELD_WIDTH, + CANVAS_INPUT_FIELD_HEIGHT, + CANVAS_INPUT_FIELD_BACKGROUND_COLOR + ); + // Draw text in input field + if (this.text) { + drawController.drawTextScreen( + this.text, + new Point( + screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, + screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2 + ), + { + textAlign: 'left', + textColor: CANVAS_INPUT_FIELD_TEXT_COLOR, + fontSize: 18, + } + ); + } + + const matchingToolNames = this.getToolNamesFromPrefixText(); + const toolInstruction = getLastStateInstructions(); + const texts: string[] = []; + if (toolInstruction) { + // Draw tool instruction + texts.push(toolInstruction); + const roundedX = round(drawController.getWorldMouseLocation().x, 2); + const roundedY = round(drawController.getWorldMouseLocation().y, 2); + texts.push(`${roundedX},${roundedY}`); + } + if (matchingToolNames.length) { + // Draw list of matching tools. eg: C => CIRCLE, COPY, ... + texts.push(...matchingToolNames); + } + this.drawListBelowInputField(drawController, texts); + } + + public submitText(value: string) { + this.text = value.trim().toUpperCase(); + this.handleEnterKey(); + } + + public handleMouseUp(evt: MouseEvent) { + if (evt.button === MouseButton.Right) { + // Right click => confirm action (ENTER) + evt.preventDefault(); + evt.stopPropagation(); + this.handleEnterKey(); + } + + // If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas + const controlsParent = (evt?.target as HTMLElement)?.closest('.controls'); + if (controlsParent) { + return; + } + + if (evt.button === MouseButton.Middle) { + setPanStartLocation(null); + } + if (evt.button === MouseButton.Left) { + const screenCanvasDrawController = getScreenCanvasDrawController(); + const closestSnapPoint = getClosestSnapPointWithinRadius( + compact([getSnapPoint(), getSnapPointOnAngleGuide()]), + screenCanvasDrawController.getWorldMouseLocation(), + SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale() + ); + + const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld( + this.getCanvasPoint(evt) + ); + const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; + + const activeToolActor = getActiveToolActor(); + activeToolActor?.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation, + screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation), + holdingCtrl: evt.ctrlKey, + holdingShift: evt.shiftKey, + } as MouseClickEvent); + } + } + + public handleMouseEnter() { + setShouldDrawCursor(true); + } + + public handleMouseMove(evt: MouseEvent) { + setShouldDrawCursor(true); + const screenCanvasDrawController = getScreenCanvasDrawController(); + const newScreenMouseLocation = this.getCanvasPoint(evt); + screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation); + + // If the middle mouse button is pressed, pan the screen + const panStartLocation = getPanStartLocation(); + if (panStartLocation) { + screenCanvasDrawController.panScreen( + newScreenMouseLocation.x - panStartLocation.x, + newScreenMouseLocation.y - panStartLocation.y + ); + setPanStartLocation(newScreenMouseLocation); + } + + // Calculate angle guides and snap points + if (getSnapEnabled()) { + calculateAngleGuidesAndSnapPoints(); + } + + // Highlight the entity closest to the mouse when the select tool is active + if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) { + const closestEntityInfo = findClosestEntity( + screenCanvasDrawController.targetToWorld(newScreenMouseLocation), + getEntities() + ); + if (closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) { + setHighlightedEntityIds([closestEntityInfo.entity.id]); + } else { + setHighlightedEntityIds([]); + } + } + } + + public handleMouseOut() { + setShouldDrawCursor(false); + } + + /** + * Change the zoom level of screen space + * @param evt + */ + public handleMouseWheel(evt: WheelEvent) { + if (Math.abs(evt.deltaY) === 0) { + return; // We can't zoom by zero delta + } + const drawController = getScreenCanvasDrawController(); + // Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing + // zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02). + drawController.zoomScreen(-evt.deltaY); + } + + public handleMouseDown(evt: MouseEvent) { + if (evt.button !== MouseButton.Middle) return; + + setPanStartLocation(this.getCanvasPoint(evt)); + } + + private getCanvasPoint(evt: MouseEvent): Point { + const bounds = getCanvas()?.getBoundingClientRect(); + return new Point( + evt.clientX - (bounds?.left ?? 0), + (bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY + ); + } + + /** + * Returns a distance to pan the screen when a directional arrow is pressed + * Offset is based on shift key being pressed (larger offset) + * and zoom level + * @private + */ + private getScreenPanStep( + direction: 'up' | 'right' | 'down' | 'left', + shiftPressed: boolean + ): Point { + const screenOffset = getScreenCanvasDrawController().getScreenOffset(); + const screenZoom = getScreenCanvasDrawController().getScreenScale(); + let step = 20; + if (shiftPressed) { + step = 100; + } + step *= screenZoom; + switch (direction) { + case 'up': + return new Point(screenOffset.x, screenOffset.y - step); + case 'right': + return new Point(screenOffset.x - step, screenOffset.y); + case 'down': + return new Point(screenOffset.x, screenOffset.y + step); + case 'left': + return new Point(screenOffset.x + step, screenOffset.y); + } + } + + public handleKeyStroke(evt: KeyboardEvent) { + if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) { + return; + } + console.log(`key pressed: ${evt.key}`); + if (evt.key === 'F12') { + // F12 => open developer tools + return; + } + if (evt.key === 'F5') { + // F5 => reload the page + return; + } + if (evt.key === 'F11') { + // F11 => toggle fullscreen + return; + } + if (evt.key === 'Tab') { + // Tab => move keyboard focus + return; + } + evt.preventDefault(); + evt.stopPropagation(); + if (evt.ctrlKey && evt.key === 'v') { + // User wants to paste the clipboard + } else if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') { + // User wants to undo the last action + this.handleUndo(evt); + } else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') { + // User wants to redo the last action + this.handleRedo(evt); + } else if (evt.ctrlKey && evt.key === 'y') { + // User wants to redo the last action + this.handleRedo(evt); + } else if (evt.ctrlKey && evt.key === 'a') { + // User wants to select everything + setSelectedEntityIds(getEntities().map((entity) => entity.id)); + } else if (evt.key === 'Backspace') { + // Remove the last character from the input field + evt.preventDefault(); + this.text = this.text.slice(0, this.text.length - 1); + } else if (evt.key === 'Delete') { + // User wants to delete the current selection + evt.preventDefault(); + getActiveToolActor()?.send({ + type: ActorEvent.DELETE, + }); + } else if (evt.key === 'Escape') { + // User wants to cancel the current action + this.handleEscapeKey(); + } else if (evt.key === 'Enter') { + // User wants to submit the input or submit the action + this.handleEnterKey(); + } else if (evt.key === 'ArrowDown') { + // Move the screen down + getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); + } else if (evt.key === 'ArrowUp') { + // Move the screen up + getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('up', evt.shiftKey)); + } else if (evt.key === 'ArrowLeft') { + // Move the screen left + getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('left', evt.shiftKey)); + } else if (evt.key === 'ArrowRight') { + // Move the screen right + getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey)); + } else if (evt.key === '+') { + // Zoom in + // TODO keep the center of the screen centered during zoom + getScreenCanvasDrawController().setScreenScale( + getScreenCanvasDrawController().getScreenScale() * 1.1 + ); + } else if (evt.key === '-') { + // Zoom in + // TODO keep the center of the screen centered during zoom + getScreenCanvasDrawController().setScreenScale( + getScreenCanvasDrawController().getScreenScale() * 0.9 + ); + } else if (evt.key?.length === 1) { + // User entered a single character => add to input field text + this.text += evt.key.toUpperCase(); + } + } + + public handleEscapeKey() { + if (getSelectedEntities().length > 0) { + // Deselect entities + setSelectedEntityIds([]); + } else if (this.text === '') { + // Cancel tool action + getActiveToolActor()?.send({ + type: ActorEvent.ESC, + }); + } else { + // clear the input field + this.text = ''; + } + } + + private getToolNamesFromPrefixText(): Tool[] { + if (this.text === '') { + return []; + } + return (Object.keys(TOOL_STATE_MACHINES).filter((cmd) => + cmd.startsWith(this.text.toUpperCase()) + ) || null) as Tool[]; + } + + public handleEnterKey() { + // submit the text as input to the active tool and clear the input field + const activeTool = getActiveToolActor(); + const activeToolSnapshot = activeTool?.getSnapshot(); + const activeToolState = activeToolSnapshot?.value; + const activeToolCanHandleTextInput = + !!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT; + + if (this.text === '') { + console.log('ENTER: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // Send the ENTER event to the active tool + getActiveToolActor()?.send({ + type: ActorEvent.ENTER, + }); + } else if (activeToolCanHandleTextInput) { + console.log('TEXT_INPUT: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // Send the text to the active tool + getActiveToolActor()?.send({ + type: ActorEvent.TEXT_INPUT, + value: this.text, + } as TextInputEvent); + this.text = ''; + } else if (this.getToolNamesFromPrefixText()[0]) { + // User entered a command. eg: L or LINE + const toolName = this.getToolNamesFromPrefixText()[0]; + + getActiveToolActor()?.stop(); + + const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); + setActiveToolActor(newToolActor); + + console.log('SWITCH TO TOOL: ', { + toolName, + // biome-ignore lint/suspicious/noExplicitAny: + activeTool: (getActiveToolActor()?.src as any).config.context.type, + }); + + this.text = ''; + } else if (NUMBER_REGEXP.test(this.text)) { + console.log(' NUMBER_INPUT: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // User entered a number. eg: 100 + getActiveToolActor()?.send({ + type: ActorEvent.NUMBER_INPUT, + value: Number.parseFloat(this.text), + worldMouseLocation: + getSnapPointOnAngleGuide()?.point || + getSnapPoint()?.point || + getScreenCanvasDrawController().getWorldMouseLocation(), + } as NumberInputEvent); + this.text = ''; + } else if (ABSOLUTE_POINT_REGEXP.test(this.text)) { + console.log('ABSOLUTE_POINT_INPUT: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // User entered coordinates to an absolute point on the canvas. eg: 100, 200 + const match = ABSOLUTE_POINT_REGEXP.exec(this.text); + if (!match) { + return; + } + const x = Number.parseFloat(match[1]); + const y = Number.parseFloat(match[3]); + getActiveToolActor()?.send({ + type: ActorEvent.ABSOLUTE_POINT_INPUT, + value: new Point(x, y), + } as AbsolutePointInputEvent); + this.text = ''; + } else if (RELATIVE_POINT_REGEXP.test(this.text)) { + console.log('RELATIVE_POINT_INPUT: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // User entered coordinates to a relative point on the canvas. eg: @100, 200 + const match = RELATIVE_POINT_REGEXP.exec(this.text); + if (!match) { + return; + } + const x = Number.parseFloat(match[1]); + const y = Number.parseFloat(match[3]); + getActiveToolActor()?.send({ + type: ActorEvent.RELATIVE_POINT_INPUT, + value: new Point(x, y), + } as RelativePointInputEvent); + this.text = ''; + } else { + console.log('TEXT_INPUT: ', { + text: this.text, + activeTool: getActiveToolActor(), + }); + // Send the text to the active tool + getActiveToolActor()?.send({ + type: ActorEvent.TEXT_INPUT, + value: this.text, + } as TextInputEvent); + this.text = ''; + } + } + + public handleUndo(evt: KeyboardEvent) { + evt.preventDefault(); + undo(); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + getActiveToolActor()?.send({ + type: ActorEvent.ESC, + }); + } + + public handleRedo(evt: KeyboardEvent) { + evt.preventDefault(); + redo(); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + getActiveToolActor()?.send({ + type: ActorEvent.ESC, + }); + } + + private drawListBelowInputField( + drawController: ScreenCanvasDrawController, + texts: string[] + ): void { + const screenMouseLocation = drawController.worldToTarget( + drawController.getWorldMouseLocation() + ); + const startY = + screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2; + const offsetY = CANVAS_INPUT_FIELD_HEIGHT; + texts.forEach((text, index) => { + drawController.drawTextScreen( + text, + new Point( + screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, + startY - index * offsetY + ), + { + textAlign: 'left', + textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, + fontSize: 18, + } + ); + }); + } +} diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts new file mode 100644 index 00000000..32477ae5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -0,0 +1,141 @@ +import { Point } from '@flatten-js/core'; +import { type DesignMeta, HtmlEvent } from '../App.types.ts'; +import { TextEntity } from '../entities/TextEntity.ts'; +import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; +import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; +import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts'; +import { + getCanvas, + getDesignMeta, + getEntities, + getLayers, + getScreenCanvasDrawController, + setActiveLayerId, + setDesignMeta, + setEntities, + setLayers, +} from '../state.ts'; + +export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; +export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; +export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b08:drawing-loaded'; +export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b08:drawing-error'; +export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed'; +export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request'; +export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response'; +export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate'; + +interface DrawingLoadMessage { + type: typeof AISLO_DRAWING_LOAD_MESSAGE; + drawing: JsonDrawingFileSerialized; + meta?: DesignMeta | null; +} + +interface DrawingSaveRequestMessage { + type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE; +} + +function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing); +} + +function notifyParent(type: string, payload: Record = {}) { + if (window.parent === window) return; + window.parent.postMessage({ type, ...payload }, window.location.origin); +} + +/** ์ˆ˜๋Ÿ‰ ํŒจ๋„์—์„œ ์ด์ „/๋‹ค์Œ ๋„๋ฉด์œผ๋กœ ์ด๋™ ์š”์ฒญ (๋ถ€๋ชจ๊ฐ€ ์ฒ˜๋ฆฌ). */ +export function requestDrawingNavigation(direction: 'prev' | 'next') { + notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction }); +} + +/** ์ˆ˜๋Ÿ‰ํ‘œ ๊ฐ’ ํŽธ์ง‘์„ ๋ถ€๋ชจ์— ์•Œ๋ฆฐ๋‹ค (ํ™•์ • ์ƒํƒœ ๋กค๋ฐฑ ์—ฐ๋™). */ +export function notifyDrawingChangedByTable() { + notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); +} + +/** + * ๋”๋ธ”ํด๋ฆญํ•œ ์œ„์น˜์˜ ํ…์ŠคํŠธ(์ž ๊ธˆ ํ•ด์ œ ๋ ˆ์ด์–ด)๋ฅผ ์ฐพ์•„ ๋‚ด์šฉ์„ ์ฆ‰์‹œ ํŽธ์ง‘ํ•œ๋‹ค. + * CAD ํ…Œ์ด๋ธ”(์ธก์  ํ…Œ์ด๋ธ”ยท์ˆ˜๋Ÿ‰ ์‚ฐ์ถœํ‘œ) ๊ฐ’ ์…€ ์ˆ˜์ • ์ˆ˜๋‹จ โ€” ์ž ๊ธˆ ๋ ˆ์ด์–ด๋Š” ์ œ์™ธ. + */ +function registerTextDoubleClickEdit() { + const canvas = getCanvas(); + if (!canvas) return; + canvas.addEventListener('dblclick', (event: MouseEvent) => { + const drawController = getScreenCanvasDrawController(); + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + const worldPoint = drawController.targetToWorld(screenPoint); + const lockedLayerIds = new Set( + getLayers() + .filter((layer) => layer.isLocked) + .map((layer) => layer.id) + ); + let closest: TextEntity | null = null; + let closestDistance = Number.MAX_SAFE_INTEGER; + for (const entity of getEntities()) { + if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue; + const distanceInfo = entity.distanceTo(worldPoint); + if (distanceInfo && distanceInfo[0] < closestDistance) { + closestDistance = distanceInfo[0]; + closest = entity; + } + } + if (!closest) return; + const fontSize = closest.getTextOptions().fontSize; + const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2); + if (closestDistance > tolerance) return; + const nextLabel = window.prompt('๊ฐ’ ์ˆ˜์ •', closest.getLabel()); + if (nextLabel === null || nextLabel === closest.getLabel()) return; + closest.setLabel(nextLabel); + setEntities([...getEntities()], true); // undo ์Šคํƒ + DRAWING_CHANGED ํ†ต์ง€ + }); +} + +/** + * B08 parent page์™€ CAD ์•ฑ ์‚ฌ์ด์˜ same-origin JSON ๊ฒฝ๊ณ„๋‹ค. + * DXF/DWG ํŒŒ์ผ์ด๋‚˜ ํŒŒ์„œ ๊ฐ์ฒด๋Š” ์ด ๊ฒฝ๊ณ„๋ฅผ ํ†ต๊ณผํ•˜์ง€ ์•Š๋Š”๋‹ค. + */ +export function registerAisloDrawingBridge() { + window.addEventListener('message', async (event: MessageEvent) => { + if (event.origin !== window.location.origin || event.source !== window.parent) return; + const message = event.data as Partial; + if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { + try { + const drawing = JSON.parse( + await exportEntitiesAndLayersToJsonString() + ) as JsonDrawingFileSerialized; + // ํŽธ์ง‘๋œ ์ˆ˜๋Ÿ‰ํ‘œ ๊ฐ’์„ ๋„๋ฉด๊ณผ ํ•จ๊ป˜ ๋ถ€๋ชจ๋กœ ๋Œ๋ ค์ค€๋‹ค. + const quantityTable = getDesignMeta()?.quantityTable ?? null; + notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable }); + } catch (error) { + const detail = error instanceof Error ? error.message : 'Unable to serialize drawing'; + notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail }); + } + return; + } + if (!isDrawingLoadMessage(event.data)) return; + + try { + const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing); + setEntities(drawing.entities, false); + setLayers(drawing.layers); + setActiveLayerId(drawing.layers[0].id); + // ์„ค๊ณ„ ์ปจํ…์ŠคํŠธ(์ œ๋ชฉยท์ธก์ ์ •๋ณดยทํ™•์ •์ƒํƒœยท์ˆ˜๋Ÿ‰ํ‘œ)๋ฅผ ์ˆ˜๋Ÿ‰ ํŒจ๋„์— ๋ฐ˜์˜ + setDesignMeta(event.data.meta ?? null); + getScreenCanvasDrawController().zoomToFitScreen(); + notifyParent(AISLO_DRAWING_LOADED_MESSAGE); + } catch (error) { + const detail = error instanceof Error ? error.message : 'Invalid drawing JSON'; + notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail }); + } + }); + window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => { + notifyParent(AISLO_DRAWING_CHANGED_MESSAGE); + }); + registerTextDoubleClickEdit(); + + notifyParent(AISLO_DRAWING_READY_MESSAGE); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts new file mode 100644 index 00000000..ff4c7fd0 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts @@ -0,0 +1,18 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; + +/** + * AlignBottom tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are bottom aligned + */ +export const alignBottomToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts new file mode 100644 index 00000000..9a7c7979 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts @@ -0,0 +1,22 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; +import {middle} from "../helpers/middle.ts"; + +/** + * AlignCenterHorizontal tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are center horizontal aligned + */ +export const alignCenterHorizontalToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + const entityBoundingBox = entity.getBoundingBox(); + const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX); + const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax); + entity.move(centerBoundingBoxX - centerEntityX, 0); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts new file mode 100644 index 00000000..f228e4b6 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts @@ -0,0 +1,18 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; + +/** + * AlignLeft tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are left aligned + */ +export const alignLeftToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts new file mode 100644 index 00000000..6e58ee36 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts @@ -0,0 +1,22 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; +import {middle} from "../helpers/middle.ts"; + +/** + * AlignCenterVertical tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are center vertical aligned + */ +export const alignCenterVerticalToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + const entityBoundingBox = entity.getBoundingBox(); + const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY); + const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax); + entity.move(0, centerBoundingBoxY - centerEntityY); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts new file mode 100644 index 00000000..7e1bca6a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts @@ -0,0 +1,18 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; + +/** + * AlignRight tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are right aligned + */ +export const alignRightToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts new file mode 100644 index 00000000..53e03856 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts @@ -0,0 +1,144 @@ +import {assign, type MachineContext, sendTo} from 'xstate'; +import type {Entity} from '../entities/Entity.ts'; +import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state.ts'; +import type {Tool} from '../tools.ts'; +import {selectToolStateMachine} from './select-tool.ts'; +import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts'; + +export interface AlignContext extends ToolContext {} + +export enum AlignState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', +} + +export enum AlignAction { + INIT_ALIGN_TOOL = 'INIT_ALIGN_TOOL', + ALIGN_SELECTION = 'ALIGN_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', +} + +export function GET_ALIGN_TOOL_STATE(type: Tool): MachineContext { + return { + types: {} as { + context: AlignContext; + events: StateEvent; + }, + context: { + type, + }, + initial: AlignState.INIT, + states: { + [AlignState.INIT]: { + description: 'Initializing the align tool', + always: { + actions: AlignAction.INIT_ALIGN_TOOL, + target: AlignState.CHECK_SELECTION, + }, + }, + [AlignState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to align', + meta: { + instructions: 'Select what you want to align, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheAlignTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return {}; + }), + target: AlignState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo( + 'selectToolInsideTheAlignTool', + ({ event }: { event: MouseClickEvent }) => { + return event; + } + ), + }, + ESC: { + actions: [AlignAction.DESELECT_ENTITIES, AlignAction.INIT_ALIGN_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo( + 'selectToolInsideTheAlignTool', + ({ event }: { event: KeyboardEnterEvent }) => { + return event; + } + ), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheAlignTool', ({ event }: { event: DrawEvent }) => { + return event; + }), + }, + }, + }, + [AlignState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + actions: AlignAction.ALIGN_SELECTION, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: AlignState.WAITING_FOR_SELECTION, + }, + ], + }, + }, + }; +} + +export function GET_ALIGN_ACTION(alignEntity: (entity: Entity, boundingBox: BoundingBox) => void) { + return { + actions: { + [AlignAction.INIT_ALIGN_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return {}; + }), + [AlignAction.ALIGN_SELECTION]: () => { + const selectedEntities = getSelectedEntities(); + if (!selectedEntities?.length) { + throw new Error('[ALIGN_LEFT] Calling align without entities selected'); + } + + // Align the entities + const boundingBox = getBoundingBoxOfMultipleEntities(selectedEntities); + + for (const entity of selectedEntities) { + alignEntity(entity, boundingBox); + } + }, + [AlignAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return {}; + }), + ...selectToolStateMachine.implementations.actions, + }, + }; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts new file mode 100644 index 00000000..844366e2 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts @@ -0,0 +1,18 @@ +import {Tool} from '../tools'; +import {createMachine} from 'xstate'; +import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; +import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; +import type {Entity} from "../entities/Entity.ts"; + +/** + * AlignTop tool state machine + * This state machine is responsible for aligning entities on the canvas + * It uses the select tool state machine to select entities to align + * When the user presses enter, the selected entities are top aligned + */ +export const alignTopToolStateMachine = createMachine( + GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY)); + }) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts new file mode 100644 index 00000000..752e7895 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -0,0 +1,368 @@ +import {type Point, Vector} from '@flatten-js/core'; +import {assign, createMachine, sendTo} from 'xstate'; +import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS} from '../App.consts.ts'; +import type {Entity} from '../entities/Entity'; +import {LineEntity} from "../entities/LineEntity.ts"; +import {getPointFromEvent} from "../helpers/get-point-from-event.ts"; +import { + addEntities, + getActiveLayerId, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import {CopyAction} from './copy-tool.ts'; +import {selectToolStateMachine} from './select-tool.ts'; +import type { + AbsolutePointInputEvent, + DrawEvent, + MouseClickEvent, + NumberInputEvent, + StateEvent, + TextInputEvent, + ToolContext, +} from './tool.types'; + +export enum ArrayState { + INIT = 'INIT', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + CHECK_SELECTION = 'CHECK_SELECTION', + ASK_COPY_MODE = 'ASK_COPY_MODE', + ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES', + LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT', + LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT', + RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT', + RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE', + EXECUTE_COPY = 'EXECUTE_COPY', +} + +export enum ArrayAction { + INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL', + PERFORM_COPY = 'PERFORM_COPY', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE', +} + +export interface ArrayContext extends ToolContext { + copyMode: CopyMode | null; + startDistanceVector: Point | null; + endDistanceVector: Point | null; + pivotPoint: Point | null; + angleStep: number | null; + numberOfCopies: number; +} + +enum CopyMode { + LINEAR = 'LINEAR', + RADIAL = 'RADIAL', +} + +const initialArrayContext: ArrayContext = { + copyMode: null, + startDistanceVector: null, + endDistanceVector: null, + pivotPoint: null, + angleStep: null, + numberOfCopies: 0, + type: Tool.ARRAY, +}; + +export const arrayToolStateMachine = createMachine( + { + types: {} as { + context: ArrayContext; + events: StateEvent; + }, + context: initialArrayContext, + initial: ArrayState.INIT, + states: { + [ArrayState.INIT]: { + description: 'Initializing the array copy tool', + always: { + actions: ArrayState.INIT, + target: ArrayState.CHECK_SELECTION, + }, + }, + [ArrayState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to copy', + meta: { + instructions: 'Select what you want to copy, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheCopyTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return initialArrayContext; + }), + target: ArrayState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + }, + }, + [ArrayState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: ArrayState.ASK_COPY_MODE, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: ArrayState.WAITING_FOR_SELECTION, + }, + ], + }, + [ArrayState.ASK_COPY_MODE]: { + meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' }, + on: { + TEXT_INPUT: { + actions: assign(({ event }) => { + return { + copyMode: + (event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR, + }; + }), + target: ArrayState.ASK_NUMBER_OF_COPIES, + }, + ENTER: { + actions: assign(() => ({ + copyMode: CopyMode.LINEAR, + })), + target: ArrayState.ASK_NUMBER_OF_COPIES, + }, + ESC: { + actions: ArrayAction.INIT_ARRAY_TOOL, + }, + }, + }, + [ArrayState.ASK_NUMBER_OF_COPIES]: { + meta: { instructions: 'Enter number of copies' }, + on: { + NUMBER_INPUT: [ + { + guard: ({ context }) => { + return context.copyMode === CopyMode.LINEAR; + }, + actions: assign(({ event }) => ({ + numberOfCopies: (event as NumberInputEvent).value, + })), + target: ArrayState.LINEAR_WAITING_FOR_START_POINT, + }, + { + guard: ({ context }) => { + return context.copyMode === CopyMode.RADIAL; + }, + actions: assign(({ event }) => ({ + numberOfCopies: (event as NumberInputEvent).value, + })), + target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT, + }, + ], + ESC: { + actions: ArrayAction.INIT_ARRAY_TOOL, + }, + }, + }, + [ArrayState.LINEAR_WAITING_FOR_START_POINT]: { + meta: { instructions: 'Click to define the start of the distance vector' }, + on: { + ABSOLUTE_POINT_INPUT: { + actions: assign(({ event }) => ({ + startDistanceVector: (event as AbsolutePointInputEvent).value, + })), + target: ArrayState.EXECUTE_COPY, + }, + MOUSE_CLICK: { + actions: assign(({ event }) => ({ + startDistanceVector: (event as MouseClickEvent).worldMouseLocation, + })), + target: ArrayState.LINEAR_WAITING_FOR_END_POINT, + }, + ESC: { + actions: ArrayAction.INIT_ARRAY_TOOL, + }, + }, + }, + [ArrayState.LINEAR_WAITING_FOR_END_POINT]: { + meta: { instructions: 'Click to define the end of the distance vector' }, + on: { + RELATIVE_POINT_INPUT: { + actions: assign(({ event, context }) => ({ + endDistanceVector: getPointFromEvent(context.startDistanceVector, event), + })), + target: ArrayState.EXECUTE_COPY, + }, + ABSOLUTE_POINT_INPUT: { + actions: assign(({ event, context }) => ({ + endDistanceVector: getPointFromEvent(context.startDistanceVector, event), + })), + target: ArrayState.EXECUTE_COPY, + }, + MOUSE_CLICK: { + actions: assign(({ event, context }) => ({ + endDistanceVector: getPointFromEvent(context.startDistanceVector, event), + })), + target: ArrayState.EXECUTE_COPY, + }, + ESC: { + actions: ArrayAction.INIT_ARRAY_TOOL, + }, + DRAW: { + actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE, + }, + }, + }, + [ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: { + meta: { instructions: 'Click to set pivot point' }, + on: { + ABSOLUTE_POINT_INPUT: { + actions: assign(({ event }) => ({ + pivotPoint: (event as AbsolutePointInputEvent).value, + })), + target: ArrayState.RADIAL_WAITING_FOR_ANGLE, + }, + MOUSE_CLICK: { + actions: assign(({ event }) => ({ + pivotPoint: (event as MouseClickEvent).worldMouseLocation, + })), + target: ArrayState.RADIAL_WAITING_FOR_ANGLE, + }, + ESC: { + actions: ArrayAction.INIT_ARRAY_TOOL, + }, + }, + }, + [ArrayState.RADIAL_WAITING_FOR_ANGLE]: { + meta: { instructions: 'Enter angle per step (in degrees)' }, + on: { + NUMBER_INPUT: { + target: ArrayState.EXECUTE_COPY, + actions: assign(({ event }) => ({ + angleStep: (event as NumberInputEvent).value, + })), + }, + }, + }, + [ArrayState.EXECUTE_COPY]: { + entry: ArrayAction.PERFORM_COPY, + always: ArrayState.INIT, + }, + }, + }, + { + actions: { + [ArrayAction.INIT_ARRAY_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + return { + copyMode: null, + distanceVector: null, + pivotPoint: null, + angleStep: null, + numberOfCopies: 0, + selectedEntities: [], + }; + }), + + [ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => { + if (!context.startDistanceVector) { + throw new Error('[ARRAY] Calling draw temp distance line without a start point'); + } + + const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); + + // Draw the array entities to show the result + // Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied; + // TODO + + // // Draw a dashed line between the start move point and the current mouse location + const activeDistanceLine = new LineEntity( + getActiveLayerId(), + context.startDistanceVector, + endPointTemp + ); + activeDistanceLine.lineColor = GUIDE_LINE_COLOR; + activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH; + activeDistanceLine.lineDash = GUIDE_LINE_STYLE; + setGhostHelperEntities([activeDistanceLine]); + }, + + [ArrayAction.PERFORM_COPY]: ({ context }) => { + const resultEntities: Entity[] = []; + + for (let i = 1; i <= context.numberOfCopies; i++) { + if ( + context.copyMode === 'LINEAR' && + context.startDistanceVector && + context.endDistanceVector + ) { + const distanceVector = new Vector( + context.endDistanceVector.x - context.startDistanceVector.x, + context.endDistanceVector.y - context.startDistanceVector.y + ); + const x = distanceVector.x * i; + const y = distanceVector.y * i; + resultEntities.push( + ...getSelectedEntities().map((entity) => { + const clone = entity.clone(); + clone.move(x, y); + return clone; + }) + ); + } else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) { + const angleRad = context.angleStep * i * TO_RADIANS; + resultEntities.push( + ...getSelectedEntities().map((entity) => { + const clone = entity.clone(); + clone.rotate(context.pivotPoint as Point, angleRad); + return clone; + }) + ); + } + } + + addEntities(resultEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [CopyAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return initialArrayContext; + }), + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts new file mode 100644 index 00000000..716d881f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts @@ -0,0 +1,164 @@ +import { CircleEntity } from '../entities/CircleEntity'; +import type { Point } from '@flatten-js/core'; +import { + addEntities, + getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { Tool } from '../tools'; +import { assign, createMachine } from 'xstate'; +import { pointDistance } from '../helpers/distance-between-points'; +import { LineState } from './line-tool.ts'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; + +export interface CircleContext extends ToolContext { + centerPoint: Point | null; +} + +export enum CircleState { + WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT', + WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE', + INIT = 'INIT', +} + +export enum CircleAction { + INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL', + RECORD_START_POINT = 'RECORD_START_POINT', + DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE', + DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE', +} + +export const circleToolStateMachine = createMachine( + { + types: {} as { + context: CircleContext; + events: StateEvent; + }, + context: { + centerPoint: null, + type: Tool.CIRCLE, + }, + initial: CircleState.INIT, + states: { + [CircleState.INIT]: { + description: 'Initializing the circle tool', + always: { + actions: CircleAction.INIT_CIRCLE_TOOL, + target: CircleState.WAITING_FOR_CENTER_POINT, + }, + }, + [CircleState.WAITING_FOR_CENTER_POINT]: { + description: 'Select the center point of the circle tool', + meta: { + instructions: 'Select the center point of the circle', + }, + on: { + MOUSE_CLICK: { + actions: CircleAction.RECORD_START_POINT, + target: CircleState.WAITING_FOR_POINT_ON_CIRCLE, + }, + ABSOLUTE_POINT_INPUT: { + actions: CircleAction.RECORD_START_POINT, + target: CircleState.WAITING_FOR_POINT_ON_CIRCLE, + }, + }, + }, + [CircleState.WAITING_FOR_POINT_ON_CIRCLE]: { + description: 'Select a point on the circle', + meta: { + instructions: 'Select the point on the circle', + }, + on: { + DRAW: { + actions: CircleAction.DRAW_TEMP_CIRCLE, + }, + MOUSE_CLICK: { + actions: CircleAction.DRAW_FINAL_CIRCLE, + target: CircleState.INIT, + }, + NUMBER_INPUT: { + actions: CircleAction.DRAW_FINAL_CIRCLE, + target: LineState.INIT, + }, + ABSOLUTE_POINT_INPUT: { + actions: CircleAction.DRAW_FINAL_CIRCLE, + target: LineState.INIT, + }, + RELATIVE_POINT_INPUT: { + actions: CircleAction.DRAW_FINAL_CIRCLE, + target: LineState.INIT, + }, + ESC: { + target: CircleState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [CircleAction.INIT_CIRCLE_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + return { + centerPoint: null, + }; + }), + [CircleAction.RECORD_START_POINT]: assign(({ event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + centerPoint: startPoint, + }; + }), + [CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => { + const activeCircle = new CircleEntity( + getActiveLayerId(), + context.centerPoint as Point, + pointDistance( + (event as DrawEvent).drawController.getWorldMouseLocation(), + context.centerPoint as Point + ) + ); + activeCircle.lineColor = getActiveLineColor(); + activeCircle.lineWidth = getActiveLineWidth(); + activeCircle.lineDash = getActiveLineDash(); + setGhostHelperEntities([activeCircle]); + }, + [CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => { + if (!context.centerPoint) { + throw new Error( + 'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool' + ); + } + const pointOnCircle: Point = getPointFromEvent( + context.centerPoint, + event as PointInputEvent + ); + const activeCircle = new CircleEntity( + getActiveLayerId(), + context.centerPoint as Point, + pointDistance(pointOnCircle, context.centerPoint as Point) + ); + activeCircle.lineColor = getActiveLineColor(); + activeCircle.lineWidth = getActiveLineWidth(); + activeCircle.lineDash = getActiveLineDash(); + addEntities([activeCircle], true); + + setGhostHelperEntities([]); + return { + centerPoint: null, + }; + }), + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts new file mode 100644 index 00000000..0b41da5e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -0,0 +1,296 @@ +import type {Point} from '@flatten-js/core'; +import { + addEntities, + getActiveLayerId, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; +import {assign, createMachine, sendTo} from 'xstate'; +import {selectToolStateMachine} from './select-tool'; +import type {Entity} from '../entities/Entity'; +import {compact} from 'es-toolkit'; +import {LineEntity} from '../entities/LineEntity'; +import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts'; +import {moveEntities} from './move-tool.helpers'; + +export interface CopyContext extends ToolContext { + startPoint: Point | null; + originalSelectedEntities: Entity[]; + copiedEntities: Entity[]; + lastDrawLocation: Point | null; +} + +export enum CopyState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT', + WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT', +} + +export enum CopyAction { + INIT_COPY_TOOL = 'INIT_COPY_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_START_POINT = 'RECORD_START_POINT', + COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY', + DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES', + COPY_SELECTION = 'COPY_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', +} + +/** + * Copy tool state machine + * This state machine is responsible for copying entities around the canvas + * It uses the select tool state machine to select entities to copy + * When the user presses enter, the selected entities are marked for copying + * The user can then select a start point + * When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location + * When the user clicks again, the end point is selected and the entities are copied to the new location + */ +export const copyToolStateMachine = createMachine( + { + types: {} as { + context: CopyContext; + events: StateEvent; + }, + context: { + startPoint: null, + originalSelectedEntities: [], + copiedEntities: [], + lastDrawLocation: null, + type: Tool.COPY, + }, + initial: CopyState.INIT, + states: { + [CopyState.INIT]: { + description: 'Initializing the copy tool', + always: { + actions: CopyAction.INIT_COPY_TOOL, + target: CopyState.CHECK_SELECTION, + }, + }, + [CopyState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: CopyState.WAITING_FOR_START_COPY_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: CopyState.WAITING_FOR_SELECTION, + }, + ], + }, + [CopyState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to copy', + meta: { + instructions: 'Select what you want to copy, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheCopyTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + startPoint: null, + }; + }), + target: CopyState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + }, + }, + [CopyState.WAITING_FOR_START_COPY_POINT]: { + description: 'Select the start of the copy line', + meta: { + instructions: 'Select the start of the copy line', + }, + always: { + actions: CopyAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [ + CopyAction.RECORD_START_POINT, + CopyAction.COPY_SELECTION_BEFORE_COPY, + ], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ESC: { + actions: CopyAction.DESELECT_ENTITIES, + target: CopyState.INIT, + }, + }, + }, + [CopyState.WAITING_FOR_END_COPY_POINT]: { + description: 'Select the end of the copy line', + meta: { + instructions: 'Select the end of the copy line', + }, + on: { + DRAW: { + actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES], + }, + MOUSE_CLICK: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ESC: { + actions: CopyAction.DESELECT_ENTITIES, + target: CopyState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [CopyAction.INIT_COPY_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + originalSelectedEntities: [], + copiedEntities: [], + lastDrawLocation: null, + }; + }), + [CopyAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [CopyAction.RECORD_START_POINT]: assign(({ event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + startPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); + + // Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides + + setSelectedEntityIds([]); + return { + startPoint: context.startPoint, + // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action + originalSelectedEntities: compact( + selectedEntities.map(entity => entity.clone()), + ), + copiedEntities: selectedEntities, + }; + }), + [CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[COPY] Calling draw temp copy line without a start point', + ); + } + + const endPointTemp = ( + event as DrawEvent + ).drawController.getWorldMouseLocation(); + + // Copy the entities to the new location + // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; + const movedEntities = context.originalSelectedEntities.map(entity => + entity.clone(), + ); + moveEntities( + movedEntities, + endPointTemp.x - context.startPoint.x, + endPointTemp.y - context.startPoint.y, + ); + + // // Draw a dashed line between the start copy point and the current mouse location + const activeCopyLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + endPointTemp, + ); + activeCopyLine.lineColor = GUIDE_LINE_COLOR; + activeCopyLine.lineWidth = GUIDE_LINE_WIDTH; + activeCopyLine.lineDash = GUIDE_LINE_STYLE; + setGhostHelperEntities([activeCopyLine, ...movedEntities]); + }, + [CopyAction.COPY_SELECTION]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[COPY] Calling copy selection without a start point', + ); + } + + // Copy the entities one final time + const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + const copiedEntities = context.originalSelectedEntities.map(entity => + entity.clone(), + ); + moveEntities( + copiedEntities, + currentEndPoint.x - context.startPoint.x, + currentEndPoint.y - context.startPoint.y, + ); + + // Switch the copied entities back from the ghost helper entities to the real entities + addEntities([...context.originalSelectedEntities, ...copiedEntities], true); + }, + [CopyAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts new file mode 100644 index 00000000..22943e86 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts @@ -0,0 +1,126 @@ +import {type Circle, Point, type Segment} from '@flatten-js/core'; +import {compact} from 'es-toolkit'; +import {ArcEntity} from '../entities/ArcEntity'; +import type {CircleEntity} from '../entities/CircleEntity'; +import type {Entity} from '../entities/Entity'; +import type {LineEntity} from '../entities/LineEntity'; +import {findNeighboringPointsOnArc} from '../helpers/find-neighboring-points-on-arc'; +import {findNeighboringPointsOnCircle} from '../helpers/find-neighboring-points-on-circle'; +import {findNeighboringPointsOnLine} from '../helpers/find-neighboring-points-on-line'; +import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts'; +import {isPointEqual} from '../helpers/is-point-equal'; +import {addEntities, deleteEntities, getActiveLayerId} from '../state'; + +export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] { + // TODO see if we need to make this list unique + return compact( + entities + .filter((otherEntity) => otherEntity.id !== entity.id) + .flatMap((otherEntity) => { + if (entity.id === otherEntity.id) { + return null; + } + return entity.getIntersections(otherEntity); + }) + ); +} + +export function eraseLineSegment( + line: LineEntity, + clickedPointOnShape: Point, + intersections: Point[] +): void { + const segment = line.getShape() as Segment; + const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnLine( + clickedPointOnShape, + segment.start, + segment.end, + intersections + ); + + const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]); + // Remove the segment that has the clickedPointOnShape point on it + const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape)); + deleteEntities([line], false); + + // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation + addEntities(remainingLines, false); +} + +export function eraseCircleSegment( + circle: CircleEntity, + clickedPointOnShape: Point, + intersections: Point[] +): void { + if (intersections.length === 0) { + deleteEntities([circle], false); + return; + } + + const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnCircle( + clickedPointOnShape, + circle, + intersections + ); + + const circleShape = circle.getShape() as Circle; + const center = circleShape.center; + + const angles = [firstCutPoint, secondCutPoint, clickedPointOnShape].map((p) => + getAngleWithXAxis(new Point(center.x, center.y), new Point(p.x, p.y)) + ); + + const [startAngle, endAngle] = isAngleBetween(angles[2], angles[0], angles[1]) + ? [angles[1], angles[0]] + : [angles[0], angles[1]]; + + const newArc = new ArcEntity( + getActiveLayerId(), + center, + circleShape.r, + startAngle, + endAngle, + true + ); + Object.assign(newArc, { + lineColor: circle.lineColor, + lineWidth: circle.lineWidth, + }); + + deleteEntities([circle], false); + + // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation + addEntities([newArc], false); +} + +function isAngleBetween(angle: number, start: number, end: number): boolean { + const twoPi = 2 * Math.PI; + return (angle - start + twoPi) % twoPi <= (end - start + twoPi) % twoPi; +} + +export function eraseArcSegment( + arc: ArcEntity, + clickedPointOnShape: Point, + intersections: Point[] +): void { + const [first, second] = findNeighboringPointsOnArc(clickedPointOnShape, arc, intersections); + + if (isPointEqual(first, second)) { + deleteEntities([arc], true); + return; + } + + const newArcs = arc + .cutAtPoints([first, second]) + .filter((cutArc) => !cutArc.containsPointOnShape(clickedPointOnShape)); + for (const newArc of newArcs) { + Object.assign(newArc, { + lineColor: arc.lineColor, + lineWidth: arc.lineWidth, + }); + addEntities([newArc], false); + } + + // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation + deleteEntities([arc], false); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts new file mode 100644 index 00000000..8aa8d85c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts @@ -0,0 +1,83 @@ +import {type Arc, Point} from '@flatten-js/core'; +import {describe, expect, it} from 'vitest'; +import {TO_DEGREES, TO_RADIANS} from '../App.consts.ts'; +import type {ArcEntity} from '../entities/ArcEntity.ts'; +import {CircleEntity} from '../entities/CircleEntity.ts'; +import {EntityName} from '../entities/Entity.ts'; +import {RectangleEntity} from '../entities/RectangleEntity.ts'; +import {getEntities, setEntities} from '../state.ts'; +import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts'; +import {handleMouseClick} from './eraser-tool.ts'; + +describe('erase-tool', () => { + /** + * --- + * --- --- + * -- -- + * - ------------------------- + * -- | | + * --- | x | + * --| | + * | | + * | | + * | | + * | | + * ------------------------- + */ + it('should delete part of circle to form an arc', () => { + const entities = [ + new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)), + new CircleEntity('layer1', new Point(0, 0), 10), + ]; + setEntities(entities); + handleMouseClick(new Point(8, -8)); + const entitiesAfterErase = getEntities(); + expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle); + expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc); + const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc; + expect(arc.center).toEqual(new Point(0, 0)); + expect(arc.r).toEqual(10); + expect(arc.startAngle * TO_DEGREES).toEqual(0); + expect(arc.endAngle * TO_DEGREES).toEqual(270); + }); + /** + * --- + * --- --- + * -- -- + * - ------------------------- + * -- | | + * --- | x | + * --| | + * | | + * | | + * | | + * | | + * ------------------------- + */ + it('should delete part of circle to form an arc using inner functions', () => { + const entities = [ + new RectangleEntity('layer1', new Point(0, 0), new Point(20, -20)), + new CircleEntity('layer1', new Point(0, 0), 10), + ]; + setEntities(entities); + + const intersections = getAllIntersectionPoints(entities[1], getEntities()); + expect(intersections[0]).toEqual(new Point(0, -10)); + expect(intersections[1]).toEqual(new Point(10, 0)); + + eraseCircleSegment( + entities[1] as CircleEntity, + new Point(10 * Math.cos(-45 * TO_RADIANS), 10 * Math.sin(-45 * TO_RADIANS)), + intersections + ); + + const entitiesAfterErase = getEntities(); + expect(entitiesAfterErase[0].getType()).toEqual(EntityName.Rectangle); + expect(entitiesAfterErase[1].getType()).toEqual(EntityName.Arc); + const arc = (entitiesAfterErase[1] as ArcEntity).getShape() as Arc; + expect(arc.center).toEqual(new Point(0, 0)); + expect(arc.r).toEqual(10); + expect(arc.startAngle * TO_DEGREES).toEqual(0); + expect(arc.endAngle * TO_DEGREES).toEqual(270); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts new file mode 100644 index 00000000..1354de9e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts @@ -0,0 +1,152 @@ +import type {Point, Polygon} from '@flatten-js/core'; +import {assign, createMachine} from 'xstate'; +import type {ArcEntity} from '../entities/ArcEntity'; +import type {CircleEntity} from '../entities/CircleEntity'; +import {EntityName} from '../entities/Entity'; +import {LineEntity} from '../entities/LineEntity'; +import type {RectangleEntity} from '../entities/RectangleEntity'; +import {findClosestEntity} from '../helpers/find-closest-entity'; +import {polygonToSegments} from '../helpers/polygon-to-segments'; +import { + addEntities, + deleteEntities, + getActiveLayerId, + getEntities, + setEntities, + setGhostHelperEntities, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import {eraseArcSegment, eraseCircleSegment, eraseLineSegment, getAllIntersectionPoints,} from './eraser-tool.helpers'; +import type {MouseClickEvent, StateEvent, ToolContext} from './tool.types'; + +export interface EraserContext extends ToolContext { + startPoint: Point | null; +} + +export enum EraserState { + INIT = 'INIT', + WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK', +} + +export enum EraserAction { + INIT_ERASER_TOOL = 'INIT_ERASER_TOOL', + HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK', +} + +export const eraserToolStateMachine = createMachine( + { + types: {} as { + context: EraserContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.ERASER, + }, + initial: EraserState.INIT, + states: { + [EraserState.INIT]: { + description: 'Initializing the eraser tool', + always: { + actions: EraserAction.INIT_ERASER_TOOL, + target: EraserState.WAITING_FOR_FIRST_CLICK, + }, + }, + [EraserState.WAITING_FOR_FIRST_CLICK]: { + description: 'Select a line segment to delete', + meta: { + instructions: 'Select a line segment to delete', + }, + on: { + // TODO implement a DRAW action to draw the segment that will be erased in dotted line + MOUSE_CLICK: { + actions: EraserAction.HANDLE_MOUSE_CLICK, + target: EraserState.WAITING_FOR_FIRST_CLICK, + }, + }, + }, + // TODO implement rectangle selection to delete + }, + }, + { + actions: { + [EraserAction.INIT_ERASER_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + return {}; + }), + [EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => { + handleMouseClick((event as MouseClickEvent).worldMouseLocation); + + return context; + }), + }, + } +); + +export function handleMouseClick(worldMouseLocation: Point) { + const closestEntity = findClosestEntity(worldMouseLocation, getEntities()); + if (!closestEntity) { + return; + } + + const clickedPointOnShape = closestEntity.segment.start; + + // Find entities that intersect with the closest entity + const intersections = getAllIntersectionPoints(closestEntity.entity, getEntities()); + + const entityType = closestEntity.entity.getType(); + switch (entityType) { + case EntityName.Line: { + const line = closestEntity.entity as LineEntity; + eraseLineSegment(line, clickedPointOnShape, intersections); + break; + } + + case EntityName.Circle: { + const circle = closestEntity.entity as CircleEntity; + eraseCircleSegment(circle, clickedPointOnShape, intersections); + break; + } + + case EntityName.Arc: { + const arc = closestEntity.entity as ArcEntity; + eraseArcSegment(arc, clickedPointOnShape, intersections); + break; + } + + case EntityName.Rectangle: { + const rectangle = closestEntity.entity as RectangleEntity; + const segments = polygonToSegments(rectangle.getShape() as Polygon); + const segmentEntities = segments.map( + (segment) => new LineEntity(getActiveLayerId(), segment) + ); + + // Find the closest segment to the clicked point + const closestSegmentInfo = findClosestEntity(worldMouseLocation, segmentEntities); + + // Remove the rectangle + deleteEntities([rectangle], false); + + // Add 4 lines instead of the rectangle + addEntities(segmentEntities, false); + + // Remove (a segment) of the 4th line closest to the cursor + const lineIntersections = getAllIntersectionPoints(closestSegmentInfo.entity, getEntities()); + eraseLineSegment( + closestSegmentInfo.entity as LineEntity, + clickedPointOnShape, + lineIntersections + ); + break; + } + + case EntityName.Image: { + // TODO implement image eraser + // Switch to rectangle entity and delete the image and the line that was closest to the cursor + } + } + + setEntities(getEntities(), true); // Force a new undo state entry +} diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts new file mode 100644 index 00000000..ee7ce6d1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts @@ -0,0 +1,46 @@ +import {Box, type Point} from '@flatten-js/core'; + +export function getContainRectangleInsideRectangle( + imageWidth: number, + imageHeight: number, + rectangleStartPoint: Point, + rectangleEndPoint: Point +): Box | null { + const width = Math.abs(rectangleStartPoint.x - rectangleEndPoint.x); + const height = Math.abs(rectangleStartPoint.y - rectangleEndPoint.y); + + if (width === 0 || height === 0) { + return null; + } + + const imageAspectRatio = imageWidth / imageHeight; + const cursorRectangleAspectRatio = width / height; + + // Draw the image to contain the rectangle created by rectangleStartPoint and rectangleEndPoint + if (imageAspectRatio < cursorRectangleAspectRatio) { + const newWidth = Math.abs(height * imageAspectRatio); + + const drawX = + rectangleStartPoint.x < rectangleEndPoint.x + ? rectangleStartPoint.x + : rectangleStartPoint.x - newWidth; + const drawY = + rectangleStartPoint.y < rectangleEndPoint.y + ? rectangleStartPoint.y + : rectangleStartPoint.y - height; + + return new Box(drawX, drawY, drawX + newWidth, drawY + height); + } + const newHeight = Math.abs(width / imageAspectRatio); + + const drawX = + rectangleStartPoint.x < rectangleEndPoint.x + ? rectangleStartPoint.x + : rectangleStartPoint.x - width; + const drawY = + rectangleStartPoint.y < rectangleEndPoint.y + ? rectangleStartPoint.y + : rectangleStartPoint.y - newHeight; + + return new Box(drawX, drawY, drawX + width, drawY + newHeight); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts new file mode 100644 index 00000000..44350c7a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts @@ -0,0 +1,245 @@ +import type {Point} from '@flatten-js/core'; +import { + addEntities, + getActiveLayerId, + setActiveToolActor, + setAngleGuideEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import {Actor, assign, createMachine} from 'xstate'; +import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types'; +import {ImageEntity} from '../entities/ImageEntity'; +import {getContainRectangleInsideRectangle} from './image-import-tool.helpers'; +import {RectangleEntity} from '../entities/RectangleEntity'; +import {selectToolStateMachine} from './select-tool'; +import {boxToPolygon, twoPointBoxToPolygon} from '../helpers/box-to-polygon'; +import {isPointEqual} from '../helpers/is-point-equal.ts'; +import {getPointFromEvent} from '../helpers/get-point-from-event.ts'; + +export interface ImageImportContext extends ToolContext { + startPoint: Point | null; + imageElement: HTMLImageElement | null; +} + +export enum ImageImportState { + INIT = 'INIT', + WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA', + WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', + WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', +} + +export enum ImageImportAction { + INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL', + STORE_IMAGE_DATA = 'STORE_IMAGE_DATA', + RECORD_START_POINT = 'RECORD_START_POINT', + DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT', + DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT', + SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', +} + +export const imageImportToolStateMachine = createMachine( + { + types: {} as { + context: ImageImportContext; + events: StateEvent; + }, + context: { + type: Tool.IMAGE_IMPORT, + startPoint: null, + imageElement: null, + }, + initial: ImageImportState.INIT, + states: { + [ImageImportState.INIT]: { + description: 'Initializing the imageImport tool', + always: { + actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + target: ImageImportState.WAIT_FOR_IMAGE_DATA, + }, + }, + [ImageImportState.WAIT_FOR_IMAGE_DATA]: { + description: 'Select an image file to import', + meta: { + instructions: 'Select an image file to import', + }, + on: { + [ActorEvent.FILE_SELECTED]: { + actions: ImageImportAction.STORE_IMAGE_DATA, + target: ImageImportState.WAITING_FOR_START_POINT, + }, + ESC: { + actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, + }, + }, + }, + [ImageImportState.WAITING_FOR_START_POINT]: { + description: 'Select the start point of the imageImport', + meta: { + instructions: 'Select the start point of the imageImport', + }, + on: { + MOUSE_CLICK: { + actions: ImageImportAction.RECORD_START_POINT, + target: ImageImportState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: ImageImportAction.RECORD_START_POINT, + target: ImageImportState.WAITING_FOR_END_POINT, + }, + }, + }, + [ImageImportState.WAITING_FOR_END_POINT]: { + description: 'Select the end point of the imageImport', + meta: { + instructions: 'Select the end point of the imageImport', + }, + on: { + DRAW: { + actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT, + }, + MOUSE_CLICK: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + NUMBER_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + ABSOLUTE_POINT_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + RELATIVE_POINT_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + ESC: { + actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, + }, + }, + }, + }, + }, + { + actions: { + [ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + imageElement: null, + }; + }), + [ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => { + return { + imageElement: (event as FileSelectedEvent).image, + }; + }), + [ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + ...context, + startPoint: startPoint, + }; + }), + [ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT', + ); + } + if (!context.imageElement) { + throw new Error( + '[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT', + ); + } + if ( + isPointEqual( + context.startPoint, + (event as DrawEvent).drawController.getWorldMouseLocation(), + ) + ) { + return; // Can't draw an image that is 0 pixels wide + } + const endPoint = getPointFromEvent( + context.startPoint, + event as PointInputEvent, + ); + const containRectangle = getContainRectangleInsideRectangle( + context.imageElement.naturalWidth, + context.imageElement.naturalHeight, + context.startPoint, + endPoint, + ); + if (!containRectangle) { + return; + } + const activeImage = new ImageEntity( + getActiveLayerId(), + context.imageElement, + containRectangle.low, + containRectangle.high, + 0, + ); + const draggedRectangle = new RectangleEntity( + getActiveLayerId(), + twoPointBoxToPolygon(context.startPoint, endPoint), + ); + setGhostHelperEntities([activeImage]); + setAngleGuideEntities([draggedRectangle]); + }, + [ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT', + ); + } + if (!context.imageElement) { + throw new Error( + '[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT', + ); + } + + const containRectangle = getContainRectangleInsideRectangle( + context.imageElement.naturalWidth, + context.imageElement.naturalHeight, + context.startPoint, + (event as MouseClickEvent).worldMouseLocation, + ); + + if (!containRectangle) { + return; + } + + const activeImage = new ImageEntity( + getActiveLayerId(), + context.imageElement, + boxToPolygon(containRectangle), + ); + addEntities([activeImage], true); + }, + [ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => { + setActiveToolActor(new Actor(selectToolStateMachine)); + }, + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts new file mode 100644 index 00000000..42dc7dd3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts @@ -0,0 +1,169 @@ +import type { Point } from '@flatten-js/core'; +import { LineEntity } from '../entities/LineEntity'; +import { + addEntities, + getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + setActiveToolActor, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import { Tool } from '../tools'; +import { Actor, assign, createMachine } from 'xstate'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { selectToolStateMachine } from './select-tool.ts'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; + +export interface LineContext extends ToolContext { + startPoint: Point | null; +} + +export enum LineState { + INIT = 'INIT', + WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', + WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', +} + +export enum LineAction { + INIT_LINE_TOOL = 'INIT_LINE_TOOL', + RECORD_START_POINT = 'RECORD_START_POINT', + DRAW_TEMP_LINE = 'DRAW_TEMP_LINE', + DRAW_FINAL_LINE = 'DRAW_FINAL_LINE', + SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', +} + +export const lineToolStateMachine = createMachine( + { + types: {} as { + context: LineContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.LINE, + }, + initial: LineState.INIT, + states: { + [LineState.INIT]: { + description: 'Initializing the line tool', + always: { + actions: LineAction.INIT_LINE_TOOL, + target: LineState.WAITING_FOR_START_POINT, + }, + }, + [LineState.WAITING_FOR_START_POINT]: { + description: 'Select the start point of the line', + meta: { + instructions: 'Select the start point of the line', + }, + on: { + MOUSE_CLICK: { + actions: LineAction.RECORD_START_POINT, + target: LineState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: LineAction.RECORD_START_POINT, + target: LineState.WAITING_FOR_END_POINT, + }, + ESC: { + actions: LineAction.SWITCH_TO_SELECT_TOOL, + }, + }, + }, + [LineState.WAITING_FOR_END_POINT]: { + description: 'Select the end point of the line', + meta: { + instructions: 'Select the end point of the line', + }, + on: { + DRAW: { + actions: LineAction.DRAW_TEMP_LINE, + }, + MOUSE_CLICK: { + actions: LineAction.DRAW_FINAL_LINE, + target: LineState.WAITING_FOR_END_POINT, + }, + NUMBER_INPUT: { + actions: LineAction.DRAW_FINAL_LINE, + target: LineState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: LineAction.DRAW_FINAL_LINE, + target: LineState.WAITING_FOR_END_POINT, + }, + RELATIVE_POINT_INPUT: { + actions: LineAction.DRAW_FINAL_LINE, + target: LineState.WAITING_FOR_END_POINT, + }, + ESC: { + target: LineState.INIT, + }, + ENTER: { + target: LineState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [LineAction.INIT_LINE_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + }; + }), + [LineAction.RECORD_START_POINT]: assign(({ event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + startPoint, + }; + }), + [LineAction.DRAW_TEMP_LINE]: ({ context, event }) => { + const activeLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + (event as DrawEvent).drawController.getWorldMouseLocation() + ); + activeLine.lineColor = getActiveLineColor(); + activeLine.lineWidth = getActiveLineWidth(); + activeLine.lineDash = getActiveLineDash(); + setGhostHelperEntities([activeLine]); + }, + [LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => { + if (!context.startPoint) { + throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity'); + } + + const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + const activeLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + endPoint + ); + activeLine.lineColor = getActiveLineColor(); + activeLine.lineWidth = getActiveLineWidth(); + activeLine.lineDash = getActiveLineDash(); + addEntities([activeLine], true); + + // Keep drawing from the last point + setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]); + setAngleGuideOriginPoint(endPoint); + return { + startPoint: endPoint, + }; + }), + [LineAction.SWITCH_TO_SELECT_TOOL]: () => { + setActiveToolActor(new Actor(selectToolStateMachine)); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts b/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts new file mode 100644 index 00000000..4a7fe04c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts @@ -0,0 +1,221 @@ +import { type Point, Vector } from '@flatten-js/core'; +import { MeasurementEntity } from '../entities/MeasurementEntity'; +import { + addEntities, + getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import { Tool } from '../tools'; +import { assign, createMachine } from 'xstate'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; +import { isPointEqual } from '../helpers/is-point-equal.ts'; + +export interface MeasurementContext extends ToolContext { + startPoint: Point | null; + endPoint: Point | null; +} + +export enum MeasurementState { + INIT = 'INIT', + WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', + WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', + WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET', +} + +export enum MeasurementAction { + INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL', + RECORD_START_POINT = 'RECORD_START_POINT', + RECORD_END_POINT = 'RECORD_END_POINT', + DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT', + DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT', +} + +export const measurementToolStateMachine = createMachine( + { + types: {} as { + context: MeasurementContext; + events: StateEvent; + }, + context: { + startPoint: null, + endPoint: null, + type: Tool.MEASUREMENT, + }, + initial: MeasurementState.INIT, + states: { + [MeasurementState.INIT]: { + description: 'Initializing the line tool', + always: { + actions: MeasurementAction.INIT_MEASUREMENT_TOOL, + target: MeasurementState.WAITING_FOR_START_POINT, + }, + }, + [MeasurementState.WAITING_FOR_START_POINT]: { + description: 'Select the start point of the measurement', + meta: { + instructions: 'Select the start point of the measurement', + }, + on: { + MOUSE_CLICK: { + actions: MeasurementAction.RECORD_START_POINT, + target: MeasurementState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: MeasurementAction.RECORD_START_POINT, + target: MeasurementState.WAITING_FOR_END_POINT, + }, + }, + }, + [MeasurementState.WAITING_FOR_END_POINT]: { + description: 'Select the end point of the measurement', + meta: { + instructions: 'Select the end point of the measurement', + }, + on: { + DRAW: { + actions: MeasurementAction.DRAW_TEMP_MEASUREMENT, + }, + MOUSE_CLICK: { + actions: MeasurementAction.RECORD_END_POINT, + target: MeasurementState.WAITING_FOR_OFFSET, + }, + NUMBER_INPUT: { + actions: MeasurementAction.RECORD_END_POINT, + target: MeasurementState.WAITING_FOR_OFFSET, + }, + ABSOLUTE_POINT_INPUT: { + actions: MeasurementAction.RECORD_END_POINT, + target: MeasurementState.WAITING_FOR_OFFSET, + }, + RELATIVE_POINT_INPUT: { + actions: MeasurementAction.RECORD_END_POINT, + target: MeasurementState.WAITING_FOR_OFFSET, + }, + ESC: { + target: MeasurementState.INIT, + }, + }, + }, + [MeasurementState.WAITING_FOR_OFFSET]: { + description: 'Select the offset to display the measurement at', + meta: { + instructions: 'Select the offset to display the measurement at', + }, + on: { + DRAW: { + actions: MeasurementAction.DRAW_TEMP_MEASUREMENT, + }, + MOUSE_CLICK: { + actions: MeasurementAction.DRAW_FINAL_MEASUREMENT, + target: MeasurementState.INIT, + }, + NUMBER_INPUT: { + actions: MeasurementAction.DRAW_FINAL_MEASUREMENT, + target: MeasurementState.INIT, + }, + ABSOLUTE_POINT_INPUT: { + actions: MeasurementAction.DRAW_FINAL_MEASUREMENT, + target: MeasurementState.INIT, + }, + RELATIVE_POINT_INPUT: { + actions: MeasurementAction.DRAW_FINAL_MEASUREMENT, + target: MeasurementState.INIT, + }, + ESC: { + target: MeasurementState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + endPoint: null, + }; + }), + [MeasurementAction.RECORD_START_POINT]: assign(({ event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + startPoint, + }; + }), + [MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => { + const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + setAngleGuideOriginPoint(endPoint); + return { + ...context, + endPoint, + }; + }), + [MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => { + const startPoint = context.startPoint as Point; + + let endPoint: Point; + let offsetPoint: Point; + if (!context.endPoint) { + // User has drawn startPoint, but not yet endPoint + // Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction + endPoint = (event as DrawEvent).drawController.getWorldMouseLocation(); + + if (isPointEqual(startPoint, endPoint)) { + return; // Cannot draw temp measurement when start and endpoint are equal + } + + const normalVector = new Vector(startPoint, endPoint) + .rotate(-90 * TO_RADIANS) + .normalize(); + // Pixel constant โ†’ world units so the default offset is zoom-independent + const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1; + offsetPoint = startPoint + .clone() + .translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor)); + } else { + // User has already selected a startPoint and endPoint + // The offsetPoint should be set to the mouse location + endPoint = context.endPoint as Point; + offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation(); + } + + const activeMeasurement = new MeasurementEntity( + getActiveLayerId(), + context.startPoint as Point, + endPoint, + offsetPoint + ); + activeMeasurement.lineColor = getActiveLineColor(); + activeMeasurement.lineWidth = getActiveLineWidth(); + activeMeasurement.lineDash = getActiveLineDash(); + setGhostHelperEntities([activeMeasurement]); + }, + [MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => { + const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent); + const activeMeasurement = new MeasurementEntity( + getActiveLayerId(), + context.startPoint as Point, + context.endPoint as Point, + offsetPoint + ); + activeMeasurement.lineColor = getActiveLineColor(); + activeMeasurement.lineWidth = getActiveLineWidth(); + activeMeasurement.lineDash = getActiveLineDash(); + addEntities([activeMeasurement], true); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts new file mode 100644 index 00000000..8b39afb5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts @@ -0,0 +1,13 @@ +import type {Entity} from '../entities/Entity'; + +/** + * Move entities by the difference between the start and end points + * @param entities + * @param deltaX + * @param deltaY + */ +export function moveEntities(entities: Entity[], deltaX: number, deltaY: number) { + for (const entity of entities) { + entity.move(deltaX, deltaY); + } +} diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts new file mode 100644 index 00000000..39a8de0c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -0,0 +1,298 @@ +import type {Point} from '@flatten-js/core'; +import { + addEntities, + deleteEntities, + getActiveLayerId, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; +import {assign, createMachine, sendTo} from 'xstate'; +import {selectToolStateMachine} from './select-tool'; +import type {Entity} from '../entities/Entity'; +import {compact} from 'es-toolkit'; +import {moveEntities} from './move-tool.helpers'; +import {LineEntity} from '../entities/LineEntity'; +import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts'; + +export interface MoveContext extends ToolContext { + startPoint: Point | null; + originalSelectedEntities: Entity[]; + movedEntities: Entity[]; + lastDrawLocation: Point | null; +} + +export enum MoveState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT', + WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT', +} + +export enum MoveAction { + INIT_MOVE_TOOL = 'INIT_MOVE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_START_POINT = 'RECORD_START_POINT', + COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE', + DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES', + MOVE_SELECTION = 'MOVE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', +} + +/** + * Move tool state machine + * This state machine is responsible for moving entities around the canvas + * It uses the select tool state machine to select entities to move + * When the user presses enter, the selected entities are marked for moving + * The user can then select a start point + * When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location + * When the user clicks again, the end point is selected and the entities are moved to the new location + */ +export const moveToolStateMachine = createMachine( + { + types: {} as { + context: MoveContext; + events: StateEvent; + }, + context: { + startPoint: null, + originalSelectedEntities: [], + movedEntities: [], + lastDrawLocation: null, + type: Tool.MOVE, + }, + initial: MoveState.INIT, + states: { + [MoveState.INIT]: { + description: 'Initializing the move tool', + always: { + actions: MoveAction.INIT_MOVE_TOOL, + target: MoveState.CHECK_SELECTION, + }, + }, + [MoveState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: MoveState.WAITING_FOR_START_MOVE_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: MoveState.WAITING_FOR_SELECTION, + }, + ], + }, + [MoveState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to move', + meta: { + instructions: 'Select what you want to move, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheMoveTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + startPoint: null, + }; + }), + target: MoveState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + }, + }, + [MoveState.WAITING_FOR_START_MOVE_POINT]: { + description: 'Select the start of the move line', + meta: { + instructions: 'Select the start of the move line', + }, + always: { + actions: MoveAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [ + MoveAction.RECORD_START_POINT, + MoveAction.COPY_SELECTION_BEFORE_MOVE, + ], + target: MoveState.WAITING_FOR_END_MOVE_POINT, + }, + ESC: { + actions: MoveAction.DESELECT_ENTITIES, + target: MoveState.INIT, + }, + }, + }, + [MoveState.WAITING_FOR_END_MOVE_POINT]: { + description: 'Select the end of the move line', + meta: { + instructions: 'Select the end of the move line', + }, + on: { + DRAW: { + actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: MoveAction.RESTORE_ORIGINAL_ENTITIES, + target: MoveState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [MoveAction.INIT_MOVE_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + originalSelectedEntities: [], + movedEntities: [], + lastDrawLocation: null, + }; + }), + [MoveAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [MoveAction.RECORD_START_POINT]: assign(({ event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + startPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); + + // Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); + + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides + + setSelectedEntityIds([]); + return { + startPoint: context.startPoint, + // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action + originalSelectedEntities: compact( + selectedEntities.map(entity => entity.clone()), + ), + movedEntities: selectedEntities, + }; + }), + [MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[MOVE] Calling draw temp move line without a start point', + ); + } + + const endPointTemp = ( + event as DrawEvent + ).drawController.getWorldMouseLocation(); + + // Move the entities to the new location + // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; + const movedEntities = context.originalSelectedEntities.map(entity => + entity.clone(), + ); + moveEntities( + movedEntities, + endPointTemp.x - context.startPoint.x, + endPointTemp.y - context.startPoint.y, + ); + + // // Draw a dashed line between the start move point and the current mouse location + const activeMoveLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + endPointTemp, + ); + activeMoveLine.lineColor = GUIDE_LINE_COLOR; + activeMoveLine.lineWidth = GUIDE_LINE_WIDTH; + activeMoveLine.lineDash = GUIDE_LINE_STYLE; + setGhostHelperEntities([activeMoveLine, ...movedEntities]); + }, + [MoveAction.MOVE_SELECTION]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[MOVE] Calling move selection without a start point', + ); + } + + // Move the entities one final time + const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + moveEntities( + context.originalSelectedEntities, + currentEndPoint.x - context.startPoint.x, + currentEndPoint.y - context.startPoint.y, + ); + + // Switch the moved entities back from the ghost helper entities to the real entities + addEntities(context.originalSelectedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [MoveAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts new file mode 100644 index 00000000..2e7447d3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts @@ -0,0 +1,138 @@ +import {toast} from 'react-toastify'; +import {assign, createMachine, sendTo} from 'xstate'; +import {PolyLineEntity} from '../entities/PolyLineEntity.ts'; +import { + getActiveLayerId, + getNotSelectedEntities, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setEntities, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import {selectToolStateMachine} from './select-tool'; +import type {StateEvent, ToolContext} from './tool.types'; + +export interface PeditContext extends ToolContext {} + +export enum PeditState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE', +} + +export enum PeditAction { + INIT_PEDIT_TOOL = 'INIT_PEDIT_TOOL', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE', +} + +/** + * Pedit tool state machine + * This state machine is responsible for combining entities into a polyline + * It uses the select tool state machine to select entities to combine + * When the user presses enter, the selected entities are combined into one polyline + */ +export const peditToolStateMachine = createMachine( + { + types: {} as { + context: PeditContext; + events: StateEvent; + }, + context: { + type: Tool.PEDIT, + }, + initial: PeditState.INIT, + states: { + [PeditState.INIT]: { + description: 'Initializing the pedit tool', + always: { + actions: PeditAction.INIT_PEDIT_TOOL, + target: PeditState.CHECK_SELECTION, + }, + }, + [PeditState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: PeditState.CONVERT_SELECTION_TO_POLYLINE, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: PeditState.WAITING_FOR_SELECTION, + }, + ], + }, + [PeditState.WAITING_FOR_SELECTION]: { + description: 'Select the entities that you want to combine into a polyline', + meta: { + instructions: 'Select what you want to combine into a polyline, then ENTER', + }, + invoke: { + id: 'selectToolInsideThePeditTool', + src: selectToolStateMachine, + onDone: { + target: PeditState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideThePeditTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [PeditAction.DESELECT_ENTITIES, PeditAction.INIT_PEDIT_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideThePeditTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideThePeditTool', ({ event }) => { + return event; + }), + }, + }, + }, + [PeditState.CONVERT_SELECTION_TO_POLYLINE]: { + always: { + actions: PeditAction.CONVERT_SELECTION_TO_POLYLINE, + target: PeditState.INIT, + }, + }, + }, + }, + { + actions: { + [PeditAction.INIT_PEDIT_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return {}; + }), + [PeditAction.CONVERT_SELECTION_TO_POLYLINE]: assign(() => { + const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities()); + const newEntities = [...getNotSelectedEntities(), newPolyLine]; + setEntities(newEntities, true); + setSelectedEntityIds([]); + toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`); + return {}; + }), + ...selectToolStateMachine.implementations.actions, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts new file mode 100644 index 00000000..aa375f5d --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts @@ -0,0 +1,152 @@ +import type { Point } from '@flatten-js/core'; +import { RectangleEntity } from '../entities/RectangleEntity'; +import { + addEntities, + getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { Tool } from '../tools'; +import { assign, createMachine } from 'xstate'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; + +export interface RectangleContext extends ToolContext { + startPoint: Point | null; +} + +export enum RectangleState { + INIT = 'INIT', + WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', + WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', +} + +export enum RectangleAction { + INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL', + RECORD_START_POINT = 'RECORD_START_POINT', + DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE', + DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE', +} + +export const rectangleToolStateMachine = createMachine( + { + types: {} as { + context: RectangleContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.RECTANGLE, + }, + initial: RectangleState.INIT, + states: { + [RectangleState.INIT]: { + description: 'Initializing the rectangle tool', + always: { + actions: RectangleAction.INIT_RECTANGLE_TOOL, + target: RectangleState.WAITING_FOR_START_POINT, + }, + }, + [RectangleState.WAITING_FOR_START_POINT]: { + description: 'Select the start point of the rectangle', + meta: { + instructions: 'Select the start point of the rectangle', + }, + on: { + MOUSE_CLICK: { + actions: RectangleAction.RECORD_START_POINT, + target: RectangleState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: RectangleAction.RECORD_START_POINT, + target: RectangleState.WAITING_FOR_END_POINT, + }, + }, + }, + [RectangleState.WAITING_FOR_END_POINT]: { + description: 'Select the end point of the rectangle', + meta: { + instructions: 'Select the end point of the rectangle', + }, + on: { + DRAW: { + actions: RectangleAction.DRAW_TEMP_RECTANGLE, + }, + MOUSE_CLICK: { + actions: RectangleAction.DRAW_FINAL_RECTANGLE, + target: RectangleState.INIT, + }, + NUMBER_INPUT: { + actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0 + target: RectangleState.INIT, + }, + ABSOLUTE_POINT_INPUT: { + actions: RectangleAction.DRAW_FINAL_RECTANGLE, + target: RectangleState.INIT, + }, + RELATIVE_POINT_INPUT: { + actions: RectangleAction.DRAW_FINAL_RECTANGLE, + target: RectangleState.INIT, + }, + ESC: { + target: RectangleState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [RectangleAction.INIT_RECTANGLE_TOOL]: () => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + }, + [RectangleAction.RECORD_START_POINT]: assign(({ event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + startPoint, + }; + }), + [RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error('[RECTANGLE]: calling draw without start point being set'); + } + const activeRectangle = new RectangleEntity( + getActiveLayerId(), + context.startPoint as Point, + (event as DrawEvent).drawController.getWorldMouseLocation() + ); + activeRectangle.lineColor = getActiveLineColor(); + activeRectangle.lineWidth = getActiveLineWidth(); + activeRectangle.lineDash = getActiveLineDash(); + setGhostHelperEntities([activeRectangle]); + }, + [RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => { + if (!context.startPoint) { + throw Error( + 'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool' + ); + } + const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + + const activeRectangle = new RectangleEntity( + getActiveLayerId(), + context.startPoint as Point, + endPoint + ); + activeRectangle.lineColor = getActiveLineColor(); + activeRectangle.lineWidth = getActiveLineWidth(); + activeRectangle.lineDash = getActiveLineDash(); + addEntities([activeRectangle], true); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts new file mode 100644 index 00000000..8687f39b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts @@ -0,0 +1,22 @@ +import {Line, type Point} from '@flatten-js/core'; +import type {Entity} from '../entities/Entity'; + +/** + * Rotate entities round a base point by a certain angle + * @param entities + * @param rotateOrigin + * @param startAnglePoint + * @param endAnglePoint + */ +export function rotateEntities( + entities: Entity[], + rotateOrigin: Point, + startAnglePoint: Point, + endAnglePoint: Point +) { + const rotationAngle = + new Line(rotateOrigin, endAnglePoint).slope - new Line(rotateOrigin, startAnglePoint).slope; + for (const entity of entities) { + entity.rotate(rotateOrigin, rotationAngle); + } +} diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts new file mode 100644 index 00000000..b89db405 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -0,0 +1,325 @@ +import type {Point} from '@flatten-js/core'; +import { + addEntities, + deleteEntities, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; +import {assign, createMachine, sendTo} from 'xstate'; +import {selectToolStateMachine} from './select-tool'; +import type {Entity} from '../entities/Entity'; +import {compact} from 'es-toolkit'; +import {rotateEntities} from './rotate-tool.helpers'; + +export interface RotateContext extends ToolContext { + rotationOrigin: Point | null; + angleStartPoint: Point | null; + originalSelectedEntities: Entity[]; +} + +export enum RotateState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN', + WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT', + WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT', +} + +export enum RotateAction { + INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN', + RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT', + COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE', + DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES', + ROTATE_SELECTION = 'ROTATE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', +} + +/** + * Rotate tool state machine + * This state machine is responsible for rotating entities around a point (rotation origin) by a certain angle + * It uses the select tool state machine to select entities to rotate + * When the user presses enter, the selected entities are marked for rotating + * The user can then select a rotation origin point + * The user can then select the start point of the rotation angle + * When the user moves their mouse, the selected entities will be updated to rotate according to the angle: start angle point => rotation origin => mouse location + * When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin + */ +export const rotateToolStateMachine = createMachine( + { + types: {} as { + context: RotateContext; + events: StateEvent; + }, + context: { + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + type: Tool.ROTATE, + }, + initial: RotateState.INIT, + states: { + [RotateState.INIT]: { + description: 'Initializing the rotate tool', + always: { + actions: RotateAction.INIT_ROTATE_TOOL, + target: RotateState.CHECK_SELECTION, + }, + }, + [RotateState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: RotateState.WAITING_FOR_ROTATION_ORIGIN, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: RotateState.WAITING_FOR_SELECTION, + }, + ], + }, + [RotateState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to rotate', + meta: { + instructions: 'Select what you want to rotate, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheRotateTool', + src: selectToolStateMachine, + onDone: { + actions: assign(({ context }) => { + return { + ...context, + }; + }), + target: RotateState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [ + RotateAction.DESELECT_ENTITIES, + RotateAction.INIT_ROTATE_TOOL, + ], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + }, + }, + [RotateState.WAITING_FOR_ROTATION_ORIGIN]: { + description: 'Select the origin of the rotate operation', + meta: { + instructions: 'Select the origin of the rotate operation', + }, + always: { + actions: RotateAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [RotateAction.RECORD_ROTATION_ORIGIN], + target: RotateState.WAITING_FOR_ANGLE_START_POINT, + }, + ESC: { + actions: RotateAction.DESELECT_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + [RotateState.WAITING_FOR_ANGLE_START_POINT]: { + description: 'Select the end of the base rotate line', + meta: { + instructions: 'Select the end of the base rotate line', + }, + on: { + MOUSE_CLICK: { + actions: [ + RotateAction.RECORD_ROTATION_ANGLE_START_POINT, + RotateAction.COPY_SELECTION_BEFORE_ROTATE, + ], + target: RotateState.WAITING_FOR_ANGLE_END_POINT, + }, + ESC: { + actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + [RotateState.WAITING_FOR_ANGLE_END_POINT]: { + description: 'Select the end of the rotate line', + meta: { + instructions: 'Select the end of the rotate line', + }, + on: { + DRAW: { + actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [ + RotateAction.ROTATE_SELECTION, + RotateAction.DESELECT_ENTITIES, + ], + target: RotateState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [RotateAction.INIT_ROTATE_TOOL]: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + [RotateAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [RotateAction.RECORD_ROTATION_ORIGIN]: assign( + ({ context, event }): RotateContext => { + setAngleGuideOriginPoint( + (event as MouseClickEvent).worldMouseLocation, + ); + return { + ...context, + rotationOrigin: (event as MouseClickEvent).worldMouseLocation, + }; + }, + ), + [RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign( + ({ context, event }): RotateContext => { + return { + ...context, + angleStartPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }, + ), + [RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign( + ({ context }): RotateContext => { + const selectedEntities = getSelectedEntities(); + + // Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); + + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being rotated and the original entities also are used for snap points / angle guides + + setSelectedEntityIds([]); + return { + ...context, + // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action + originalSelectedEntities: compact( + selectedEntities.map(entity => entity.clone()), + ), + }; + }, + ), + [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { + if (!context.rotationOrigin || !context.angleStartPoint) { + throw new Error( + '[ROTATE] Calling draw temp rotate entities without a base start point or base end point', + ); + } + + const angleEndpoint = ( + event as DrawEvent + ).drawController.getWorldMouseLocation(); + + // Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating + const rotatedEntities = compact( + context.originalSelectedEntities.map(entity => entity.clone()), + ); + rotateEntities( + rotatedEntities, + context.rotationOrigin, + context.angleStartPoint, + angleEndpoint, + ); + + setGhostHelperEntities(rotatedEntities); + }, + [RotateAction.ROTATE_SELECTION]: ({ context, event }) => { + if (!context.rotationOrigin || !context.angleStartPoint) { + throw new Error( + '[ROTATE] Calling rotate selection without some rotate vector endpoints', + ); + } + const angleEndpoint = (event as MouseClickEvent).worldMouseLocation; + + // Rotate the entities one final time + const rotatedEntities = compact( + context.originalSelectedEntities.map(entity => entity.clone()), + ); + rotateEntities( + rotatedEntities, + context.rotationOrigin, + context.angleStartPoint, + angleEndpoint, + ); + + // Switch the rotated entities back from the ghost helper entities to the real entities + addEntities(rotatedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + ...context, + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + }; + }), + [RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign( + ({ context }): RotateContext => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + ...context, + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + }; + }, + ), + ...selectToolStateMachine.implementations.actions, + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts new file mode 100644 index 00000000..fda881b3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts @@ -0,0 +1,24 @@ +import type {Point} from '@flatten-js/core'; +import type {Entity} from '../entities/Entity'; +import {pointDistance} from '../helpers/distance-between-points'; + +/** + * Scale entities by base vector to destination scale vector + * @param entities + * @param baseVectorStartPoint + * @param baseVectorEndPoint + * @param scaleVectorEndPoint + */ +export function scaleEntities( + entities: Entity[], + baseVectorStartPoint: Point, + baseVectorEndPoint: Point, + scaleVectorEndPoint: Point +) { + const scaleFactor = + pointDistance(baseVectorStartPoint, scaleVectorEndPoint) / + pointDistance(baseVectorStartPoint, baseVectorEndPoint); + for (const entity of entities) { + entity.scale(baseVectorStartPoint, scaleFactor); + } +} diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts new file mode 100644 index 00000000..3d81e395 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -0,0 +1,326 @@ +import type {Point} from '@flatten-js/core'; +import { + addEntities, + deleteEntities, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import {Tool} from '../tools'; +import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; +import {assign, createMachine, sendTo} from 'xstate'; +import {selectToolStateMachine} from './select-tool'; +import type {Entity} from '../entities/Entity'; +import {compact} from 'es-toolkit'; +import {scaleEntities} from './scale-tool.helpers'; + +export interface ScaleContext extends ToolContext { + baseVectorStartPoint: Point | null; + baseVectorEndPoint: Point | null; + scaleVectorEndPoint: Point | null; + originalSelectedEntities: Entity[]; +} + +export enum ScaleState { + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT', + WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT', + WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT', +} + +export enum ScaleAction { + INIT_SCALE_TOOL = 'INIT_SCALE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT', + RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT', + COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', + DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', + SCALE_SELECTION = 'SCALE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', +} + +/** + * Scale tool state machine + * This state machine is responsible for scaling entities from a base vector to a certain scale vector + * It uses the select tool state machine to select entities to scale + * When the user presses enter, the selected entities are marked for scaling + * The user can then select a base scale vector start point + * The user can then select the base scale vector end point + * When the user moves their mouse, the selected entities will be updated to scale according to the vector: base vector => scale vector from start point to mouse location + * When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector + */ +export const scaleToolStateMachine = createMachine( + { + types: {} as { + context: ScaleContext; + events: StateEvent; + }, + context: { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + type: Tool.SCALE, + }, + initial: ScaleState.INIT, + states: { + [ScaleState.INIT]: { + description: 'Initializing the scale tool', + always: { + actions: ScaleAction.INIT_SCALE_TOOL, + target: ScaleState.CHECK_SELECTION, + }, + }, + [ScaleState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: ScaleState.WAITING_FOR_SELECTION, + }, + ], + }, + [ScaleState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to scale', + meta: { + instructions: 'Select what you want to scale, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheScaleTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + }; + }), + target: ScaleState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [ + ScaleAction.DESELECT_ENTITIES, + ScaleAction.INIT_SCALE_TOOL, + ], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { + description: 'Select the origin of the scale operation', + meta: { + instructions: 'Select the origin of the scale operation', + }, + always: { + actions: ScaleAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT], + target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.DESELECT_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { + description: 'Select the end of the base scale line', + meta: { + instructions: 'Select the end of the base scale line', + }, + on: { + MOUSE_CLICK: { + actions: [ + ScaleAction.RECORD_BASE_VECTOR_END_POINT, + ScaleAction.COPY_SELECTION_BEFORE_SCALE, + ], + target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { + description: 'Select the end of the scale line', + meta: { + instructions: 'Select the end of the scale line', + }, + on: { + DRAW: { + actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [ + ScaleAction.SCALE_SELECTION, + ScaleAction.DESELECT_ENTITIES, + ], + target: ScaleState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [ScaleAction.INIT_SCALE_TOOL]: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + [ScaleAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign( + ({ context, event }) => { + setAngleGuideOriginPoint( + (event as MouseClickEvent).worldMouseLocation, + ); + return { + ...context, + baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }, + ), + [ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign( + ({ context, event }) => { + return { + ...context, + baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }, + ), + [ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); + + // Scale the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); + + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being scaled and the original entities also are used for snap points / angle guides + + setSelectedEntityIds([]); + return { + ...context, + // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action + originalSelectedEntities: compact( + selectedEntities.map(entity => entity.clone()), + ), + }; + }), + [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error( + '[SCALE] Calling draw temp scale entities without a base start point or base end point', + ); + } + + const scaleVectorEndPointTemp = ( + event as DrawEvent + ).drawController.getWorldMouseLocation(); + + // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling + const scaledEntities = compact( + context.originalSelectedEntities.map(entity => entity.clone()), + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPointTemp, + ); + + setGhostHelperEntities(scaledEntities); + }, + [ScaleAction.SCALE_SELECTION]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error( + '[SCALE] Calling scale selection without some scale vector endpoints', + ); + } + const scaleVectorEndPoint = (event as MouseClickEvent) + .worldMouseLocation; + + // Scale the entities one final time + const scaledEntities = compact( + context.originalSelectedEntities.map(entity => entity.clone()), + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPoint, + ); + + // Switch the scaled entities back from the ghost helper entities to the real entities + addEntities(scaledEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [ScaleAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack + setGhostHelperEntities([]); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts new file mode 100644 index 00000000..cf076847 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts @@ -0,0 +1,151 @@ +import type {Box, Point, Polygon} from '@flatten-js/core'; +import {compact} from 'es-toolkit'; +import { + EPSILON, + HIGHLIGHT_ENTITY_DISTANCE, + SELECTION_RECTANGLE_COLOR_CONTAINS, + SELECTION_RECTANGLE_COLOR_INTERSECTION, + SELECTION_RECTANGLE_STYLE, + SELECTION_RECTANGLE_WIDTH, +} from '../App.consts'; +import {RectangleEntity} from '../entities/RectangleEntity'; +import {toast} from 'react-toastify'; +import {findClosestEntity} from '../helpers/find-closest-entity'; +import { + getActiveLayerId, + getEntities, + getLayers, + getSelectedEntityIds, + isEntitySelected, + setGhostHelperEntities, + setSelectedEntityIds, +} from '../state'; +import type {SelectContext} from './select-tool'; +import type {MouseClickEvent} from './tool.types'; + +export function handleFirstSelectionPoint( + context: SelectContext, + event: MouseClickEvent +): SelectContext { + const closestEntityInfo = findClosestEntity(event.worldMouseLocation, getEntities()); + + // Mouse is close to entity and is not dragging a rectangle + if (closestEntityInfo && closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) { + // Select the entity close to the mouse + const closestEntity = closestEntityInfo.entity; + if (!event.holdingCtrl && !event.holdingShift) { + setSelectedEntityIds([closestEntity.id]); + } else if (event.holdingCtrl) { + // ctrl => toggle selection + if (isEntitySelected(closestEntity)) { + // Remove the entity from the selection + setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id)); + } else { + // Add the entity to the selection + setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]); + } + } else { + // shift => add to selection + setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]); + } + return { + ...context, + startPoint: null, + }; + } + + // No elements are close to the mouse and no selection dragging is in progress + // Start a new selection rectangle drag + return { + ...context, + startPoint: event.worldMouseLocation, + }; +} + +export function selectEntitiesInsideRectangle( + startPoint: Point, + endPoint: Point, + holdingCtrl: boolean + // holdingShift: boolean, // TODO implement add to selection using shift +): void { + // Finish the selection + const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint); + const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint); + const newSelectedEntityIds: string[] = compact( + getEntities().map((entity): string | null => { + const layer = getLayers().find((layer) => layer.id === entity.layerId); + if (!layer) { + toast.error(`Failed to find layer for entity: ${entity?.id}`); + console.error('Failed to find layer for entity', entity); + return null; + } + if (intersectionSelection) { + // Select all entities that are inside the selection rectangle or intersect with the selection rectangle + if ( + entity.intersectsWithBox(activeSelectionRectangle.getBoundingBox() as Box) || + entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box) + ) { + if (holdingCtrl) { + if (isEntitySelected(entity)) { + return null; + } + if (!layer.isLocked) { + return entity.id; + } + } + if (!layer.isLocked) { + return entity.id; + } + } + } else { + // Select only entities that are completely inside the selection rectangle + if (entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)) { + if (holdingCtrl) { + if (isEntitySelected(entity)) { + return null; + } + if (!layer.isLocked) { + return entity.id; + } + } + if (!layer.isLocked) { + return entity.id; + } + } + } + return null; + }) + ); + setSelectedEntityIds(newSelectedEntityIds); +} + +export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) { + const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint); + const isIntersectionSelection: boolean = getIsIntersectionSelection( + activeSelectionRectangle, + startPoint + ); + activeSelectionRectangle.lineColor = isIntersectionSelection + ? SELECTION_RECTANGLE_COLOR_INTERSECTION + : SELECTION_RECTANGLE_COLOR_CONTAINS; + activeSelectionRectangle.lineWidth = SELECTION_RECTANGLE_WIDTH; + activeSelectionRectangle.lineDash = SELECTION_RECTANGLE_STYLE; + setGhostHelperEntities([activeSelectionRectangle]); +} + +/** + * Selections to the left of the start point are intersection selections (green), and everything intersecting with the selection rectangle will be selected + * Selections to the right of the start point are normal selections (blue), and only the entities fully inside the selection rectangle will be selected + */ +export function getIsIntersectionSelection( + rectangleEntity: RectangleEntity, + startPoint: Point +): boolean { + if (!rectangleEntity.getShape() || !startPoint) { + return false; + } + const selectionRectangleMinX = Math.min( + ...(rectangleEntity.getShape() as Polygon).vertices.map((v) => v.x) + ); + return Math.abs(startPoint.x - selectionRectangleMinX) > EPSILON; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts new file mode 100644 index 00000000..5b99270f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -0,0 +1,180 @@ +import type {Point} from '@flatten-js/core'; +import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state'; +import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; +import {Tool} from '../tools'; +import {assign, createMachine} from 'xstate'; +import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers'; + +export interface SelectContext extends ToolContext { + startPoint: Point | null; +} + +export enum SelectState { + INIT = 'INIT', + WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT', + SELECTION_COMPLETED = 'SELECTION_COMPLETED', +} + +export enum SelectAction { + INIT_SELECT_TOOL = 'INIT_SELECT_TOOL', + HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT', + SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE', + DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE', + DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES', +} + +export const selectToolStateMachine = createMachine( + { + types: {} as { + context: SelectContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.SELECT, + }, + initial: SelectState.INIT, + states: { + [SelectState.INIT]: { + description: 'Initializing the select tool', + always: { + actions: SelectAction.INIT_SELECT_TOOL, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + }, + [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { + description: + 'Select a line or select the first point of a selection rectangle', + meta: { + instructions: 'Select a line or start drawing a selection rectangle', + }, + on: { + MOUSE_CLICK: { + actions: SelectAction.HANDLE_FIRST_SELECT_POINT, + target: SelectState.CHECK_SELECTION, + }, + ESC: { + actions: SelectAction.INIT_SELECT_TOOL, + }, + ENTER: { + target: SelectState.SELECTION_COMPLETED, + }, + DELETE: { + actions: SelectAction.DELETE_SELECTED_ENTITIES, + target: SelectState.INIT, + }, + }, + }, + [SelectState.CHECK_SELECTION]: { + description: + 'Checking to select one line or start drawing a selection rectangle', + meta: { + instructions: + 'Select one line or start drawing a selection rectangle', + }, + always: [ + { + // User started drawing a selection rectangle + guard: ({ context }: { context: SelectContext }) => + !!context.startPoint, + target: SelectState.WAITING_FOR_SECOND_SELECT_POINT, + }, + { + // User clicked on an entity + guard: ({ context }: { context: SelectContext }) => + !context.startPoint, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ], + }, + [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { + description: 'Select the second point of a selection rectangle', + meta: { + instructions: 'Select the second point of a selection rectangle', + }, + on: { + DRAW: { + actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE, + }, + MOUSE_CLICK: { + actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ESC: { + target: SelectState.INIT, + }, + }, + }, + [SelectState.SELECTION_COMPLETED]: { + description: 'Selection completed', + type: 'final', + }, + }, + }, + { + actions: { + INIT_SELECT_TOOL: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + HANDLE_FIRST_SELECT_POINT: assign( + ({ context, event }: { context: SelectContext; event: StateEvent }) => { + return handleFirstSelectionPoint(context, event as MouseClickEvent); + }, + ), + DRAW_TEMP_SELECTION_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // assert + throw new Error( + '[SELECT] Calling drawTempSelectionRectangle without startPoint set', + ); + } + drawTempSelectionRectangle( + context.startPoint as Point, + (event as DrawEvent).drawController.getWorldMouseLocation(), + ); + }, + SELECT_ENTITIES_INSIDE_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // + throw new Error( + '[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point', + ); + } + selectEntitiesInsideRectangle( + context.startPoint, + (event as MouseClickEvent).worldMouseLocation, + (event as MouseClickEvent).holdingCtrl, + // (event as MouseClickEvent).holdingShift, + ); + setGhostHelperEntities([]); + }, + DELETE_SELECTED_ENTITIES: () => { + setEntities(getNotSelectedEntities(), true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + }, + RESET_SELECTION: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + }; + }), + }, + }, +); diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts b/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts new file mode 100644 index 00000000..ed85f72a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts @@ -0,0 +1,75 @@ +import type {StateMachine} from 'xstate'; /* eslint-disable @typescript-eslint/no-explicit-any */ +import {Tool} from '../tools'; +import {alignBottomToolStateMachine} from './align-bottom-tool.ts'; +import {alignCenterHorizontalToolStateMachine} from './align-center-horizontal-tool.ts'; +import {alignLeftToolStateMachine} from './align-left-tool.ts'; +import {alignCenterVerticalToolStateMachine} from './align-middle-vertical-tool.ts'; +import {alignRightToolStateMachine} from './align-right-tool.ts'; +import {alignTopToolStateMachine} from './align-top-tool.ts'; +import {arrayToolStateMachine} from './array-tool.ts'; +import {circleToolStateMachine} from './circle-tool'; +import {copyToolStateMachine} from './copy-tool.ts'; +import {eraserToolStateMachine} from './eraser-tool'; +import {imageImportToolStateMachine} from './image-import-tool'; +import {lineToolStateMachine} from './line-tool'; +import {measurementToolStateMachine} from './measurement-tool'; +import {moveToolStateMachine} from './move-tool'; +import {rectangleToolStateMachine} from './rectangle-tool'; +import {rotateToolStateMachine} from './rotate-tool'; +import {scaleToolStateMachine} from './scale-tool'; +import {selectToolStateMachine} from './select-tool'; +import {peditToolStateMachine} from "./pedit-tool.ts"; + +export const TOOL_STATE_MACHINES: Record< + Partial, + StateMachine< + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any, + // biome-ignore lint/suspicious/noExplicitAny: + any + > +> = { + [Tool.LINE]: lineToolStateMachine, + [Tool.RECTANGLE]: rectangleToolStateMachine, + [Tool.CIRCLE]: circleToolStateMachine, + [Tool.SELECT]: selectToolStateMachine, + [Tool.ERASER]: eraserToolStateMachine, + [Tool.MOVE]: moveToolStateMachine, + [Tool.COPY]: copyToolStateMachine, + [Tool.SCALE]: scaleToolStateMachine, + [Tool.ROTATE]: rotateToolStateMachine, + [Tool.IMAGE_IMPORT]: imageImportToolStateMachine, + [Tool.MEASUREMENT]: measurementToolStateMachine, + [Tool.ALIGN_LEFT]: alignLeftToolStateMachine, + [Tool.ALIGN_CENTER_HORIZONTAL]: alignCenterHorizontalToolStateMachine, + [Tool.ALIGN_RIGHT]: alignRightToolStateMachine, + [Tool.ALIGN_TOP]: alignTopToolStateMachine, + [Tool.ALIGN_CENTER_VERTICAL]: alignCenterVerticalToolStateMachine, + [Tool.ALIGN_BOTTOM]: alignBottomToolStateMachine, + [Tool.ARRAY]: arrayToolStateMachine, + [Tool.PEDIT]: peditToolStateMachine, +}; diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts new file mode 100644 index 00000000..182629a8 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts @@ -0,0 +1,117 @@ +import type { Point } from '@flatten-js/core'; +import type { Tool } from '../tools'; +import type { EventObject } from 'xstate'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; + +export enum ActionType { + Click = 'Click', + TypedCommand = 'TypedCommand', + ActivateTool = 'ActivateTool', +} + +export interface ClickEvent { + worldMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; +} + +export interface TypedCommandEvent { + text: string; +} + +export interface ToolHandler { + handleToolActivate(): void; + handleToolClick( + worldMouseLocation: Point, + holdingCtrl: boolean, + holdingShift: boolean, + ): void; + handleToolTypedCommand(command: string): void; +} + +export enum ActorEvent { + MOUSE_CLICK = 'MOUSE_CLICK', + ESC = 'ESC', + ENTER = 'ENTER', + DELETE = 'DELETE', + DRAW = 'DRAW', + FILE_SELECTED = 'FILE_SELECTED', + NUMBER_INPUT = 'NUMBER_INPUT', + TEXT_INPUT = 'TEXT_INPUT', + ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT', + RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT', +} + +export interface MouseClickEvent extends EventObject { + type: ActorEvent.MOUSE_CLICK; + worldMouseLocation: Point; + screenMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; +} + +export interface KeyboardEscEvent extends EventObject { + type: ActorEvent.ESC; +} + +export interface KeyboardEnterEvent extends EventObject { + type: ActorEvent.ENTER; +} + +export interface KeyboardDeleteEvent extends EventObject { + type: ActorEvent.DELETE; +} + +export interface NumberInputEvent extends EventObject { + type: ActorEvent.NUMBER_INPUT; + value: number; + worldMouseLocation: Point; +} + +export interface TextInputEvent extends EventObject { + type: ActorEvent.TEXT_INPUT; + value: string; +} + +export interface AbsolutePointInputEvent extends EventObject { + type: ActorEvent.ABSOLUTE_POINT_INPUT; + value: Point; +} + +export interface RelativePointInputEvent extends EventObject { + type: ActorEvent.RELATIVE_POINT_INPUT; + value: Point; +} + +export interface FileSelectedEvent extends EventObject { + type: ActorEvent.FILE_SELECTED; + image: HTMLImageElement; +} + +export interface DrawEvent extends EventObject { + type: ActorEvent.DRAW; + drawController: ScreenCanvasDrawController; +} + +export type PointInputEvent = + | DrawEvent + | MouseClickEvent + | NumberInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent; + +export type StateEvent = + | MouseClickEvent + | KeyboardEscEvent + | KeyboardEnterEvent + | KeyboardDeleteEvent + | NumberInputEvent + | TextInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent + | FileSelectedEvent + | DrawEvent; + +export interface ToolContext { + type: Tool; +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/click.ts b/B07_DesignDetail/openwebcad/test/helpers/click.ts new file mode 100644 index 00000000..38b980c7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/click.ts @@ -0,0 +1,25 @@ +import {TOOLBAR_WIDTH} from '../../src/App.consts'; +import {MouseButton} from '../../src/App.types'; +import type {InputController} from '../../src/inputController/input-controller'; + +/** + * Trigger a click event on the canvas + * @param inputController + * @param x x-coordinate relative to the left of the draw area excluding the toolbar + * @param y y-coordinate relative to the top of the draw area + * @param mouseButton + */ +export function click( + inputController: InputController, + x: number, + y: number, + mouseButton: MouseButton = MouseButton.Left +) { + inputController.handleMouseUp({ + button: mouseButton, + clientX: TOOLBAR_WIDTH + x, // Coordinates are relative to the top left of the draw area excluding the toolbar + clientY: y, + preventDefault: () => {}, + stopPropagation: () => {}, + } as MouseEvent); +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts new file mode 100644 index 00000000..b16704d5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts @@ -0,0 +1,25 @@ +import {Point} from '@flatten-js/core'; +import {Actor} from 'xstate'; +import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenCanvas.drawController'; +import {InputController} from '../../src/inputController/input-controller'; +import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state'; +import {Tool} from '../../src/tools'; +import {TOOL_STATE_MACHINES} from '../../src/tools/tool.consts'; +import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController'; +import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts'; + +export function initApplication(): InputController { + const inputController = new InputController(); + setInputController(inputController); + setEntities([], true); // Creates the first undo entry + + const canvasSize = new Point(CANVAS_WIDTH, CANVAS_HEIGHT); + + const lineToolActor = new Actor(TOOL_STATE_MACHINES[Tool.LINE]); + lineToolActor.start(); + setActiveToolActor(lineToolActor); + setScreenCanvasDrawController( + new ScreenCanvasDrawControllerMock(null, canvasSize) as unknown as ScreenCanvasDrawController + ); + return inputController; +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts new file mode 100644 index 00000000..4d929143 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts @@ -0,0 +1,76 @@ +import type {InputController} from '../../src/inputController/input-controller'; +import {Tool} from '../../src/tools'; +import {click} from './click'; +import type {Recording, Step} from './replay-recording.types'; +import {setActiveTool} from './set-active-tool'; + +const DATA_ID_TO_TOOL_NAME: Record = { + 'select-button': Tool.SELECT, + 'line-button': Tool.LINE, + 'rectangle-button': Tool.RECTANGLE, + 'circle-button': Tool.CIRCLE, + 'move-button': Tool.MOVE, + 'scale-button': Tool.SCALE, + 'rotate-button': Tool.ROTATE, + 'measurement-button': Tool.MEASUREMENT, + 'undo-button': null, + 'redo-button': null, + 'delete-segment-button': Tool.ERASER, + 'line-color-button': null, + 'line-width-button': null, + 'angle-guide-button': null, + 'angle-guide-5-button': null, + 'angle-guide-15-button': null, + 'angle-guide-30-button': null, + 'angle-guide-45-button': null, + 'angle-guide-90-button': null, + 'zoom-level-button': null, + 'import-image-button': null, + 'json-open-button': null, + 'json-save-button': null, + 'svg-export-button': null, + 'png-export-button': null, + 'pdf-export-button': null, + 'github-link-button': null, +}; + +function handleClick(inputController: InputController, step: Step) { + const firstSelector = step.selectors[0][0]; + if (firstSelector.startsWith('[data-id')) { + // clicked a button or the canvas + const dataId = step.selectors[0][0].split("'")[1]; + if (dataId === 'canvas') { + click(inputController, step.offsetX, step.offsetY); + } else if (DATA_ID_TO_TOOL_NAME[dataId]) { + setActiveTool(DATA_ID_TO_TOOL_NAME[dataId]); + } else { + console.error('Failed to replay step: ', step); + } + } else { + console.error('Failed to replay step without data id: ', step); + } +} + +function handleKeyUp(inputController: InputController, step: Step) { + inputController.handleKeyStroke({ + key: step.key, + preventDefault: () => {}, + stopPropagation: () => {}, + ctrlKey: false, + shiftKey: false, + } as KeyboardEvent); +} + +export function replayRecording(inputController: InputController, recording: Recording) { + for (const step of recording.steps) { + switch (step.type) { + case 'click': + handleClick(inputController, step); + break; + + case 'keyUp': + handleKeyUp(inputController, step); + break; + } + } +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts new file mode 100644 index 00000000..d0c90b97 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts @@ -0,0 +1,28 @@ +export interface Recording { + title: string; + selectorAttribute: string; + steps: Step[]; +} + +export interface Step { + type: string; + width?: number; + height?: number; + deviceScaleFactor?: number; + isMobile?: boolean; + hasTouch?: boolean; + isLandscape?: boolean; + url?: string; + assertedEvents?: AssertedEvent[]; + target?: string; + selectors?: string[][]; + offsetY?: number; + offsetX?: number; + key?: string; +} + +export interface AssertedEvent { + type: string; + url: string; + title: string; +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts new file mode 100644 index 00000000..38cbbfb3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts @@ -0,0 +1,11 @@ +import type { Tool } from '../../src/tools'; +import { getActiveToolActor, setActiveToolActor } from '../../src/state'; +import { Actor } from 'xstate'; +import { TOOL_STATE_MACHINES } from '../../src/tools/tool.consts'; + +export function setActiveTool(toolName: Tool) { + getActiveToolActor()?.stop(); + + const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); + setActiveToolActor(newToolActor); +} diff --git a/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts new file mode 100644 index 00000000..54701e4f --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts @@ -0,0 +1,4 @@ +import {TOOLBAR_WIDTH} from "../../src/App.consts"; + +export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH; +export const CANVAS_HEIGHT = 1080; diff --git a/B07_DesignDetail/openwebcad/test/mocks/floorplan.svg b/B07_DesignDetail/openwebcad/test/mocks/floorplan.svg new file mode 100644 index 00000000..7f7bd102 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/floorplan.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/B07_DesignDetail/openwebcad/test/mocks/test-svg-import.svg b/B07_DesignDetail/openwebcad/test/mocks/test-svg-import.svg new file mode 100644 index 00000000..a19f3c8d --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/test-svg-import.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + \ No newline at end of file