Equidistant binding highlight for diamonds

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs 2025-04-24 19:42:18 +02:00
parent e6b808e86f
commit 4d61040184
No known key found for this signature in database
4 changed files with 189 additions and 107 deletions

View file

@ -14,6 +14,13 @@ import {
pointDistance, pointDistance,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
bezierEquation,
curve,
curveTangent,
vectorNormalize,
vectorNormal,
vectorScale,
pointFromVector,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -476,11 +483,7 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
]; ];
}; };
export const getDiamondPoints = ( export const getDiamondPoints = (element: ExcalidrawElement) => {
element: ExcalidrawElement,
wPadding: number = 0,
hPadding: number = 0,
) => {
// Here we add +1 to avoid these numbers to be 0 // Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it // otherwise rough.js will throw an error complaining about it
const topX = Math.floor(element.width / 2) + 1; const topX = Math.floor(element.width / 2) + 1;
@ -492,16 +495,7 @@ export const getDiamondPoints = (
const leftX = 0; const leftX = 0;
const leftY = rightY; const leftY = rightY;
return [ return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
topX,
topY - hPadding,
rightX + wPadding,
rightY,
bottomX,
bottomY + hPadding,
leftX - wPadding,
leftY,
];
}; };
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes // reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
@ -1159,3 +1153,26 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
}; };
export function offsetBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}

View file

@ -51,6 +51,7 @@ import {
getCommonBounds, getCommonBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
offsetBezier,
} from "@excalidraw/element/bounds"; } from "@excalidraw/element/bounds";
import type { import type {
@ -198,10 +199,6 @@ const strokeDiamondWithRotation = (
padding: number, padding: number,
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
) => { ) => {
const { width, height } = element;
const side = Math.hypot(width, height);
const wPaddingMax = (1.8 * (padding * side)) / height;
const hPaddingMax = (1.8 * (padding * side)) / width;
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(element),
@ -215,56 +212,59 @@ const strokeDiamondWithRotation = (
context.beginPath(); context.beginPath();
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element, wPaddingMax, hPaddingMax); getDiamondPoints(element);
if (element.roundness) { if (element.roundness) {
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius( const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY), Math.abs(rightY - topY),
element, element,
); );
const topApprox = offsetBezier(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
padding,
);
const rightApprox = offsetBezier(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
padding,
);
const bottomApprox = offsetBezier(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
padding,
);
const leftApprox = offsetBezier(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
padding,
);
context.moveTo(topX + verticalRadius, topY + horizontalRadius); context.moveTo(
context.lineTo(rightX - verticalRadius, rightY - horizontalRadius); topApprox[topApprox.length - 1][0],
context.bezierCurveTo( topApprox[topApprox.length - 1][1],
rightX,
rightY,
rightX,
rightY,
rightX - verticalRadius,
rightY + horizontalRadius,
);
context.lineTo(bottomX + verticalRadius, bottomY - horizontalRadius);
context.bezierCurveTo(
bottomX,
bottomY,
bottomX,
bottomY,
bottomX - verticalRadius,
bottomY - horizontalRadius,
);
context.lineTo(leftX + verticalRadius, leftY + horizontalRadius);
context.bezierCurveTo(
leftX,
leftY,
leftX,
leftY,
leftX + verticalRadius,
leftY - horizontalRadius,
);
context.lineTo(topX - verticalRadius, topY + horizontalRadius);
context.bezierCurveTo(
topX,
topY,
topX,
topY,
topX + verticalRadius,
topY + horizontalRadius,
); );
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRom(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRom(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRom(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRom(context, topApprox);
} else { } else {
context.moveTo(topX, topY); context.moveTo(topX, topY - padding);
context.lineTo(rightX, rightY); context.lineTo(rightX + padding, rightY);
context.lineTo(bottomX, bottomY); context.lineTo(bottomX, bottomY + padding);
context.lineTo(leftX, leftY); context.lineTo(leftX - padding, leftY);
} }
} }
@ -273,62 +273,63 @@ const strokeDiamondWithRotation = (
// sharp inset edges on line joins < 90 degrees. // sharp inset edges on line joins < 90 degrees.
{ {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element, 5, 5); getDiamondPoints(element);
if (element.roundness) { if (element.roundness) {
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius( const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY), Math.abs(rightY - topY),
element, element,
); );
const topApprox = offsetBezier(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
-5,
);
const rightApprox = offsetBezier(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
-5,
);
const bottomApprox = offsetBezier(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
-5,
);
const leftApprox = offsetBezier(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
-5,
);
context.moveTo(topX - verticalRadius, topY + horizontalRadius); context.moveTo(
context.lineTo(leftX + verticalRadius, leftY - horizontalRadius); topApprox[topApprox.length - 1][0],
context.bezierCurveTo( topApprox[topApprox.length - 1][1],
leftX,
leftY,
leftX,
leftY,
leftX + verticalRadius,
leftY + horizontalRadius,
);
context.lineTo(bottomX - verticalRadius, bottomY - horizontalRadius);
context.bezierCurveTo(
bottomX,
bottomY,
bottomX,
bottomY,
bottomX + verticalRadius,
bottomY - horizontalRadius,
);
context.lineTo(rightX - verticalRadius, rightY + horizontalRadius);
context.bezierCurveTo(
rightX,
rightY,
rightX,
rightY,
rightX - verticalRadius,
rightY - horizontalRadius,
);
context.lineTo(topX + verticalRadius, topY + horizontalRadius);
context.bezierCurveTo(
topX,
topY,
topX,
topY,
topX - verticalRadius,
topY + horizontalRadius,
); );
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRom(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRom(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRom(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRom(context, topApprox);
} else { } else {
context.moveTo(topX, topY); context.moveTo(topX, topY - 5);
context.lineTo(leftX, leftY); context.lineTo(leftX + 5, leftY);
context.lineTo(bottomX, bottomY); context.lineTo(bottomX, bottomY + 5);
context.lineTo(rightX, rightY); context.lineTo(rightX - 5, rightY);
}
} }
context.closePath(); context.closePath();
context.fill(); context.fill();
}
context.restore(); context.restore();
}; };
@ -388,7 +389,8 @@ const renderBindingHighlightForBindableElement = (
maxBindingGap(element, element.width, element.height, zoom) - maxBindingGap(element, element.width, element.height, zoom) -
BINDING_HIGHLIGHT_OFFSET; BINDING_HIGHLIGHT_OFFSET;
// To ensure the binding highlight doesn't overlap the element itself // To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET; const padding = maxBindingGap(element, element.width, element.height, zoom);
//context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
const radius = getCornerRadius( const radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
@ -1354,3 +1356,39 @@ export const renderInteractiveScene = <
renderConfig.callback(ret); renderConfig.callback(ret);
return ret as T extends true ? void : ReturnType<U>; return ret as T extends true ? void : ReturnType<U>;
}; };
function drawCatmullRom(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
}
}
}

