diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts index 73f2c2c4a..d2434e73e 100644 --- a/packages/excalidraw/lasso/index.ts +++ b/packages/excalidraw/lasso/index.ts @@ -29,10 +29,7 @@ import { type AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animated-trail"; -import { - getLassoSelectedElementIds, - type LassoWorkerInput, -} from "./lasso-main"; +import { getLassoSelectedElementIds } from "./utils"; import type App from "../components/App"; @@ -183,17 +180,14 @@ export class LassoTrail extends AnimatedTrail { } if (lassoPath) { - // need to omit command, otherwise "shared" chunk will be included in the main bundle by default - const message: Omit = { + const { selectedElementIds } = getLassoSelectedElementIds({ lassoPath, elements: this.app.visibleElements, elementsSegments: this.elementsSegments, intersectedElements: this.intersectedElements, enclosedElements: this.enclosedElements, simplifyDistance: 5 / this.app.state.zoom.value, - }; - - const { selectedElementIds } = await getLassoSelectedElementIds(message); + }); this.selectElementsFromIds(selectedElementIds); } diff --git a/packages/excalidraw/lasso/lasso-main.ts b/packages/excalidraw/lasso/lasso-main.ts deleted file mode 100644 index c62698b29..000000000 --- a/packages/excalidraw/lasso/lasso-main.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { promiseTry } from "@excalidraw/common"; - -import type { ExcalidrawElement } from "@excalidraw/element/types"; - -import type { GlobalPoint } from "@excalidraw/math"; - -import { WorkerPool } from "../workers"; - -import type { Commands, ElementsSegmentsMap } from "./lasso-shared.chunk"; - -let shouldUseWorkers = typeof Worker !== "undefined"; - -/** - * Tries to get the selected element with a worker, if it fails, it fallbacks to the main thread. - * - * @param input - The input data for the lasso selection. - * @returns The selected element ids. - */ -export const getLassoSelectedElementIds = async ( - input: Omit, -): Promise< - LassoWorkerOutput -> => { - const { Commands, getLassoSelectedElementIds } = - await lazyLoadLassoSharedChunk(); - - const inputWithCommand: LassoWorkerInput = { - ...input, - command: Commands.GET_LASSO_SELECTED_ELEMENT_IDS, - }; - - if (!shouldUseWorkers) { - return getLassoSelectedElementIds(inputWithCommand); - } - - return promiseTry(async () => { - try { - const workerPool = await getOrCreateWorkerPool(); - - const result = await workerPool.postMessage(inputWithCommand, {}); - - return result; - } catch (e) { - // don't use workers if they are failing - shouldUseWorkers = false; - - // eslint-disable-next-line no-console - console.error( - "Failed to use workers for lasso selection, falling back to the main thread.", - e, - ); - - // fallback to the main thread - return getLassoSelectedElementIds(inputWithCommand); - } - }); -}; - -// lazy-loaded and cached chunks -let lassoWorker: Promise | null = null; -let lassoShared: Promise | null = null; - -export const lazyLoadLassoWorkerChunk = async () => { - if (!lassoWorker) { - lassoWorker = import("./lasso-worker.chunk"); - } - - return lassoWorker; -}; - -export const lazyLoadLassoSharedChunk = async () => { - if (!lassoShared) { - lassoShared = import("./lasso-shared.chunk"); - } - - return lassoShared; -}; - -export type LassoWorkerInput = { - command: typeof Commands.GET_LASSO_SELECTED_ELEMENT_IDS; - lassoPath: GlobalPoint[]; - elements: readonly ExcalidrawElement[]; - elementsSegments: ElementsSegmentsMap; - intersectedElements: Set; - enclosedElements: Set; - simplifyDistance?: number; -}; - -export type LassoWorkerOutput = - T extends typeof Commands.GET_LASSO_SELECTED_ELEMENT_IDS - ? { - selectedElementIds: string[]; - } - : never; - -let workerPool: Promise< - WorkerPool> -> | null = null; - -/** - * Lazy initialize or get the worker pool singleton. - * - * @throws implicitly if anything goes wrong - */ -const getOrCreateWorkerPool = () => { - if (!workerPool) { - // immediate concurrent-friendly return, to ensure we have only one pool instance - workerPool = promiseTry(async () => { - const { WorkerUrl } = await lazyLoadLassoWorkerChunk(); - - const pool = WorkerPool.create< - LassoWorkerInput, - LassoWorkerOutput - >(WorkerUrl, { - // limit the pool size to a single active worker - maxPoolSize: 1, - }); - - return pool; - }); - } - - return workerPool; -}; diff --git a/packages/excalidraw/lasso/lasso-shared.chunk.ts b/packages/excalidraw/lasso/lasso-shared.chunk.ts deleted file mode 100644 index 7fb0c5dea..000000000 --- a/packages/excalidraw/lasso/lasso-shared.chunk.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { - GlobalPoint, - Line, - LineSegment, - LocalPoint, - Polygon, -} from "@excalidraw/math/types"; -import type { ExcalidrawElement } from "@excalidraw/element/types"; - -import type { LassoWorkerInput, LassoWorkerOutput } from "./lasso-main"; - -export type ElementsSegmentsMap = Map[]>; - -/** - * Shared commands between the main thread and worker threads. - */ -export const Commands = { - GET_LASSO_SELECTED_ELEMENT_IDS: "GET_LASSO_SELECTED_ELEMENT_IDS", -} as const; - -export const getLassoSelectedElementIds = ( - input: LassoWorkerInput, -): LassoWorkerOutput => { - const { - lassoPath, - elements, - elementsSegments, - intersectedElements, - enclosedElements, - simplifyDistance, - } = input; - // simplify the path to reduce the number of points - let path: GlobalPoint[] = lassoPath; - if (simplifyDistance) { - path = simplify(lassoPath, simplifyDistance) 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]; - - return { - selectedElementIds: results, - }; -}; - -const enclosureTest = ( - lassoPath: GlobalPoint[], - element: ExcalidrawElement, - elementsSegments: ElementsSegmentsMap, -): boolean => { - const lassoPolygon = polygonFromPoints(lassoPath); - const segments = elementsSegments.get(element.id); - if (!segments) { - return false; - } - - return segments.some((segment) => { - return segment.some((point) => polygonIncludesPoint(point, lassoPolygon)); - }); -}; - -const intersectionTest = ( - lassoPath: GlobalPoint[], - element: ExcalidrawElement, - elementsSegments: ElementsSegmentsMap, -): boolean => { - const elementSegments = elementsSegments.get(element.id); - if (!elementSegments) { - return false; - } - - const lassoSegments = lassoPath.reduce((acc, point, index) => { - if (index === 0) { - return acc; - } - acc.push(lineSegment(lassoPath[index - 1], point)); - return acc; - }, [] as LineSegment[]); - - return lassoSegments.some((lassoSegment) => - elementSegments.some( - (elementSegment) => - // introduce a bit of tolerance to account for roughness and simplification of paths - lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, - ), - ); -}; - -export function polygonFromPoints( - points: Point[], -) { - return polygonClose(points) as Polygon; -} - -function polygonClose( - polygon: Point[], -) { - return polygonIsClosed(polygon) - ? polygon - : ([...polygon, polygon[0]] as Polygon); -} - -function polygonIsClosed( - polygon: Point[], -) { - return pointsEqual(polygon[0], polygon[polygon.length - 1]); -} - -const PRECISION = 10e-5; - -function pointsEqual( - a: Point, - b: Point, -): boolean { - const abs = Math.abs; - return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION; -} - -const polygonIncludesPoint = ( - point: Point, - polygon: Polygon, -) => { - const x = point[0]; - const y = point[1]; - let inside = false; - - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i][0]; - const yi = polygon[i][1]; - const xj = polygon[j][0]; - const yj = polygon[j][1]; - - if ( - ((yi > y && yj <= y) || (yi <= y && yj > y)) && - x < ((xj - xi) * (y - yi)) / (yj - yi) + xi - ) { - inside = !inside; - } - } - - return inside; -}; - -function lineSegment

( - a: P, - b: P, -): LineSegment

{ - return [a, b] as LineSegment

; -} - -function lineSegmentIntersectionPoints( - l: LineSegment, - s: LineSegment, - threshold?: number, -): Point | null { - const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1])); - - if ( - !candidate || - !pointOnLineSegment(candidate, s, threshold) || - !pointOnLineSegment(candidate, l, threshold) - ) { - return null; - } - - return candidate; -} - -function linesIntersectAt( - a: Line, - b: Line, -): Point | null { - const A1 = a[1][1] - a[0][1]; - const B1 = a[0][0] - a[1][0]; - const A2 = b[1][1] - b[0][1]; - const B2 = b[0][0] - b[1][0]; - const D = A1 * B2 - A2 * B1; - if (D !== 0) { - const C1 = A1 * a[0][0] + B1 * a[0][1]; - const C2 = A2 * b[0][0] + B2 * b[0][1]; - return pointFrom((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D); - } - - return null; -} - -function line

(a: P, b: P): Line

{ - return [a, b] as Line

; -} - -const pointOnLineSegment = ( - point: Point, - line: LineSegment, - threshold = PRECISION, -) => { - const distance = distanceToLineSegment(point, line); - - if (distance === 0) { - return true; - } - - return distance < threshold; -}; - -const distanceToLineSegment = ( - point: Point, - line: LineSegment, -) => { - const [x, y] = point; - const [[x1, y1], [x2, y2]] = line; - - const A = x - x1; - const B = y - y1; - const C = x2 - x1; - const D = y2 - y1; - - const dot = A * C + B * D; - const len_sq = C * C + D * D; - let param = -1; - if (len_sq !== 0) { - param = dot / len_sq; - } - - let xx; - let yy; - - if (param < 0) { - xx = x1; - yy = y1; - } else if (param > 1) { - xx = x2; - yy = y2; - } else { - xx = x1 + param * C; - yy = y1 + param * D; - } - - const dx = x - xx; - const dy = y - yy; - return Math.sqrt(dx * dx + dy * dy); -}; - -function pointFrom( - x: number, - y: number, -): Point { - return [x, y] as Point; -} - -// Adapated from https://www.npmjs.com/package/points-on-curve/v/1.0.1 -export function simplify(points: any, distance: any) { - return simplifyPoints(points, 0, points.length, distance, []); -} -// Ramer–Douglas–Peucker algorithm -// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm -function simplifyPoints( - points: any, - start: any, - end: any, - epsilon: any, - newPoints: any[], -) { - const outPoints: any[] = newPoints || []; - // find the most distance point from the endpoints - const s = points[start]; - const e = points[end - 1]; - let maxDistSq = 0; - let maxNdx = 1; - for (let i = start + 1; i < end - 1; ++i) { - const distSq = distanceToSegmentSq(points[i], s, e); - if (distSq > maxDistSq) { - maxDistSq = distSq; - maxNdx = i; - } - } - // if that point is too far, split - if (Math.sqrt(maxDistSq) > epsilon) { - simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints); - simplifyPoints(points, maxNdx, end, epsilon, outPoints); - } else { - if (!outPoints.length) { - outPoints.push(s); - } - outPoints.push(e); - } - return outPoints; -} - -// distance between 2 points squared -function distanceSq(p1: any, p2: any) { - return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2); -} -// Sistance squared from a point p to the line segment vw -function distanceToSegmentSq(p: any, v: any, w: any) { - const l2 = distanceSq(v, w); - if (l2 === 0) { - return distanceSq(p, v); - } - let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2; - t = Math.max(0, Math.min(1, t)); - return distanceSq(p, lerp(v, w, t)); -} - -function lerp(a: any, b: any, t: any) { - return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; -} diff --git a/packages/excalidraw/lasso/lasso-worker.chunk.ts b/packages/excalidraw/lasso/lasso-worker.chunk.ts deleted file mode 100644 index 9a2b25e4a..000000000 --- a/packages/excalidraw/lasso/lasso-worker.chunk.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Commands, getLassoSelectedElementIds } from "./lasso-shared.chunk"; - -import type { LassoWorkerInput } from "./lasso-main"; - -/** - * Due to this export (and related dynamic import), this worker code will be included in the bundle automatically (as a separate chunk), - * without the need for esbuild / vite /rollup plugins and special browser / server treatment. - * - * `import.meta.url` is undefined in nodejs - */ -export const WorkerUrl: URL | undefined = import.meta.url - ? new URL(import.meta.url) - : undefined; - -// run only in the worker context -if (typeof window === "undefined" && typeof self !== "undefined") { - self.onmessage = (event: MessageEvent) => { - switch (event.data.command) { - case Commands.GET_LASSO_SELECTED_ELEMENT_IDS: - const result = getLassoSelectedElementIds(event.data); - self.postMessage(result); - break; - } - }; -} diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts new file mode 100644 index 000000000..b04e66db9 --- /dev/null +++ b/packages/excalidraw/lasso/utils.ts @@ -0,0 +1,111 @@ +import { simplify } from "points-on-curve"; + +import { + polygonFromPoints, + polygonIncludesPoint, + lineSegment, + lineSegmentIntersectionPoints, +} from "@excalidraw/math"; + +import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +export type ElementsSegmentsMap = Map[]>; + +export const getLassoSelectedElementIds = (input: { + lassoPath: GlobalPoint[]; + elements: readonly ExcalidrawElement[]; + elementsSegments: ElementsSegmentsMap; + intersectedElements: Set; + enclosedElements: Set; + simplifyDistance?: number; +}): { + selectedElementIds: string[]; +} => { + const { + lassoPath, + elements, + elementsSegments, + intersectedElements, + enclosedElements, + simplifyDistance, + } = input; + // simplify the path to reduce the number of points + let path: GlobalPoint[] = lassoPath; + if (simplifyDistance) { + path = simplify(lassoPath, simplifyDistance) 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]; + + return { + selectedElementIds: results, + }; +}; + +const enclosureTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, +): boolean => { + const lassoPolygon = polygonFromPoints(lassoPath); + const segments = elementsSegments.get(element.id); + if (!segments) { + return false; + } + + return segments.some((segment) => { + return segment.some((point) => polygonIncludesPoint(point, lassoPolygon)); + }); +}; + +const intersectionTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsSegments: ElementsSegmentsMap, +): boolean => { + const elementSegments = elementsSegments.get(element.id); + if (!elementSegments) { + return false; + } + + const lassoSegments = lassoPath.reduce((acc, point, index) => { + if (index === 0) { + return acc; + } + acc.push(lineSegment(lassoPath[index - 1], point)); + return acc; + }, [] as LineSegment[]); + + return lassoSegments.some((lassoSegment) => + elementSegments.some( + (elementSegment) => + // introduce a bit of tolerance to account for roughness and simplification of paths + lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, + ), + ); +}; diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index 688ef8c7b..00e0ec2b4 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -29,14 +29,11 @@ import { Excalidraw } from "../index"; import { getSelectedElements } from "../scene"; -import { - Commands, - getLassoSelectedElementIds, -} from "../lasso/lasso-shared.chunk"; +import { getLassoSelectedElementIds } from "../lasso/utils"; import { act, render } from "./test-utils"; -import type { ElementsSegmentsMap } from "../lasso/lasso-shared.chunk"; +import type { ElementsSegmentsMap } from "../lasso/utils"; const { h } = window; @@ -68,7 +65,6 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => { } const result = getLassoSelectedElementIds({ - command: Commands.GET_LASSO_SELECTED_ELEMENT_IDS, lassoPath: h.app.lassoTrail .getCurrentTrail()