fix: Freedraw hit test precision
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointRotateRads,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
@@ -47,8 +48,9 @@ export const distanceToElement = (
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
case "freedraw":
|
||||
return distanceToFreeDrawElement(element, elementsMap, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,3 +147,30 @@ const distanceToLinearOrFreeDraElement = (
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point to a freedraw element.
|
||||
*
|
||||
* @param element The freedraw element
|
||||
* @param p The point to consider
|
||||
* @returns 0 if the point is within the inked area, otherwise the euclidean
|
||||
* distance to the stroke outline
|
||||
*/
|
||||
const distanceToFreeDrawElement = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const polygon = lines.map((line) => line[0]);
|
||||
if (polygonIncludesPointNonZero(p, polygon)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
|
||||
};
|
||||
|
||||
@@ -677,67 +677,43 @@ export const generateLinearCollisionShape = (
|
||||
});
|
||||
}
|
||||
case "freedraw": {
|
||||
if (element.points.length < 2) {
|
||||
const outlinePoints = getFreedrawOutlinePoints(element);
|
||||
|
||||
if (outlinePoints.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
const collisionOutline =
|
||||
element.strokeOptions?.variability === "constant" &&
|
||||
CONSTANT_WIDTH_FREEDRAW.STREAMLINE > 0
|
||||
? (simplify(
|
||||
outlinePoints as Mutable<LocalPoint[]>,
|
||||
CONSTANT_WIDTH_FREEDRAW.STREAMLINE,
|
||||
) as [number, number][])
|
||||
: outlinePoints;
|
||||
|
||||
return generator
|
||||
.curve(simplifiedPoints as [number, number][], options)
|
||||
.sets[0].ops.slice(0, element.points.length)
|
||||
.map((op, i) => {
|
||||
if (i === 0) {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return {
|
||||
op: "move",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
if (collisionOutline.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Close the outline polygon so its boundary never has a gap at the seam.
|
||||
const [firstX, firstY] = collisionOutline[0];
|
||||
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
|
||||
const closedOutline =
|
||||
firstX === lastX && firstY === lastY
|
||||
? collisionOutline
|
||||
: [...collisionOutline, collisionOutline[0]];
|
||||
|
||||
return closedOutline.map((point, idx) => {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return {
|
||||
op: "bcurveTo",
|
||||
data: [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]
|
||||
.map((p) =>
|
||||
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
)
|
||||
.flat(),
|
||||
op: idx === 0 ? "move" : "lineTo",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,3 +218,54 @@ describe("hitElementItself cache", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freedraw collision matches the rendered stroke width", () => {
|
||||
// A straight, horizontal stroke centered on y === 0.
|
||||
const points = Array.from({ length: 21 }, (_, i) =>
|
||||
pointFrom<LocalPoint>(i * 5, 0),
|
||||
);
|
||||
|
||||
const createFreeDraw = (variability: "variable" | "constant") =>
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeWidth: 10,
|
||||
points,
|
||||
strokeOptions: { variability, streamline: 0.5 },
|
||||
});
|
||||
|
||||
const distanceAt = (
|
||||
element: ReturnType<typeof createFreeDraw>,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
distance.distanceToElement(
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
pointFrom<GlobalPoint>(x, y),
|
||||
);
|
||||
|
||||
it("treats a point on the centerline as a direct hit for both modes", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0);
|
||||
expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("hits across the body of a thick stroke, not just near the centerline", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses a wider hit area for variable-width than for constant-width strokes", () => {
|
||||
const offset = 20;
|
||||
const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset);
|
||||
const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset);
|
||||
|
||||
expect(variableDistance).toBe(0);
|
||||
expect(constantDistance).toBeGreaterThan(0);
|
||||
expect(variableDistance).toBeLessThan(constantDistance);
|
||||
});
|
||||
|
||||
it("does not hit points clearly outside even the widest stroke", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user