From 3e69b33a28119c292f815ca088c21591c5b051cd Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 24 Mar 2026 19:18:04 +0000 Subject: [PATCH] fix: Hit tests Signed-off-by: Mark Tolmacs --- packages/element/src/bounds.ts | 5 +- packages/element/src/linearElementEditor.ts | 3 - packages/element/src/shape.ts | 42 +++++++++++-- packages/element/tests/bounds.test.ts | 8 +-- .../tests/linearElementEditor.test.tsx | 62 +++++++++---------- packages/utils/src/shape.ts | 3 +- 6 files changed, 76 insertions(+), 47 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a125a9dbca..36c07e13d9 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -893,9 +893,8 @@ export const getArrowheadPoints = ( ); if (arrowhead === "diamond" || arrowhead === "diamond_outline") { - // point opposite to the arrowhead point — use the same Bezier tangent - // direction (nx, ny) as the wings so the diamond isn't distorted on - // rounded arrows where the tangent differs from the chord direction. + // point opposite to the arrowhead point, just mirrored across the (tx, ty) + // point const ox = tx - nx * minSize * 2; const oy = ty - ny * minSize * 2; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index f6f9a088a5..2e7c2f6091 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -790,9 +790,6 @@ export class LinearElementEditor { 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 segmentCount = lines.length + segCurves.length; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 144620804b..a464f4e340 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -44,7 +44,7 @@ import type { } from "@excalidraw/excalidraw/scene/types"; import { elementWithCanvasCache } from "./renderElement"; -import { debugDrawLine, debugDrawPoint, debugDrawPolygon } from "./visualdebug"; +import { debugDrawLine, debugDrawPolygon } from "./visualdebug"; import { canBecomePolygon, @@ -90,7 +90,7 @@ const CP_CHORD_POWER = 1; // This factor [0, 1] controls how far the tangent direction is pulled toward // the bisector (the chord-bisector normal) linearly with turn sharpness. // 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 { private static rg = new RoughGenerator(); @@ -665,8 +665,8 @@ export const generateLinearCollisionShape = ( data: rotateLocal(points[1][0], points[1][1]), }); } else { - // Chord-length C2 spline – mirrors generateRoundedSimpleArrowShape exactly - // so hit-testing matches rendering. + // Chord-length C2 spline. Mirrors generateRoundedSimpleArrowShape + // exactly so hit-testing matches rendering. const n = points.length - 1; const h = new Float64Array(n); 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])); } + // 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++) { const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3; const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist; diff --git a/packages/element/tests/bounds.test.ts b/packages/element/tests/bounds.test.ts index 4249040f85..1ac4ab51ee 100644 --- a/packages/element/tests/bounds.test.ts +++ b/packages/element/tests/bounds.test.ts @@ -135,9 +135,9 @@ describe("getElementBounds", () => { } as ExcalidrawLinearElement; const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); - expect(x1).toEqual(364.8060248040008); - expect(y1).toEqual(186.47330186064582); - expect(x2).toEqual(494.2327553105778); - expect(y2).toEqual(322.3237366886165); + expect(x1).toEqual(366.0476290709661); + expect(y1).toEqual(186.59818534770224); + expect(x2).toEqual(494.6034220048372); + expect(y2).toEqual(324.16489799221546); }); }); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index f4153c1acc..1d308dbf21 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -434,12 +434,12 @@ describe("Test Linear Elements", () => { expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` [ [ - "42.76190", - "62.13334", + "51.36383", + "54.86323", ], [ - "87.23810", - "37.65714", + "81.64884", + "43.04575", ], ] `); @@ -482,7 +482,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, @@ -499,12 +499,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "97.30521", - "77.27340", + "101.36383", + "74.86323", ], [ - "133.70877", - "60.46424", + "131.64884", + "63.04575", ], ] `); @@ -809,12 +809,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "5.35122", - "49.57854", + "22.32088", + "37.43003", ], [ - "87.32439", - "37.60536", + "81.55727", + "43.21091", ], ] `); @@ -898,12 +898,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "42.76190", - "62.13334", + "51.36383", + "54.86323", ], [ - "87.23810", - "37.65714", + "81.64884", + "43.04575", ], ] `); @@ -1065,8 +1065,8 @@ describe("Test Linear Elements", () => { ); expect(position).toMatchInlineSnapshot(` { - "x": 75, - "y": 60, + "x": "86.53100", + "y": "72.83556", } `); }); @@ -1181,12 +1181,12 @@ describe("Test Linear Elements", () => { ), ).toMatchInlineSnapshot(` [ - "18.54397", - "19.35821", + 20, + 20, 105, 80, - "56.33510", - "47.11973", + "56.68277", + "47.27188", ] `); @@ -1196,7 +1196,7 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` { "height": 130, - "width": "369.28398", + "width": "368.53316", } `); @@ -1208,7 +1208,7 @@ describe("Test Linear Elements", () => { ), ).toMatchInlineSnapshot(` { - "x": "274.28398", + "x": "273.53316", "y": 45, } `); @@ -1223,12 +1223,12 @@ describe("Test Linear Elements", () => { ), ).toMatchInlineSnapshot(` [ - "19.96519", - "9.07108", - "504.28398", - "146.41791", - "204.79250", - "77.74450", + 20, + 35, + "503.53316", + "119.02540", + "204.47758", + "77.01270", ] `); }); diff --git a/packages/utils/src/shape.ts b/packages/utils/src/shape.ts index c207174d71..62cfd65ca1 100644 --- a/packages/utils/src/shape.ts +++ b/packages/utils/src/shape.ts @@ -205,7 +205,6 @@ export const getCurvePathOps = (shape: Drawable): Op[] => { return set.ops; } } - return shape.sets[0].ops; }; @@ -326,7 +325,7 @@ export const getClosedCurveShape = ( for (const operation of ops) { if (operation.op === "move") { 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])); } else { odd = !odd;