Removing workers as the complexit is much worse, while perf. does not seem to be much better

This commit is contained in:
Marcel Mraz 2025-04-02 12:55:45 +01:00
parent aab03481a2
commit 57b4a098f3
6 changed files with 116 additions and 493 deletions

View file

@ -29,10 +29,7 @@ import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
import { import { getLassoSelectedElementIds } from "./utils";
getLassoSelectedElementIds,
type LassoWorkerInput,
} from "./lasso-main";
import type App from "../components/App"; import type App from "../components/App";
@ -183,17 +180,14 @@ export class LassoTrail extends AnimatedTrail {
} }
if (lassoPath) { if (lassoPath) {
// need to omit command, otherwise "shared" chunk will be included in the main bundle by default const { selectedElementIds } = getLassoSelectedElementIds({
const message: Omit<LassoWorkerInput, "command"> = {
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsSegments: this.elementsSegments, elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,
simplifyDistance: 5 / this.app.state.zoom.value, simplifyDistance: 5 / this.app.state.zoom.value,
}; });
const { selectedElementIds } = await getLassoSelectedElementIds(message);
this.selectElementsFromIds(selectedElementIds); this.selectElementsFromIds(selectedElementIds);
} }

View file

@ -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<LassoWorkerInput, "command">,
): Promise<
LassoWorkerOutput<typeof Commands.GET_LASSO_SELECTED_ELEMENT_IDS>
> => {
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<typeof import("./lasso-worker.chunk")> | null = null;
let lassoShared: Promise<typeof import("./lasso-shared.chunk")> | 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<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
simplifyDistance?: number;
};
export type LassoWorkerOutput<T extends LassoWorkerInput["command"]> =
T extends typeof Commands.GET_LASSO_SELECTED_ELEMENT_IDS
? {
selectedElementIds: string[];
}
: never;
let workerPool: Promise<
WorkerPool<LassoWorkerInput, LassoWorkerOutput<LassoWorkerInput["command"]>>
> | 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<LassoWorkerInput["command"]>
>(WorkerUrl, {
// limit the pool size to a single active worker
maxPoolSize: 1,
});
return pool;
});
}
return workerPool;
};

View file

@ -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<string, LineSegment<GlobalPoint>[]>;
/**
* 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<typeof Commands.GET_LASSO_SELECTED_ELEMENT_IDS> => {
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<GlobalPoint>[]);
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<Point extends GlobalPoint | LocalPoint>(
points: Point[],
) {
return polygonClose(points) as Polygon<Point>;
}
function polygonClose<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
) {
return polygonIsClosed(polygon)
? polygon
: ([...polygon, polygon[0]] as Polygon<Point>);
}
function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
) {
return pointsEqual(polygon[0], polygon[polygon.length - 1]);
}
const PRECISION = 10e-5;
function pointsEqual<Point extends GlobalPoint | LocalPoint>(
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 extends LocalPoint | GlobalPoint>(
point: Point,
polygon: Polygon<Point>,
) => {
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<P extends GlobalPoint | LocalPoint>(
a: P,
b: P,
): LineSegment<P> {
return [a, b] as LineSegment<P>;
}
function lineSegmentIntersectionPoints<Point extends GlobalPoint | LocalPoint>(
l: LineSegment<Point>,
s: LineSegment<Point>,
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<Point extends GlobalPoint | LocalPoint>(
a: Line<Point>,
b: Line<Point>,
): 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<Point>((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D);
}
return null;
}
function line<P extends GlobalPoint | LocalPoint>(a: P, b: P): Line<P> {
return [a, b] as Line<P>;
}
const pointOnLineSegment = <Point extends LocalPoint | GlobalPoint>(
point: Point,
line: LineSegment<Point>,
threshold = PRECISION,
) => {
const distance = distanceToLineSegment(point, line);
if (distance === 0) {
return true;
}
return distance < threshold;
};
const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
point: Point,
line: LineSegment<Point>,
) => {
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<Point extends GlobalPoint | LocalPoint>(
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, []);
}
// RamerDouglasPeucker 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];
}

View file

@ -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<LassoWorkerInput>) => {
switch (event.data.command) {
case Commands.GET_LASSO_SELECTED_ELEMENT_IDS:
const result = getLassoSelectedElementIds(event.data);
self.postMessage(result);
break;
}
};
}

View file

@ -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<string, LineSegment<GlobalPoint>[]>;
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];
elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
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<GlobalPoint>[]);
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,
),
);
};

View file

@ -29,14 +29,11 @@ import { Excalidraw } from "../index";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { import { getLassoSelectedElementIds } from "../lasso/utils";
Commands,
getLassoSelectedElementIds,
} from "../lasso/lasso-shared.chunk";
import { act, render } from "./test-utils"; import { act, render } from "./test-utils";
import type { ElementsSegmentsMap } from "../lasso/lasso-shared.chunk"; import type { ElementsSegmentsMap } from "../lasso/utils";
const { h } = window; const { h } = window;
@ -68,7 +65,6 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => {
} }
const result = getLassoSelectedElementIds({ const result = getLassoSelectedElementIds({
command: Commands.GET_LASSO_SELECTED_ELEMENT_IDS,
lassoPath: lassoPath:
h.app.lassoTrail h.app.lassoTrail
.getCurrentTrail() .getCurrentTrail()