fix: Reimplement with C2/G2 continuity

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-03-24 19:26:58 +00:00
parent 03d46aa62f
commit eb6ab3f5b0
2 changed files with 259 additions and 293 deletions
+234 -268
View File
@@ -80,11 +80,12 @@ import type {
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE = 0.25; // At sharp corners, scale tangent handle lengths up by this fraction of the
// Lerp weight for interior-point tangents: 1 = pure bisector (smooth, no twist possible),
// 0 = chord-aligned (flat). Values between dampen the angle at midpoints without flipping. // Controls how handle distance scales with chord length.
const SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE = 0.9; // At 1.0 handles are exactly h/3 (standard Hermite). Values below 1 make
const SIMPLE_ROUNDED_ARROW_CP_LENGTH_RATIO = 1 / 3; // short segments curvier and long segments more taut (sub-linear scaling).
const CP_CHORD_POWER = 1;
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
@@ -659,90 +660,72 @@ export const generateLinearCollisionShape = (
data: rotateLocal(points[1][0], points[1][1]), data: rotateLocal(points[1][0], points[1][1]),
}); });
} else { } else {
const n = points.length; // Chord-length C2 spline mirrors generateRoundedSimpleArrowShape exactly
const ptxn = new Float64Array(n); // so hit-testing matches rendering.
const ptyn = new Float64Array(n); const n = points.length - 1;
const h = new Float64Array(n);
for (let i = 1; i < n - 1; i++) { for (let i = 0; i < n; i++) {
const inDx = points[i][0] - points[i - 1][0]; h[i] = Math.max(
const inDy = points[i][1] - points[i - 1][1]; 1e-10,
const inLen = Math.hypot(inDx, inDy); Math.hypot(
const inUx = inDx / inLen; points[i + 1][0] - points[i][0],
const inUy = inDy / inLen; points[i + 1][1] - points[i][1],
const outDx = points[i + 1][0] - points[i][0]; ),
const outDy = points[i + 1][1] - points[i][1]; );
const outLen = Math.hypot(outDx, outDy);
const outUx = outDx / outLen;
const outUy = outDy / outLen;
const bisDx = inUx + outUx;
const bisDy = inUy + outUy;
const bisLen = Math.hypot(bisDx, bisDy);
let bisUx: number;
let bisUy: number;
if (bisLen > 1e-8) {
bisUx = bisDx / bisLen;
bisUy = bisDy / bisLen;
} else {
bisUx = -inUy;
bisUy = inUx;
}
const dotMid = bisUx * outUx + bisUy * outUy;
const mx =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUx +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUx;
const my =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUy +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUy;
const mLen = Math.hypot(mx, my);
ptxn[i] = mx / mLen;
ptyn[i] = my / mLen;
} }
// Endpoints: reflect the adjacent interior tangent across the const mx = new Float64Array(n + 1);
// endpoint's chord with specific dampening const my = new Float64Array(n + 1);
{ const diag = new Float64Array(n + 1);
const cx = points[1][0] - points[0][0]; const rhsX = new Float64Array(n + 1);
const cy = points[1][1] - points[0][1]; const rhsY = new Float64Array(n + 1);
const cLen = Math.hypot(cx, cy);
const cux = cx / cLen; diag[0] = 2;
const cuy = cy / cLen; rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0];
const dot = ptxn[1] * cux + ptyn[1] * cuy; rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0];
const rx = for (let i = 1; i < n; i++) {
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux - diag[i] = 2 * (h[i - 1] + h[i]);
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * ptxn[1]; rhsX[i] =
const ry = 3 *
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy - ((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] +
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * ptyn[1]; (h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]);
const rLen = Math.hypot(rx, ry); rhsY[i] =
ptxn[0] = rx / rLen; 3 *
ptyn[0] = ry / rLen; ((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;
const cx = points[n - 1][0] - points[n - 2][0]; rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1];
const cy = points[n - 1][1] - points[n - 2][1]; rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1];
const cLen = Math.hypot(cx, cy);
const cux = cx / cLen; for (let i = 1; i <= n; i++) {
const cuy = cy / cLen; const sub = i < n ? h[i] : 1;
const dot = ptxn[n - 2] * cux + ptyn[n - 2] * cuy; const supPrev = i === 1 ? 1 : h[i - 2];
const rx = const w = sub / diag[i - 1];
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux - diag[i] -= w * supPrev;
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * ptxn[n - 2]; rhsX[i] -= w * rhsX[i - 1];
const ry = rhsY[i] -= w * rhsY[i - 1];
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy - }
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * ptyn[n - 2]; mx[n] = rhsX[n] / diag[n];
const rLen = Math.hypot(rx, ry); my[n] = rhsY[n] / diag[n];
ptxn[n - 1] = rx / rLen; for (let i = n - 1; i >= 0; i--) {
ptyn[n - 1] = ry / rLen; 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];
} }
for (let i = 0; i < n - 1; i++) { // Normalised tangent directions; handle length scales sub-linearly with chord.
const d = const mlen = new Float64Array(n + 1);
pointDistance(points[i], points[i + 1]) * for (let i = 0; i <= n; i++) {
SIMPLE_ROUNDED_ARROW_CP_LENGTH_RATIO; mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i]));
const cp1x = points[i][0] + ptxn[i] * d; }
const cp1y = points[i][1] + ptyn[i] * d;
const cp2x = points[i + 1][0] - ptxn[i + 1] * d; for (let i = 0; i < n; i++) {
const cp2y = points[i + 1][1] - ptyn[i + 1] * d; const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3;
const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist;
const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist;
const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist;
const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist;
const rcp1 = rotateLocal(cp1x, cp1y); const rcp1 = rotateLocal(cp1x, cp1y);
const rcp2 = rotateLocal(cp2x, cp2y); const rcp2 = rotateLocal(cp2x, cp2y);
@@ -1089,7 +1072,7 @@ const _generateElementShape = (
}; };
/** /**
* Debug helper to visualise chord and control points. * Debug helper to visualise C2 spline control points.
* *
* Chords are grey, CP1 handles/circles are green, CP2 handles/diamonds are blue, * Chords are grey, CP1 handles/circles are green, CP2 handles/diamonds are blue,
* segment points are red X markers. * segment points are red X markers.
@@ -1099,8 +1082,8 @@ const debugRoundedArrowControlPoints = (
elementY: number, elementY: number,
points: readonly LocalPoint[], points: readonly LocalPoint[],
) => { ) => {
const n = points.length; const nPts = points.length;
if (n < 2) { if (nPts < 2) {
return; return;
} }
@@ -1111,105 +1094,99 @@ const debugRoundedArrowControlPoints = (
const CP_RADIUS = 5; const CP_RADIUS = 5;
const DIAMOND_RADIUS = 6; const DIAMOND_RADIUS = 6;
const txn = new Float64Array(n); // Segment points: red X
const tyn = new Float64Array(n); for (let i = 0; i < nPts; i++) {
debugDrawPoint(g(points[i][0], points[i][1]), {
for (let i = 1; i < n - 1; i++) { color: "#ff3333",
const inDx = points[i][0] - points[i - 1][0]; ...PERMANENT,
const inDy = points[i][1] - points[i - 1][1]; });
const inLen = Math.hypot(inDx, inDy);
const inUx = inDx / inLen;
const inUy = inDy / inLen;
const outDx = points[i + 1][0] - points[i][0];
const outDy = points[i + 1][1] - points[i][1];
const outLen = Math.hypot(outDx, outDy);
const outUx = outDx / outLen;
const outUy = outDy / outLen;
const bisDx = inUx + outUx;
const bisDy = inUy + outUy;
const bisLen = Math.hypot(bisDx, bisDy);
let bisUx: number;
let bisUy: number;
if (bisLen > 1e-8) {
bisUx = bisDx / bisLen;
bisUy = bisDy / bisLen;
} else {
bisUx = -inUy;
bisUy = inUx;
}
const dotMid = bisUx * outUx + bisUy * outUy;
const mx =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUx +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUx;
const my =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUy +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUy;
const mLen = Math.hypot(mx, my);
txn[i] = mx / mLen;
tyn[i] = my / mLen;
} }
// Endpoints: reflect the adjacent interior tangent across the endpoint's own chord. if (nPts === 2) {
{ debugDrawLine(
const cx = points[1][0] - points[0][0]; lineSegment(g(points[0][0], points[0][1]), g(points[1][0], points[1][1])),
const cy = points[1][1] - points[0][1]; { color: "#888888", ...PERMANENT },
const cLen = Math.hypot(cx, cy); );
const cux = cx / cLen; return;
const cuy = cy / cLen;
const dot = txn[1] * cux + tyn[1] * cuy;
const rx =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * txn[1];
const ry =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * tyn[1];
const rLen = Math.hypot(rx, ry);
txn[0] = rx / rLen;
tyn[0] = ry / rLen;
}
{
const cx = points[n - 1][0] - points[n - 2][0];
const cy = points[n - 1][1] - points[n - 2][1];
const cLen = Math.hypot(cx, cy);
const cux = cx / cLen;
const cuy = cy / cLen;
const dot = txn[n - 2] * cux + tyn[n - 2] * cuy;
const rx =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * txn[n - 2];
const ry =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * tyn[n - 2];
const rLen = Math.hypot(rx, ry);
txn[n - 1] = rx / rLen;
tyn[n - 1] = ry / rLen;
} }
for (let i = 0; i < n - 1; i++) { // Chord-length C2 spline same algorithm as generateRoundedSimpleArrowShape
const d = const n = nPts - 1;
const h = new Float64Array(n);
for (let i = 0; i < n; i++) {
h[i] = Math.max(
1e-10,
Math.hypot( Math.hypot(
points[i + 1][0] - points[i][0], points[i + 1][0] - points[i][0],
points[i + 1][1] - points[i][1], points[i + 1][1] - points[i][1],
) * SIMPLE_ROUNDED_ARROW_CP_LENGTH_RATIO; ),
);
}
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]));
}
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 p0 = g(points[i][0], points[i][1]);
const p1 = g(points[i + 1][0], points[i + 1][1]); const p1 = g(points[i + 1][0], points[i + 1][1]);
const cp1 = g(points[i][0] + txn[i] * d, points[i][1] + tyn[i] * d); const cp1 = g(
points[i][0] + (mx[i] / mlen[i]) * cpDist,
points[i][1] + (my[i] / mlen[i]) * cpDist,
);
const cp2 = g( const cp2 = g(
points[i + 1][0] - txn[i + 1] * d, points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist,
points[i + 1][1] - tyn[i + 1] * d, points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist,
); );
// chord (grey) // chord (grey)
debugDrawLine(lineSegment(p0, p1), { color: "#888888", ...PERMANENT }); debugDrawLine(lineSegment(p0, p1), { color: "#888888", ...PERMANENT });
// CP1 handle + circle (green = outgoing from p0) // CP1 handle + circle (green = outgoing from p0)
debugDrawLine(lineSegment(p0, cp1), { debugDrawLine(lineSegment(p0, cp1), { color: "#00cc44", ...PERMANENT });
color: "#00cc44",
...PERMANENT,
});
debugDrawPolygon( debugDrawPolygon(
Array.from({ length: 9 }, (_, k) => Array.from({ length: 9 }, (_, k) =>
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
@@ -1232,14 +1209,6 @@ const debugRoundedArrowControlPoints = (
{ color: "#0088ff", close: true, ...PERMANENT }, { color: "#0088ff", close: true, ...PERMANENT },
); );
} }
// Segment points: red X
for (let i = 0; i < n; i++) {
debugDrawPoint(g(points[i][0], points[i][1]), {
color: "#ff3333",
...PERMANENT,
});
}
}; };
const generateRoundedSimpleArrowShape = ( const generateRoundedSimpleArrowShape = (
@@ -1253,99 +1222,96 @@ const generateRoundedSimpleArrowShape = (
return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`; return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`;
} }
const n = points.length; // Chord-length parameterised C2 natural cubic spline (Thomas's algorithm).
const txn = new Float64Array(n); //
const tyn = new Float64Array(n); // Unknowns: tangent vectors m[0..n] at each knot (n = number of segments).
// Chord lengths h[i] = |K[i+1] K[i]| act as the parameter intervals so
for (let i = 1; i < n - 1; i++) { // that tightly-spaced knots don't over-influence distant ones.
const inDx = points[i][0] - points[i - 1][0]; //
const inDy = points[i][1] - points[i - 1][1]; // Row 0: 2·m₀ + m₁ = 3·(K₁−K₀)/h₀
const inLen = Math.hypot(inDx, inDy); // Row i: h[i]·mᵢ₋₁ + 2·(h[i1]+h[i])·mᵢ + h[i1]·mᵢ₊₁
const inUx = inDx / inLen; // = 3·(h[i]·(Kᵢ−Kᵢ₋₁)/h[i1]
const inUy = inDy / inLen; // + h[i1]·(Kᵢ₊₁−Kᵢ)/h[i]) 1≤i≤n1
// Row n: mₙ₋₁ + 2·mₙ = 3·(Kₙ−Kₙ₋₁)/h[n1]
const outDx = points[i + 1][0] - points[i][0]; //
const outDy = points[i + 1][1] - points[i][1]; // Bézier control points from Hermite→Bézier identity:
const outLen = Math.hypot(outDx, outDy); // cp1ᵢ = Kᵢ + mᵢ · h[i] / 3
const outUx = outDx / outLen; // cp2ᵢ = Kᵢ₊₁ mᵢ₊₁ · h[i] / 3
const outUy = outDy / outLen; const n = points.length - 1; // number of segments
const h = new Float64Array(n);
// Bisector: average of the two incident unit chord vectors for (let i = 0; i < n; i++) {
const bisDx = inUx + outUx; h[i] = Math.max(
const bisDy = inUy + outUy; 1e-10,
const bisLen = Math.hypot(bisDx, bisDy);
let bisUx: number;
let bisUy: number;
if (bisLen > 1e-8) {
bisUx = bisDx / bisLen;
bisUy = bisDy / bisLen;
} else {
// 180° hairpin -> rotate incoming chord 90°
bisUx = -inUy;
bisUy = inUx;
}
const dotMid = bisUx * outUx + bisUy * outUy;
const mx =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUx +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUx;
const my =
(1 - SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE) * dotMid * outUy +
SIMPLE_ROUNDED_ARROW_MIDPOINT_ANGLE_SCALE * bisUy;
const mLen = Math.hypot(mx, my);
txn[i] = mx / mLen;
tyn[i] = my / mLen;
}
// Endpoints: reflect the adjacent interior tangent across the endpoint's own chord.
// This mirrors the angle the interior CP makes with the chord, preventing overshoot.
// ENDPOINT_ANGLE_SCALE < 1 reduces the perpendicular deviation, making endpoints more taut.
{
const cx = points[1][0] - points[0][0];
const cy = points[1][1] - points[0][1];
const cLen = Math.hypot(cx, cy);
const cux = cx / cLen;
const cuy = cy / cLen;
const dot = txn[1] * cux + tyn[1] * cuy;
const rx =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * txn[1];
const ry =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * tyn[1];
const rLen = Math.hypot(rx, ry);
txn[0] = rx / rLen;
tyn[0] = ry / rLen;
}
{
const cx = points[n - 1][0] - points[n - 2][0];
const cy = points[n - 1][1] - points[n - 2][1];
const cLen = Math.hypot(cx, cy);
const cux = cx / cLen;
const cuy = cy / cLen;
const dot = txn[n - 2] * cux + tyn[n - 2] * cuy;
const rx =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cux -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * txn[n - 2];
const ry =
(1 + SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE) * dot * cuy -
SIMPLE_ROUNDED_ARROW_ENDPOINT_ANGLE_SCALE * tyn[n - 2];
const rLen = Math.hypot(rx, ry);
txn[n - 1] = rx / rLen;
tyn[n - 1] = ry / rLen;
}
const path: string[] = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < n - 1; i++) {
const d =
Math.hypot( Math.hypot(
points[i + 1][0] - points[i][0], points[i + 1][0] - points[i][0],
points[i + 1][1] - points[i][1], points[i + 1][1] - points[i][1],
) * SIMPLE_ROUNDED_ARROW_CP_LENGTH_RATIO; ),
const cp1x = points[i][0] + txn[i] * d; );
const cp1y = points[i][1] + tyn[i] * d; }
const cp2x = points[i + 1][0] - txn[i + 1] * d;
const cp2y = points[i + 1][1] - tyn[i + 1] * d; 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);
// Row 0 natural BC (zero second derivative at start)
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];
// Interior rows
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]);
}
// Row n natural BC (zero second derivative at end)
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];
// Forward sweep
// sub[i] = h[i] for i=1..n1, sub[n] = 1
// sup[i] = 1 for i=0, h[i1] for i=1..n1 (never modified)
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];
}
// Back substitution
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]));
}
const path: string[] = [`M ${points[0][0]} ${points[0][1]}`];
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;
const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist;
const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist;
const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist;
path.push( path.push(
`C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1][0]} ${ `C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1][0]} ${
points[i + 1][1] points[i + 1][1]
@@ -434,12 +434,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"47.30521", "42.76190",
"57.27340", "62.13334",
], ],
[ [
"83.70877", "87.23810",
"40.46424", "37.65714",
], ],
] ]
`); `);
@@ -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(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect([line.x, line.y]).toEqual([ expect([line.x, line.y]).toEqual([
points[0][0] + deltaX, points[0][0] + deltaX,
@@ -809,12 +809,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"13.73276", "5.35122",
"41.73533", "49.57854",
], ],
[ [
"83.95050", "87.32439",
"40.24690", "37.60536",
], ],
] ]
`); `);
@@ -898,12 +898,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"47.30521", "42.76190",
"57.27340", "62.13334",
], ],
[ [
"83.70877", "87.23810",
"40.46424", "37.65714",
], ],
] ]
`); `);
@@ -1181,12 +1181,12 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
"19.99875", "18.54397",
20, "19.35821",
105, 105,
80, 80,
"56.25357", "56.33510",
"46.47665", "47.11973",
] ]
`); `);
@@ -1196,7 +1196,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": "367.68709", "width": "369.28398",
} }
`); `);
@@ -1208,7 +1208,7 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"x": "272.68709", "x": "274.28398",
"y": 45, "y": 45,
} }
`); `);
@@ -1223,12 +1223,12 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
[ [
20, "19.96519",
"18.77567", "9.07108",
"502.68709", "504.28398",
"123.53753", "146.41791",
"203.94165", "204.79250",
"71.15660", "77.74450",
] ]
`); `);
}); });