fix: Hit tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-03-24 19:26:59 +00:00
parent cc1f502a0f
commit 3e69b33a28
6 changed files with 76 additions and 47 deletions
+2 -3
View File
@@ -893,9 +893,8 @@ export const getArrowheadPoints = (
); );
if (arrowhead === "diamond" || arrowhead === "diamond_outline") { if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point — use the same Bezier tangent // point opposite to the arrowhead point, just mirrored across the (tx, ty)
// direction (nx, ny) as the wings so the diamond isn't distorted on // point
// rounded arrows where the tangent differs from the chord direction.
const ox = tx - nx * minSize * 2; const ox = tx - nx * minSize * 2;
const oy = ty - ny * minSize * 2; const oy = ty - ny * minSize * 2;
@@ -790,9 +790,6 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
// For curved (non-elbow) arrows the quadratic spline produces N-2 arcs
// for N anchor points. Cap the loop to the actual segment count so we
// don't request a midpoint for a segment that doesn't exist.
const [lines, segCurves] = deconstructLinearOrFreeDrawElement(element); const [lines, segCurves] = deconstructLinearOrFreeDrawElement(element);
const segmentCount = lines.length + segCurves.length; const segmentCount = lines.length + segCurves.length;
+38 -4
View File
@@ -44,7 +44,7 @@ import type {
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement"; import { elementWithCanvasCache } from "./renderElement";
import { debugDrawLine, debugDrawPoint, debugDrawPolygon } from "./visualdebug"; import { debugDrawLine, debugDrawPolygon } from "./visualdebug";
import { import {
canBecomePolygon, canBecomePolygon,
@@ -90,7 +90,7 @@ const CP_CHORD_POWER = 1;
// This factor [0, 1] controls how far the tangent direction is pulled toward // This factor [0, 1] controls how far the tangent direction is pulled toward
// the bisector (the chord-bisector normal) linearly with turn sharpness. // the bisector (the chord-bisector normal) linearly with turn sharpness.
// 0 = pure C2 spline; 1 = tangent fully aligned with the bisector. // 0 = pure C2 spline; 1 = tangent fully aligned with the bisector.
const CP_ANGLE_CORRECTION = 0.5; const CP_ANGLE_CORRECTION = 1;
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
@@ -665,8 +665,8 @@ export const generateLinearCollisionShape = (
data: rotateLocal(points[1][0], points[1][1]), data: rotateLocal(points[1][0], points[1][1]),
}); });
} else { } else {
// Chord-length C2 spline mirrors generateRoundedSimpleArrowShape exactly // Chord-length C2 spline. Mirrors generateRoundedSimpleArrowShape
// so hit-testing matches rendering. // exactly so hit-testing matches rendering.
const n = points.length - 1; const n = points.length - 1;
const h = new Float64Array(n); const h = new Float64Array(n);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -725,6 +725,40 @@ export const generateLinearCollisionShape = (
mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i])); mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i]));
} }
// At interior knots, blend the C2 tangent direction toward the
// bisector direction by a factor proportional to turn sharpness *
// CP_ANGLE_CORRECTION
for (let k = 1; k < n; k++) {
const d1x = (points[k][0] - points[k - 1][0]) / h[k - 1];
const d1y = (points[k][1] - points[k - 1][1]) / h[k - 1];
const d2x = (points[k + 1][0] - points[k][0]) / h[k];
const d2y = (points[k + 1][1] - points[k][1]) / h[k];
const dot = d1x * d2x + d1y * d2y;
const t = ((1 - dot) / 2) * CP_ANGLE_CORRECTION;
if (t < 1e-6) {
continue;
}
const bx = d1x + d2x;
const by = d1y + d2y;
const blen = Math.hypot(bx, by);
if (blen < 1e-10) {
continue;
}
let px = bx / blen;
let py = by / blen;
const tx = mx[k] / mlen[k];
const ty = my[k] / mlen[k];
if (tx * px + ty * py < 0) {
px = -px;
py = -py;
}
const blendX = tx + t * (px - tx);
const blendY = ty + t * (py - ty);
const blendLen = Math.max(1e-10, Math.hypot(blendX, blendY));
mx[k] = (blendX / blendLen) * mlen[k];
my[k] = (blendY / blendLen) * mlen[k];
}
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3; const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3;
const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist; const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist;
+4 -4
View File
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(364.8060248040008); expect(x1).toEqual(366.0476290709661);
expect(y1).toEqual(186.47330186064582); expect(y1).toEqual(186.59818534770224);
expect(x2).toEqual(494.2327553105778); expect(x2).toEqual(494.6034220048372);
expect(y2).toEqual(322.3237366886165); expect(y2).toEqual(324.16489799221546);
}); });
}); });
@@ -434,12 +434,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"42.76190", "51.36383",
"62.13334", "54.86323",
], ],
[ [
"87.23810", "81.64884",
"37.65714", "43.04575",
], ],
] ]
`); `);
@@ -482,7 +482,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect([line.x, line.y]).toEqual([ expect([line.x, line.y]).toEqual([
points[0][0] + deltaX, points[0][0] + deltaX,
@@ -499,12 +499,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"97.30521", "101.36383",
"77.27340", "74.86323",
], ],
[ [
"133.70877", "131.64884",
"60.46424", "63.04575",
], ],
] ]
`); `);
@@ -809,12 +809,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"5.35122", "22.32088",
"49.57854", "37.43003",
], ],
[ [
"87.32439", "81.55727",
"37.60536", "43.21091",
], ],
] ]
`); `);
@@ -898,12 +898,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"42.76190", "51.36383",
"62.13334", "54.86323",
], ],
[ [
"87.23810", "81.64884",
"37.65714", "43.04575",
], ],
] ]
`); `);
@@ -1065,8 +1065,8 @@ describe("Test Linear Elements", () => {
); );
expect(position).toMatchInlineSnapshot(` expect(position).toMatchInlineSnapshot(`
{ {
"x": 75, "x": "86.53100",
"y": 60, "y": "72.83556",
} }
`); `);
}); });
@@ -1181,12 +1181,12 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
"18.54397", 20,
"19.35821", 20,
105, 105,
80, 80,
"56.33510", "56.68277",
"47.11973", "47.27188",
] ]
`); `);
@@ -1196,7 +1196,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": "369.28398", "width": "368.53316",
} }
`); `);
@@ -1208,7 +1208,7 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"x": "274.28398", "x": "273.53316",
"y": 45, "y": 45,
} }
`); `);
@@ -1223,12 +1223,12 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
"19.96519", 20,
"9.07108", 35,
"504.28398", "503.53316",
"146.41791", "119.02540",
"204.79250", "204.47758",
"77.74450", "77.01270",
] ]
`); `);
}); });
+1 -2
View File
@@ -205,7 +205,6 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
return set.ops; return set.ops;
} }
} }
return shape.sets[0].ops; return shape.sets[0].ops;
}; };
@@ -326,7 +325,7 @@ export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
for (const operation of ops) { for (const operation of ops) {
if (operation.op === "move") { if (operation.op === "move") {
if (fillPathSet) { if (fillPathSet) {
// fillPath is always a single run no odd/even skipping needed // fillPath is always a single run, no odd/even skipping needed
points.push(pointFrom(operation.data[0], operation.data[1])); points.push(pointFrom(operation.data[0], operation.data[1]));
} else { } else {
odd = !odd; odd = !odd;