From 4d6104018450c1a94a399cc752248e6f1dcb5bb7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Apr 2025 19:42:18 +0200 Subject: [PATCH] Equidistant binding highlight for diamonds Signed-off-by: Mark Tolmacs --- packages/element/src/bounds.ts | 47 ++-- .../excalidraw/renderer/interactiveScene.ts | 220 ++++++++++-------- packages/math/src/curve.ts | 24 +- packages/math/src/vector.ts | 5 + 4 files changed, 189 insertions(+), 107 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index d1b8daca84..4b27843aca 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -14,6 +14,13 @@ import { pointDistance, pointFromArray, pointRotateRads, + bezierEquation, + curve, + curveTangent, + vectorNormalize, + vectorNormal, + vectorScale, + pointFromVector, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -476,11 +483,7 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => { ]; }; -export const getDiamondPoints = ( - element: ExcalidrawElement, - wPadding: number = 0, - hPadding: number = 0, -) => { +export const getDiamondPoints = (element: ExcalidrawElement) => { // Here we add +1 to avoid these numbers to be 0 // otherwise rough.js will throw an error complaining about it const topX = Math.floor(element.width / 2) + 1; @@ -492,16 +495,7 @@ export const getDiamondPoints = ( const leftX = 0; const leftY = rightY; - return [ - topX, - topY - hPadding, - rightX + wPadding, - rightY, - bottomX, - bottomY + hPadding, - leftX - wPadding, - leftY, - ]; + return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; }; // 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; }; + +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; +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 266e52643f..0c98fda21a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -51,6 +51,7 @@ import { getCommonBounds, getDiamondPoints, getElementAbsoluteCoords, + offsetBezier, } from "@excalidraw/element/bounds"; import type { @@ -198,10 +199,6 @@ const strokeDiamondWithRotation = ( padding: number, 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( pointFrom(element.x, element.y), elementCenterPoint(element), @@ -215,56 +212,59 @@ const strokeDiamondWithRotation = ( context.beginPath(); const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element, wPaddingMax, hPaddingMax); + getDiamondPoints(element); if (element.roundness) { const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); const horizontalRadius = getCornerRadius( Math.abs(rightY - topY), 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.lineTo(rightX - verticalRadius, rightY - horizontalRadius); - context.bezierCurveTo( - 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.moveTo( + topApprox[topApprox.length - 1][0], + topApprox[topApprox.length - 1][1], ); + 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 { - context.moveTo(topX, topY); - context.lineTo(rightX, rightY); - context.lineTo(bottomX, bottomY); - context.lineTo(leftX, leftY); + context.moveTo(topX, topY - padding); + context.lineTo(rightX + padding, rightY); + context.lineTo(bottomX, bottomY + padding); + context.lineTo(leftX - padding, leftY); } } @@ -273,62 +273,63 @@ const strokeDiamondWithRotation = ( // sharp inset edges on line joins < 90 degrees. { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element, 5, 5); + getDiamondPoints(element); if (element.roundness) { const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); const horizontalRadius = getCornerRadius( Math.abs(rightY - topY), 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.lineTo(leftX + verticalRadius, leftY - horizontalRadius); - context.bezierCurveTo( - 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.moveTo( + topApprox[topApprox.length - 1][0], + topApprox[topApprox.length - 1][1], ); + 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 { - context.moveTo(topX, topY); - context.lineTo(leftX, leftY); - context.lineTo(bottomX, bottomY); - context.lineTo(rightX, rightY); + context.moveTo(topX, topY - 5); + context.lineTo(leftX + 5, leftY); + context.lineTo(bottomX, bottomY + 5); + context.lineTo(rightX - 5, rightY); } - - context.closePath(); - context.fill(); } - + context.closePath(); + context.fill(); context.restore(); }; @@ -388,7 +389,8 @@ const renderBindingHighlightForBindableElement = ( maxBindingGap(element, element.width, element.height, zoom) - BINDING_HIGHLIGHT_OFFSET; // 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( Math.min(element.width, element.height), @@ -1354,3 +1356,39 @@ export const renderInteractiveScene = < renderConfig.callback(ret); return ret as T extends true ? void : ReturnType; }; + +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); + } + } +} diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index a79fb43a19..5404619c74 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -3,6 +3,8 @@ import type { Bounds } from "@excalidraw/element/bounds"; import { isPoint, pointDistance, pointFrom } from "./point"; import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; +import { vector } from "./vector"; + import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; /** @@ -82,7 +84,7 @@ function solve( return [t0, s0]; } -const bezierEquation = ( +export const bezierEquation = ( c: Curve, t: number, ) => @@ -274,6 +276,26 @@ export function isCurve

( ); } +export function curveTangent( + [p0, p1, p2, p3]: Curve, + 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( c: Curve, ): Bounds { diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index 2467220674..12682fcd9f 100644 --- a/packages/math/src/vector.ts +++ b/packages/math/src/vector.ts @@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => { 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]);