View file

@ -3,6 +3,8 @@ import type { Bounds } from "@excalidraw/element/bounds";
import { isPoint, pointDistance, pointFrom } from "./point"; import { isPoint, pointDistance, pointFrom } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
import { vector } from "./vector";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
/** /**
@ -82,7 +84,7 @@ function solve(
return [t0, s0]; return [t0, s0];
} }
const bezierEquation = <Point extends GlobalPoint | LocalPoint>( export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>, c: Curve<Point>,
t: number, t: number,
) => ) =>
@ -274,6 +276,26 @@ export function isCurve<P extends GlobalPoint | LocalPoint>(
); );
} }
export function curveTangent<Point extends GlobalPoint | LocalPoint>(
[p0, p1, p2, p3]: Curve<Point>,
t: number,
) {
return vector(
-3 * (1 - t) * (1 - t) * p0[0] +
3 * (1 - t) * (1 - t) * p1[0] -
6 * t * (1 - t) * p1[0] -
3 * t * t * p2[0] +
6 * t * (1 - t) * p2[0] +
3 * t * t * p3[0],
-3 * (1 - t) * (1 - t) * p0[1] +
3 * (1 - t) * (1 - t) * p1[1] -
6 * t * (1 - t) * p1[1] -
3 * t * t * p2[1] +
6 * t * (1 - t) * p2[1] +
3 * t * t * p3[1],
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>( function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>, c: Curve<Point>,
): Bounds { ): Bounds {

View file

@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => {
return vector(v[0] / m, v[1] / m); return vector(v[0] / m, v[1] / m);
}; };
/**
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);