From 33d5886123f92f0c231eb71b9b36d41d9e0f8cfa Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Thu, 27 Feb 2025 18:05:36 +1100 Subject: [PATCH] feed segments to worker --- packages/excalidraw/lasso/index.ts | 218 ++++++++++- packages/excalidraw/lasso/worker.ts | 355 ++++-------------- packages/excalidraw/renderer/renderElement.ts | 2 +- packages/excalidraw/types.ts | 2 +- 4 files changed, 280 insertions(+), 297 deletions(-) diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts index a64598891..24fc33f53 100644 --- a/packages/excalidraw/lasso/index.ts +++ b/packages/excalidraw/lasso/index.ts @@ -1,23 +1,43 @@ -import { GlobalPoint, pointFrom } from "../../math"; +import { pointsOnBezierCurves } from "points-on-curve"; +import { + type Curve, + type GlobalPoint, + type LineSegment, + type Radians, + lineSegment, + pointFrom, + pointRotateRads, +} from "../../math"; import { AnimatedTrail } from "../animated-trail"; -import { AnimationFrameHandler } from "../animation-frame-handler"; -import App from "../components/App"; +import { type AnimationFrameHandler } from "../animation-frame-handler"; +import type App from "../components/App"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; -import { +import type { + ElementsMap, ExcalidrawElement, + ExcalidrawEllipseElement, ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, NonDeleted, } from "../element/types"; import { getFrameChildren } from "../frame"; import { selectGroupsForSelectedElements } from "../groups"; -import { easeOut } from "../utils"; -import { LassoWorkerInput, LassoWorkerOutput } from "./worker"; +import { getElementShape } from "../shapes"; +import { arrayToMap, easeOut } from "../utils"; +import type { LassoWorkerInput, LassoWorkerOutput } from "./worker"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "../element/utils"; +import { getElementAbsoluteCoords } from "../element"; export class LassoTrail extends AnimatedTrail { private intersectedElements: Set = new Set(); private enclosedElements: Set = new Set(); private worker: Worker | null = null; + private elementsSegments: Map[]> | null = + null; constructor(animationFrameHandler: AnimationFrameHandler, app: App) { super(animationFrameHandler, app, { @@ -132,10 +152,20 @@ export class LassoTrail extends AnimatedTrail { .getCurrentTrail() ?.originalPoints?.map((p) => pointFrom(p[0], p[1])); + if (!this.elementsSegments) { + this.elementsSegments = new Map(); + const visibleElementsMap = arrayToMap(this.app.visibleElements); + for (const element of this.app.visibleElements) { + const segments = getElementLineSegments(element, visibleElementsMap); + this.elementsSegments.set(element.id, segments); + } + } + if (lassoPath) { const message: LassoWorkerInput = { lassoPath, elements: this.app.visibleElements, + elementsSegments: this.elementsSegments, intersectedElements: this.intersectedElements, enclosedElements: this.enclosedElements, }; @@ -149,9 +179,185 @@ export class LassoTrail extends AnimatedTrail { super.clearTrails(); this.intersectedElements.clear(); this.enclosedElements.clear(); + this.elementsSegments = null; this.app.setState({ lassoSelection: null, }); this.worker?.terminate(); } } + +/** + * Given an element, return the line segments that make up the element. + * + * Uses helpers from /math + */ +const getElementLineSegments = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): LineSegment[] => { + const shape = getElementShape(element, elementsMap); + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + const center = pointFrom(cx, cy); + + if (shape.type === "polycurve") { + const curves = shape.data; + const points = curves + .map((curve) => pointsOnBezierCurves(curve, 10)) + .flat(); + let i = 0; + const segments: LineSegment[] = []; + while (i < points.length - 1) { + segments.push( + lineSegment( + pointFrom(points[i][0], points[i][1]), + pointFrom(points[i + 1][0], points[i + 1][1]), + ), + ); + i++; + } + + return segments; + } else if (shape.type === "polyline") { + return shape.data as LineSegment[]; + } else if (isRectanguloidElement(element)) { + const [sides, corners] = deconstructRectanguloidElement(element); + const cornerSegments: LineSegment[] = corners + .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) + .flat(); + const rotatedSides = getRotatedSides(sides, center, element.angle); + return [...rotatedSides, ...cornerSegments]; + } else if (element.type === "diamond") { + const [sides, corners] = deconstructDiamondElement(element); + const cornerSegments = corners + .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) + .flat(); + const rotatedSides = getRotatedSides(sides, center, element.angle); + + return [...rotatedSides, ...cornerSegments]; + } else if (shape.type === "polygon") { + const points = shape.data as GlobalPoint[]; + const segments: LineSegment[] = []; + for (let i = 0; i < points.length - 1; i++) { + segments.push(lineSegment(points[i], points[i + 1])); + } + return segments; + } else if (shape.type === "ellipse") { + return getSegmentsOnEllipse(element as ExcalidrawEllipseElement); + } + + const [nw, ne, sw, se, , , w, e] = ( + [ + [x1, y1], + [x2, y1], + [x1, y2], + [x2, y2], + [cx, y1], + [cx, y2], + [x1, cy], + [x2, cy], + ] as GlobalPoint[] + ).map((point) => pointRotateRads(point, center, element.angle)); + + return [ + lineSegment(nw, ne), + lineSegment(sw, se), + lineSegment(nw, sw), + lineSegment(ne, se), + lineSegment(nw, e), + lineSegment(sw, e), + lineSegment(ne, w), + lineSegment(se, w), + ]; +}; + +const isRectanguloidElement = ( + element: ExcalidrawElement, +): element is ExcalidrawRectanguloidElement => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "image" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + (element.type === "text" && !element.containerId)) + ); +}; + +const getRotatedSides = ( + sides: LineSegment[], + center: GlobalPoint, + angle: Radians, +) => { + return sides.map((side) => { + return lineSegment( + pointRotateRads(side[0], center, angle), + pointRotateRads(side[1], center, angle), + ); + }); +}; + +const getSegmentsOnCurve = ( + curve: Curve, + center: GlobalPoint, + angle: Radians, +): LineSegment[] => { + const points = pointsOnBezierCurves(curve, 10); + let i = 0; + const segments: LineSegment[] = []; + while (i < points.length - 1) { + segments.push( + lineSegment( + pointRotateRads( + pointFrom(points[i][0], points[i][1]), + center, + angle, + ), + pointRotateRads( + pointFrom(points[i + 1][0], points[i + 1][1]), + center, + angle, + ), + ), + ); + i++; + } + + return segments; +}; + +const getSegmentsOnEllipse = ( + ellipse: ExcalidrawEllipseElement, +): LineSegment[] => { + const center = pointFrom( + ellipse.x + ellipse.width / 2, + ellipse.y + ellipse.height / 2, + ); + + const a = ellipse.width / 2; + const b = ellipse.height / 2; + + const segments: LineSegment[] = []; + const points: GlobalPoint[] = []; + const n = 90; + const deltaT = (Math.PI * 2) / n; + + for (let i = 0; i < n; i++) { + const t = i * deltaT; + const x = center[0] + a * Math.cos(t); + const y = center[1] + b * Math.sin(t); + points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle)); + } + + for (let i = 0; i < points.length - 1; i++) { + segments.push(lineSegment(points[i], points[i + 1])); + } + + segments.push(lineSegment(points[points.length - 1], points[0])); + return segments; +}; diff --git a/packages/excalidraw/lasso/worker.ts b/packages/excalidraw/lasso/worker.ts index 0b974e832..d9a9f7196 100644 --- a/packages/excalidraw/lasso/worker.ts +++ b/packages/excalidraw/lasso/worker.ts @@ -1,18 +1,8 @@ -import { - GlobalPoint, - LineSegment, - LocalPoint, - Radians, -} from "../../math/types"; -import { pointFrom, pointRotateRads } from "../../math/point"; -import { polygonFromPoints } from "../../math/polygon"; -import { ElementsMap, ExcalidrawElement } from "../element/types"; -import { pointsOnBezierCurves, simplify } from "points-on-curve"; -import { lineSegment } from "../../math/segment"; -import throttle from "lodash.throttle"; -import { RoughGenerator } from "roughjs/bin/generator"; -import { Point } from "roughjs/bin/geometry"; -import { Drawable, Op } from "roughjs/bin/core"; +import type { GlobalPoint, LineSegment } from "../../math/types"; +import { polygonFromPoints, polygonIncludesPoint } from "../../math/polygon"; +import type { ExcalidrawElement } from "../element/types"; +import { lineSegment, lineSegmentIntersectionPoints } from "../../math/segment"; +import { simplify } from "points-on-curve"; // variables to track processing state and latest input data // for "backpressure" purposes @@ -38,7 +28,9 @@ self.onmessage = (event: MessageEvent) => { // function to process the latest data const processInputData = () => { // If no data to process, return - if (!latestInputData) return; + if (!latestInputData) { + return; + } // capture the current data to process and reset latestData const dataToProcess = latestInputData; @@ -79,9 +71,12 @@ const processInputData = () => { } }; +type ElementsSegments = Map[]>; + export type LassoWorkerInput = { lassoPath: GlobalPoint[]; elements: readonly ExcalidrawElement[]; + elementsSegments: ElementsSegments; intersectedElements: Set; enclosedElements: Set; }; @@ -90,304 +85,86 @@ export type LassoWorkerOutput = { selectedElementIds: string[]; }; -export const updateSelection = throttle( - (input: LassoWorkerInput): LassoWorkerOutput => { - const { lassoPath, elements, intersectedElements, enclosedElements } = - input; - - const elementsMap = arrayToMap(elements); - // simplify the path to reduce the number of points - const simplifiedPath = simplify(lassoPath, 0.75) as GlobalPoint[]; - // close the path to form a polygon for enclosure check - const closedPath = polygonFromPoints(simplifiedPath); - // as the path might not enclose a shape anymore, clear before checking - enclosedElements.clear(); - for (const [, element] of elementsMap) { - if ( - !intersectedElements.has(element.id) && - !enclosedElements.has(element.id) - ) { - const enclosed = enclosureTest(closedPath, element, elementsMap); - if (enclosed) { - enclosedElements.add(element.id); - } else { - const intersects = intersectionTest(closedPath, element, elementsMap); - if (intersects) { - intersectedElements.add(element.id); - } +export const updateSelection = (input: LassoWorkerInput): LassoWorkerOutput => { + const { + lassoPath, + elements, + elementsSegments, + intersectedElements, + enclosedElements, + } = input; + // simplify the path to reduce the number of points + const path = simplify(lassoPath, 2) as GlobalPoint[]; + // close the path to form a polygon for enclosure check + const closedPath = polygonFromPoints(path); + // as the path might not enclose a shape anymore, clear before checking + enclosedElements.clear(); + for (const element of elements) { + if ( + !intersectedElements.has(element.id) && + !enclosedElements.has(element.id) + ) { + const enclosed = enclosureTest(closedPath, element, elementsSegments); + if (enclosed) { + enclosedElements.add(element.id); + } else { + const intersects = intersectionTest( + closedPath, + element, + elementsSegments, + ); + if (intersects) { + intersectedElements.add(element.id); } } } + } - const results = [...intersectedElements, ...enclosedElements]; + const results = [...intersectedElements, ...enclosedElements]; - return { - selectedElementIds: results, - }; - }, - 100, -); + return { + selectedElementIds: results, + }; +}; const enclosureTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - elementsMap: ElementsMap, + elementsSegments: ElementsSegments, ): boolean => { const lassoPolygon = polygonFromPoints(lassoPath); - const segments = getElementLineSegments(element, elementsMap); + const segments = elementsSegments.get(element.id); + if (!segments) { + return false; + } return segments.some((segment) => { - return segment.some((point) => isPointInPolygon(point, lassoPolygon)); + return segment.some((point) => polygonIncludesPoint(point, lassoPolygon)); }); }; -// // Helper function to check if a point is inside a polygon -const isPointInPolygon = ( - point: GlobalPoint, - polygon: GlobalPoint[], -): boolean => { - let isInside = false; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i][0], - yi = polygon[i][1]; - const xj = polygon[j][0], - yj = polygon[j][1]; - - const intersect = - yi > point[1] !== yj > point[1] && - point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi; - if (intersect) isInside = !isInside; - } - return isInside; -}; - const intersectionTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - elementsMap: ElementsMap, + elementsSegments: ElementsSegments, ): boolean => { - const elementSegments = getElementLineSegments(element, elementsMap); + const elementSegments = elementsSegments.get(element.id); + if (!elementSegments) { + return false; + } const lassoSegments = lassoPath.reduce((acc, point, index) => { - if (index === 0) return acc; - acc.push([lassoPath[index - 1], point] as [GlobalPoint, GlobalPoint]); + if (index === 0) { + return acc; + } + acc.push(lineSegment(lassoPath[index - 1], point)); return acc; - }, [] as [GlobalPoint, GlobalPoint][]); + }, [] as LineSegment[]); return lassoSegments.some((lassoSegment) => - elementSegments.some((elementSegment) => - doLineSegmentsIntersect(lassoSegment, elementSegment), + elementSegments.some( + (elementSegment) => + lineSegmentIntersectionPoints(lassoSegment, elementSegment) !== null, ), ); }; - -// Helper function to check if two line segments intersect -const doLineSegmentsIntersect = ( - [p1, p2]: [GlobalPoint, GlobalPoint], - [p3, p4]: [GlobalPoint, GlobalPoint], -): boolean => { - const denominator = - (p4[1] - p3[1]) * (p2[0] - p1[0]) - (p4[0] - p3[0]) * (p2[1] - p1[1]); - - if (denominator === 0) return false; - - const ua = - ((p4[0] - p3[0]) * (p1[1] - p3[1]) - (p4[1] - p3[1]) * (p1[0] - p3[0])) / - denominator; - const ub = - ((p2[0] - p1[0]) * (p1[1] - p3[1]) - (p2[1] - p1[1]) * (p1[0] - p3[0])) / - denominator; - - return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1; -}; - -const getCurvePathOps = (shape: Drawable): Op[] => { - for (const set of shape.sets) { - if (set.type === "path") { - return set.ops; - } - } - return shape.sets[0].ops; -}; - -const getElementLineSegments = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): LineSegment[] => { - const [x1, y1, x2, y2, cx, cy] = [ - element.x, - element.y, - element.x + element.width, - element.y + element.height, - element.x + element.width / 2, - element.y + element.height / 2, - ]; - - const center: GlobalPoint = pointFrom(cx, cy); - - if ( - element.type === "line" || - element.type === "arrow" || - element.type === "freedraw" - ) { - const segments: LineSegment[] = []; - - const getPointsOnCurve = () => { - const generator = new RoughGenerator(); - - const drawable = generator.curve(element.points as unknown as Point[]); - - const ops = getCurvePathOps(drawable); - - const _points: LocalPoint[] = []; - // let odd = false; - // for (const operation of ops) { - // if (operation.op === "move") { - // odd = !odd; - // if (odd) { - // if ( - // Array.isArray(operation.data) && - // operation.data.length >= 2 && - // operation.data.every( - // (d) => d !== undefined && typeof d === "number", - // ) - // ) { - // _points.push(pointFrom(operation.data[0], operation.data[1])); - // } - // } - // } else if (operation.op === "bcurveTo") { - // if (odd) { - // if ( - // Array.isArray(operation.data) && - // operation.data.length === 6 && - // operation.data.every( - // (d) => d !== undefined && typeof d === "number", - // ) - // ) { - // _points.push(pointFrom(operation.data[0], operation.data[1])); - // _points.push(pointFrom(operation.data[2], operation.data[3])); - // _points.push(pointFrom(operation.data[4], operation.data[5])); - // } - // } - // } else if (operation.op === "lineTo") { - // if ( - // Array.isArray(operation.data) && - // operation.data.length >= 2 && - // odd && - // operation.data.every( - // (d) => d !== undefined && typeof d === "number", - // ) - // ) { - // _points.push(pointFrom(operation.data[0], operation.data[1])); - // } - // } - // } - - return pointsOnBezierCurves(_points, 10, 5); - }; - - let i = 0; - - // const points = - // element.roughness !== 0 && element.type !== "freedraw" - // ? getPointsOnCurve() - // : element.points; - - const points = element.points; - - while (i < points.length - 1) { - segments.push( - lineSegment( - pointRotateRads( - pointFrom( - element.points[i][0] + element.x, - element.points[i][1] + element.y, - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - element.points[i + 1][0] + element.x, - element.points[i + 1][1] + element.y, - ), - center, - element.angle, - ), - ), - ); - i++; - } - - return segments; - } - - const [nw, ne, sw, se, n, s, w, e] = ( - [ - [x1, y1], - [x2, y1], - [x1, y2], - [x2, y2], - [cx, y1], - [cx, y2], - [x1, cy], - [x2, cy], - ] as GlobalPoint[] - ).map((point) => pointRotateRads(point, center, element.angle)); - - if (element.type === "diamond") { - return [ - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - ]; - } - - if (element.type === "ellipse") { - return [ - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - lineSegment(n, w), - lineSegment(n, e), - lineSegment(s, w), - lineSegment(s, e), - ]; - } - - if (element.type === "frame" || element.type === "magicframe") { - return [ - lineSegment(nw, ne), - lineSegment(ne, se), - lineSegment(se, sw), - lineSegment(sw, nw), - ]; - } - - return [ - lineSegment(nw, ne), - lineSegment(sw, se), - lineSegment(nw, sw), - lineSegment(ne, se), - lineSegment(nw, e), - lineSegment(sw, e), - lineSegment(ne, w), - lineSegment(se, w), - ]; -}; - -// This is a copy of arrayToMap from utils.ts -// copy to avoid accessing DOM related things in worker -const arrayToMap = ( - items: readonly T[] | Map, -) => { - if (items instanceof Map) { - return items; - } - return items.reduce((acc: Map, element) => { - acc.set(typeof element === "string" ? element : element.id, element); - return acc; - }, new Map()); -}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 680da8a1b..b361ad728 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -60,7 +60,7 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; import { ShapeCache } from "../scene/ShapeCache"; import { getVerticalOffset } from "../fonts"; -import { GlobalPoint, isRightAngleRads } from "../../math"; +import { isRightAngleRads } from "../../math"; import { getCornerRadius } from "../shapes"; import { getUncroppedImageElement } from "../element/cropElement"; import { getLineHeightInPx } from "../element/textMeasurements"; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cde786d20..095c94913 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -42,7 +42,7 @@ import type { ContextMenuItems } from "./components/ContextMenu"; import type { SnapLine } from "./snapping"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { StoreActionType } from "./store"; -import { GlobalPoint } from "../math"; +import type { GlobalPoint } from "../math"; export type SocketId = string & { _brand: "SocketId" };