feed segments to worker

This commit is contained in:
Ryan Di 2025-02-27 18:05:36 +11:00
parent b0cdf1c296
commit 33d5886123
4 changed files with 280 additions and 297 deletions

View file

@ -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 { AnimatedTrail } from "../animated-trail";
import { AnimationFrameHandler } from "../animation-frame-handler"; import { type AnimationFrameHandler } from "../animation-frame-handler";
import App from "../components/App"; import type App from "../components/App";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; import { isFrameLikeElement, isLinearElement } from "../element/typeChecks";
import { import type {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
NonDeleted, NonDeleted,
} from "../element/types"; } from "../element/types";
import { getFrameChildren } from "../frame"; import { getFrameChildren } from "../frame";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { easeOut } from "../utils"; import { getElementShape } from "../shapes";
import { LassoWorkerInput, LassoWorkerOutput } from "./worker"; 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 { export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set(); private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set(); private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
private worker: Worker | null = null; private worker: Worker | null = null;
private elementsSegments: Map<string, LineSegment<GlobalPoint>[]> | null =
null;
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, { super(animationFrameHandler, app, {
@ -132,10 +152,20 @@ export class LassoTrail extends AnimatedTrail {
.getCurrentTrail() .getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])); ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(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) { if (lassoPath) {
const message: LassoWorkerInput = { const message: LassoWorkerInput = {
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,
}; };
@ -149,9 +179,185 @@ export class LassoTrail extends AnimatedTrail {
super.clearTrails(); super.clearTrails();
this.intersectedElements.clear(); this.intersectedElements.clear();
this.enclosedElements.clear(); this.enclosedElements.clear();
this.elementsSegments = null;
this.app.setState({ this.app.setState({
lassoSelection: null, lassoSelection: null,
}); });
this.worker?.terminate(); 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<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center = pointFrom<GlobalPoint>(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<GlobalPoint>[] = [];
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<GlobalPoint>[];
} else if (isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = 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<GlobalPoint>[] = [];
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<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
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;
};

View file

@ -1,18 +1,8 @@
import { import type { GlobalPoint, LineSegment } from "../../math/types";
GlobalPoint, import { polygonFromPoints, polygonIncludesPoint } from "../../math/polygon";
LineSegment, import type { ExcalidrawElement } from "../element/types";
LocalPoint, import { lineSegment, lineSegmentIntersectionPoints } from "../../math/segment";
Radians, import { simplify } from "points-on-curve";
} 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";
// variables to track processing state and latest input data // variables to track processing state and latest input data
// for "backpressure" purposes // for "backpressure" purposes
@ -38,7 +28,9 @@ self.onmessage = (event: MessageEvent<LassoWorkerInput>) => {
// function to process the latest data // function to process the latest data
const processInputData = () => { const processInputData = () => {
// If no data to process, return // If no data to process, return
if (!latestInputData) return; if (!latestInputData) {
return;
}
// capture the current data to process and reset latestData // capture the current data to process and reset latestData
const dataToProcess = latestInputData; const dataToProcess = latestInputData;
@ -79,9 +71,12 @@ const processInputData = () => {
} }
}; };
type ElementsSegments = Map<string, LineSegment<GlobalPoint>[]>;
export type LassoWorkerInput = { export type LassoWorkerInput = {
lassoPath: GlobalPoint[]; lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsSegments: ElementsSegments;
intersectedElements: Set<ExcalidrawElement["id"]>; intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>; enclosedElements: Set<ExcalidrawElement["id"]>;
}; };
@ -90,304 +85,86 @@ export type LassoWorkerOutput = {
selectedElementIds: string[]; selectedElementIds: string[];
}; };
export const updateSelection = throttle( export const updateSelection = (input: LassoWorkerInput): LassoWorkerOutput => {
(input: LassoWorkerInput): LassoWorkerOutput => { const {
const { lassoPath, elements, intersectedElements, enclosedElements } = lassoPath,
input; elements,
elementsSegments,
const elementsMap = arrayToMap(elements); intersectedElements,
// simplify the path to reduce the number of points enclosedElements,
const simplifiedPath = simplify(lassoPath, 0.75) as GlobalPoint[]; } = input;
// close the path to form a polygon for enclosure check // simplify the path to reduce the number of points
const closedPath = polygonFromPoints(simplifiedPath); const path = simplify(lassoPath, 2) as GlobalPoint[];
// as the path might not enclose a shape anymore, clear before checking // close the path to form a polygon for enclosure check
enclosedElements.clear(); const closedPath = polygonFromPoints(path);
for (const [, element] of elementsMap) { // as the path might not enclose a shape anymore, clear before checking
if ( enclosedElements.clear();
!intersectedElements.has(element.id) && for (const element of elements) {
!enclosedElements.has(element.id) if (
) { !intersectedElements.has(element.id) &&
const enclosed = enclosureTest(closedPath, element, elementsMap); !enclosedElements.has(element.id)
if (enclosed) { ) {
enclosedElements.add(element.id); const enclosed = enclosureTest(closedPath, element, elementsSegments);
} else { if (enclosed) {
const intersects = intersectionTest(closedPath, element, elementsMap); enclosedElements.add(element.id);
if (intersects) { } else {
intersectedElements.add(element.id); const intersects = intersectionTest(
} closedPath,
element,
elementsSegments,
);
if (intersects) {
intersectedElements.add(element.id);
} }
} }
} }
}
const results = [...intersectedElements, ...enclosedElements]; const results = [...intersectedElements, ...enclosedElements];
return { return {
selectedElementIds: results, selectedElementIds: results,
}; };
}, };
100,
);
const enclosureTest = ( const enclosureTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsSegments: ElementsSegments,
): boolean => { ): boolean => {
const lassoPolygon = polygonFromPoints(lassoPath); const lassoPolygon = polygonFromPoints(lassoPath);
const segments = getElementLineSegments(element, elementsMap); const segments = elementsSegments.get(element.id);
if (!segments) {
return false;
}
return segments.some((segment) => { 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 = ( const intersectionTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsSegments: ElementsSegments,
): boolean => { ): boolean => {
const elementSegments = getElementLineSegments(element, elementsMap); const elementSegments = elementsSegments.get(element.id);
if (!elementSegments) {
return false;
}
const lassoSegments = lassoPath.reduce((acc, point, index) => { const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) return acc; if (index === 0) {
acc.push([lassoPath[index - 1], point] as [GlobalPoint, GlobalPoint]); return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc; return acc;
}, [] as [GlobalPoint, GlobalPoint][]); }, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) => return lassoSegments.some((lassoSegment) =>
elementSegments.some((elementSegment) => elementSegments.some(
doLineSegmentsIntersect(lassoSegment, elementSegment), (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<GlobalPoint>[] => {
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<GlobalPoint>[] = [];
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 = <T extends { id: string } | string>(
items: readonly T[] | Map<string, T>,
) => {
if (items instanceof Map) {
return items;
}
return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
}, new Map());
};

View file

@ -60,7 +60,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame"; import { getContainingFrame } from "../frame";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts"; import { getVerticalOffset } from "../fonts";
import { GlobalPoint, isRightAngleRads } from "../../math"; import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { getUncroppedImageElement } from "../element/cropElement"; import { getUncroppedImageElement } from "../element/cropElement";
import { getLineHeightInPx } from "../element/textMeasurements"; import { getLineHeightInPx } from "../element/textMeasurements";

View file

@ -42,7 +42,7 @@ import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store"; import type { StoreActionType } from "./store";
import { GlobalPoint } from "../math"; import type { GlobalPoint } from "../math";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };