Merge branch 'master' into mtolmacs/feat/prettier-arrow-curves

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-04 16:01:28 +00:00
112 changed files with 5469 additions and 1149 deletions
-227
View File
@@ -13,7 +13,6 @@ import {
import {
pointFrom,
pointDistance,
lineSegment,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
@@ -1024,9 +1023,6 @@ const _generateElementShape = (
generateRoughOptions(element, true, isDarkMode),
),
];
if (window.visualDebug?.data) {
debugRoundedArrowControlPoints(element.x, element.y, points);
}
}
// add lines only in arrow
@@ -1110,229 +1106,6 @@ const _generateElementShape = (
}
};
/**
* Debug helper to visualise C2 spline control points.
*
* Chords are grey, CP1 handles/circles are green, CP2 handles/diamonds are blue,
* segment points are red X markers.
*/
const debugRoundedArrowControlPoints = (
elementX: number,
elementY: number,
points: readonly LocalPoint[],
) => {
const nPts = points.length;
if (nPts < 2) {
return;
}
const g = (lx: number, ly: number): GlobalPoint =>
pointFrom<GlobalPoint>(elementX + lx, elementY + ly);
const PERMANENT = { permanent: true } as const;
const CP_RADIUS = 5;
const DIAMOND_RADIUS = 6;
// // Segment points: red X
// for (let i = 0; i < nPts; i++) {
// debugDrawPoint(g(points[i][0], points[i][1]), {
// color: "#ff3333",
// ...PERMANENT,
// });
// }
if (nPts === 2) {
debugDrawLine(
lineSegment(g(points[0][0], points[0][1]), g(points[1][0], points[1][1])),
{ color: "#888888", ...PERMANENT },
);
return;
}
// Chord-length C2 spline same algorithm as generateRoundedSimpleArrowShape
const n = nPts - 1;
const h = new Float64Array(n);
for (let i = 0; i < n; i++) {
h[i] = Math.max(
1e-10,
Math.hypot(
points[i + 1][0] - points[i][0],
points[i + 1][1] - points[i][1],
),
);
}
const mx = new Float64Array(n + 1);
const my = new Float64Array(n + 1);
const diag = new Float64Array(n + 1);
const rhsX = new Float64Array(n + 1);
const rhsY = new Float64Array(n + 1);
diag[0] = 2;
rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0];
rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0];
for (let i = 1; i < n; i++) {
diag[i] = 2 * (h[i - 1] + h[i]);
rhsX[i] =
3 *
((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] +
(h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]);
rhsY[i] =
3 *
((h[i] * (points[i][1] - points[i - 1][1])) / h[i - 1] +
(h[i - 1] * (points[i + 1][1] - points[i][1])) / h[i]);
}
diag[n] = 2;
rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1];
rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1];
for (let i = 1; i <= n; i++) {
const sub = i < n ? h[i] : 1;
const supPrev = i === 1 ? 1 : h[i - 2];
const w = sub / diag[i - 1];
diag[i] -= w * supPrev;
rhsX[i] -= w * rhsX[i - 1];
rhsY[i] -= w * rhsY[i - 1];
}
mx[n] = rhsX[n] / diag[n];
my[n] = rhsY[n] / diag[n];
for (let i = n - 1; i >= 0; i--) {
const sup = i === 0 ? 1 : h[i - 1];
mx[i] = (rhsX[i] - sup * mx[i + 1]) / diag[i];
my[i] = (rhsY[i] - sup * my[i + 1]) / diag[i];
}
// Normalised tangent directions; handle length scales sub-linearly with chord.
const mlen = new Float64Array(n + 1);
for (let i = 0; i <= n; i++) {
mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i]));
}
// Mirror the angle-correction from generateRoundedSimpleArrowShape.
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;
}
// Blend target: the bisector direction itself (pick sign aligning with current tangent)
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];
}
// Bisector at interior knots: orange line along bisector, yellow tick for
// perpendicular-to-bisector (the ideal symmetric tangent direction).
const BISECTOR_HALF_LEN = 20;
const PERP_HALF_LEN = 12;
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 bx = d1x + d2x;
const by = d1y + d2y;
const blen = Math.hypot(bx, by);
if (blen < 1e-10) {
continue;
}
const bnx = bx / blen;
const bny = by / blen;
const pnx = -bny; // perpendicular to bisector = ideal tangent direction
const pny = bnx;
const pk = g(points[k][0], points[k][1]);
// bisector (orange)
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(
pk[0] - bnx * BISECTOR_HALF_LEN,
pk[1] - bny * BISECTOR_HALF_LEN,
),
pointFrom<GlobalPoint>(
pk[0] + bnx * BISECTOR_HALF_LEN,
pk[1] + bny * BISECTOR_HALF_LEN,
),
),
{ color: "#ff8800", ...PERMANENT },
);
// perpendicular tick / ideal tangent (yellow)
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(
pk[0] - pnx * PERP_HALF_LEN,
pk[1] - pny * PERP_HALF_LEN,
),
pointFrom<GlobalPoint>(
pk[0] + pnx * PERP_HALF_LEN,
pk[1] + pny * PERP_HALF_LEN,
),
),
{ color: "#ffdd00", ...PERMANENT },
);
}
for (let i = 0; i < n; i++) {
const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3;
const p0 = g(points[i][0], points[i][1]);
const p1 = g(points[i + 1][0], points[i + 1][1]);
const cp1 = g(
points[i][0] + (mx[i] / mlen[i]) * cpDist,
points[i][1] + (my[i] / mlen[i]) * cpDist,
);
const cp2 = g(
points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist,
points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist,
);
// chord (grey)
debugDrawLine(lineSegment(p0, p1), { color: "#888888", ...PERMANENT });
// CP1 handle + circle (green = outgoing from p0)
debugDrawLine(lineSegment(p0, cp1), { color: "#00cc44", ...PERMANENT });
debugDrawPolygon(
Array.from({ length: 9 }, (_, k) =>
pointFrom<GlobalPoint>(
cp1[0] + Math.cos((k * Math.PI) / 4) * CP_RADIUS,
cp1[1] + Math.sin((k * Math.PI) / 4) * CP_RADIUS,
),
),
{ color: "#00cc44", close: true, ...PERMANENT },
);
// CP2 handle + diamond (blue = incoming to p1)
debugDrawLine(lineSegment(p1, cp2), { color: "#0088ff", ...PERMANENT });
debugDrawPolygon(
[
pointFrom<GlobalPoint>(cp2[0], cp2[1] - DIAMOND_RADIUS),
pointFrom<GlobalPoint>(cp2[0] + DIAMOND_RADIUS, cp2[1]),
pointFrom<GlobalPoint>(cp2[0], cp2[1] + DIAMOND_RADIUS),
pointFrom<GlobalPoint>(cp2[0] - DIAMOND_RADIUS, cp2[1]),
],
{ color: "#0088ff", close: true, ...PERMANENT },
);
}
};
const generateRoundedSimpleArrowShape = (
points: readonly LocalPoint[],
): string => {