Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs 2025-05-07 22:18:03 +02:00
parent 9203c99eec
commit 814520b1f0
No known key found for this signature in database
3 changed files with 146 additions and 138 deletions

View File

@ -1,12 +1,6 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
pointFrom,
pointDistance,
type LocalPoint,
curve,
pointFromArray,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@ -36,6 +30,7 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@ -68,37 +63,6 @@ function adjustRoughness(element: ExcalidrawElement): number {
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5); return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
} }
export const generateRoughOptionsForCollision = (
element: ExcalidrawElement,
): Options => {
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
roughness: 0,
preserveVertices: true,
};
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "diamond":
case "ellipse": {
if (element.type === "ellipse") {
options.curveFitting = 1;
}
return options;
}
case "line":
case "freedraw":
case "arrow":
return options;
default: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
};
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
@ -341,50 +305,22 @@ const getArrowheadShapes = (
} }
}; };
export const generateComponentsForCollision = (element: ExcalidrawElement) => { export const generateLinearCollisionShape = (
const ops = generateRoughOpsForCollision(element) as { element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
op: string; ) => {
data: number[];
}[];
const components = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
curve(
prevPoint,
pointFrom<LocalPoint>(op.data[0], op.data[1]),
pointFrom<LocalPoint>(op.data[2], op.data[3]),
pointFrom<LocalPoint>(op.data[4], op.data[5]),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
return components;
};
const generateRoughOpsForCollision = (element: ExcalidrawElement) => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
switch (element.type) { switch (element.type) {
case "line": case "line":
case "arrow": { case "arrow": {
let shape: any; let shape: any;
const options = generateRoughOptions(element);
// points array can be empty in the beginning, so it is important to add // points array can be empty in the beginning, so it is important to add
// initial position to it // initial position to it
@ -393,42 +329,24 @@ const generateRoughOpsForCollision = (element: ExcalidrawElement) => {
: [pointFrom<LocalPoint>(0, 0)]; : [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes shape = generator.path(generateElbowArrowShape(points, 16), options)
if ( .sets[0].ops;
!points.every(
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
)
) {
console.error(
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
element.id,
JSON.stringify(points),
);
shape = [];
} else {
shape = generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptionsForCollision(element),
).sets[0].ops;
}
} else if (!element.roundness) { } else if (!element.roundness) {
// curve is always the first element shape = points.map((point, idx) => {
// this simplifies finding the curve for an element return idx === 0
if (options.fill) { ? { op: "move", data: point }
shape = generator.polygon(points as unknown as RoughPoint[], options) : {
.sets[0].ops; op: "lineTo",
data: [point[0], point[1]],
};
});
} else { } else {
shape = generator.linearPath( shape = generator
points as unknown as RoughPoint[], .curve(points as unknown as RoughPoint[], options)
options, .sets[0].ops.slice(0, element.points.length);
).sets[0].ops;
}
} else {
shape = generator.curve(points as unknown as RoughPoint[], options)
.sets[0].ops;
} }
return shape.slice(0, element.points.length); return shape;
} }
case "freedraw": { case "freedraw": {
const simplifiedPoints = simplify( const simplifiedPoints = simplify(
@ -437,10 +355,7 @@ const generateRoughOpsForCollision = (element: ExcalidrawElement) => {
); );
return generator return generator
.curve( .curve(simplifiedPoints as [number, number][], options)
simplifiedPoints as [number, number][],
generateRoughOptionsForCollision(element),
)
.sets[0].ops.slice(0, element.points.length); .sets[0].ops.slice(0, element.points.length);
} }
} }

View File

@ -4,9 +4,9 @@ import {
arrayToMap, arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
curve,
curveIntersectLineSegment, curveIntersectLineSegment,
isCurve, isCurve,
isLineSegment,
isPointWithinBounds, isPointWithinBounds,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
@ -22,7 +22,12 @@ import {
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math"; import type {
Curve,
GlobalPoint,
LineSegment,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
@ -37,6 +42,7 @@ import {
} from "./typeChecks"; } from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
@ -44,18 +50,16 @@ import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { generateComponentsForCollision } from "./Shape";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
import { debugDrawCubicBezier } from "@excalidraw/excalidraw/visualdebug";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
@ -107,21 +111,6 @@ export const hitElementItself = ({
: isPointOnShape(point, element, threshold) : isPointOnShape(point, element, threshold)
: false; : false;
element.type === "freedraw" &&
generateComponentsForCollision(element).forEach((c) => {
if (isCurve(c)) {
debugDrawCubicBezier(
curve(
pointFrom<GlobalPoint>(element.x + c[0][0], element.y + c[0][1]),
pointFrom<GlobalPoint>(element.x + c[1][0], element.y + c[1][1]),
pointFrom<GlobalPoint>(element.x + c[2][0], element.y + c[2][1]),
pointFrom<GlobalPoint>(element.x + c[3][0], element.y + c[3][1]),
),
{ color: "red" },
);
}
});
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
const x1 = frameNameBound.x - threshold; const x1 = frameNameBound.x - threshold;
@ -216,11 +205,41 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return []; return intersectLinearOrFreeDrawWithLineSegment(element, line);
//throw new Error(`Unimplemented element type '${element.type}'`);
} }
}; };
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
): GlobalPoint[] => {
const shapes = deconstructLinearOrFreeDrawElement(element);
const intersections: GlobalPoint[] = [];
for (const shape of shapes) {
switch (true) {
case isCurve(shape):
intersections.push(
...curveIntersectLineSegment(shape as Curve<GlobalPoint>, segment),
);
continue;
case isLineSegment(shape):
const point = lineSegmentIntersectionPoints(
segment,
shape as LineSegment<GlobalPoint>,
);
if (point) {
intersections.push(point);
}
continue;
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = ( const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,

View File

@ -2,6 +2,7 @@ import {
curve, curve,
lineSegment, lineSegment,
pointFrom, pointFrom,
pointFromArray,
pointFromVector, pointFromVector,
rectangle, rectangle,
vectorFromPoint, vectorFromPoint,
@ -12,17 +13,90 @@ import {
import { elementCenterPoint } from "@excalidraw/common"; import { elementCenterPoint } from "@excalidraw/common";
import type { Curve, LineSegment } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./Shape";
import type { import type {
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
): (Curve<GlobalPoint> | LineSegment<GlobalPoint>)[] {
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const components = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
return components;
}
/** /**
* Get the building components of a rectanguloid element in the form of * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves.