frame.akima
Version:
A package for Akima interpolation
346 lines (345 loc) • 15.4 kB
JavaScript
import { AkimaPoint } from './AkimaPoint';
export class CatmullRom {
points;
splineFunc;
polarSplineFunc;
constructor(points) {
this.points = points;
this.splineFunc = this.createSplineFunction(points);
this.polarSplineFunc = this.createPolarCatmullRom();
}
/**
* Core Catmull-Rom interpolation between four control points
*/
catmullRomInterpolate(p0, p1, p2, p3, t) {
const t2 = t * t;
const t3 = t2 * t;
// Catmull-Rom basis functions
const x = 0.5 *
(2 * p1.X +
(-p0.X + p2.X) * t +
(2 * p0.X - 5 * p1.X + 4 * p2.X - p3.X) * t2 +
(-p0.X + 3 * p1.X - 3 * p2.X + p3.X) * t3);
const y = 0.5 *
(2 * p1.Y +
(-p0.Y + p2.Y) * t +
(2 * p0.Y - 5 * p1.Y + 4 * p2.Y - p3.Y) * t2 +
(-p0.Y + 3 * p1.Y - 3 * p2.Y + p3.Y) * t3);
return new AkimaPoint(x, y);
}
/**
* Calculate centroid of points
*/
getCentroid() {
const sum = this.points.reduce((acc, point) => ({
x: acc.x + point.X,
y: acc.y + point.Y,
}), { x: 0, y: 0 });
return new AkimaPoint(sum.x / this.points.length, sum.y / this.points.length);
}
/**
* Creates a Catmull-Rom spline interpolation function for a given set of points.
*
* The returned function takes a parameter `t` in the range [0, 1) and returns
* the interpolated `Point` on the closed spline curve. The spline wraps around,
* forming a closed loop through all provided points.
*
* @param points - An array of `Point` objects representing the control points of the spline.
* Must contain at least 3 points.
* @returns A function that takes a parameter `t` (number in [0, 1)) and returns the interpolated `Point`.
* @throws Error if fewer than 3 points are provided.
*/
createSplineFunction(points) {
if (points.length < 3) {
throw new Error('At least 3 points required for Catmull-Rom spline');
}
// For closed curve, extend the array with wraparound points
const extendedPoints = [
points[points.length - 1], // p-1
...points, // p0, p1, ..., pn-1
points[0], // pn
points[1], // pn+1
];
return (t) => {
// Normalize t to [0, 1) and map to segment
const normalizedT = ((t % 1) + 1) % 1; // Handle negative t
const segmentCount = points.length;
const segmentFloat = normalizedT * segmentCount;
const segmentIndex = Math.floor(segmentFloat);
const localT = segmentFloat - segmentIndex;
// Get the four control points for this segment
const p0 = extendedPoints[segmentIndex]; // Previous point
const p1 = extendedPoints[segmentIndex + 1]; // Start point
const p2 = extendedPoints[segmentIndex + 2]; // End point
const p3 = extendedPoints[segmentIndex + 3]; // Next point
return this.catmullRomInterpolate(p0, p1, p2, p3, localT);
};
}
/**
* Creates a polar Catmull-Rom spline interpolation function based on the current set of points.
*
* The function sorts the points by their angle around the centroid, finds the point closest to the positive x-axis,
* and reorders the points so that this point is first. It then constructs a Catmull-Rom spline in polar coordinates.
*
* @throws {Error} If fewer than 3 points are available to construct the spline.
* @returns A function that takes an angle (in radians) and returns the interpolated {@link AkimaPoint} on the spline at that angle.
*/
createPolarCatmullRom() {
if (this.points.length < 3) {
throw new Error('At least 3 points required for Catmull-Rom spline');
}
// Calculate center point
const center = this.getCentroid();
// Sort points by angle around center
const sortedPoints = [...this.points].sort((a, b) => {
const angleA = Math.atan2(a.Y - center.Y, a.X - center.X);
const angleB = Math.atan2(b.Y - center.Y, b.X - center.X);
return angleA - angleB;
});
// Find the point closest to angle 0 (positive x-axis, y=0)
let closestIndex = 0;
let minAngleDiff = Math.PI * 2;
// console.log(`center: [${center.X}, ${center.Y}]`);
for (let i = 0; i < sortedPoints.length; i++) {
const pointAngle = Math.atan2(sortedPoints[i].Y - center.Y, sortedPoints[i].X - center.X);
const normalizedAngle = ((pointAngle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
const angleDiff = Math.abs(normalizedAngle);
// console.log(
// `[${i}] = [${Math.round(sortedPoints[i].X * 1000) / 1000}, ${
// Math.round(sortedPoints[i].Y * 1000) / 1000
// }] pointAngle: ${pointAngle}, normalizedAngle: ${normalizedAngle}, angleDiff: ${angleDiff}`
// );
if (angleDiff < minAngleDiff) {
minAngleDiff = angleDiff;
closestIndex = i;
}
}
// console.log(`closestIndex: ${closestIndex}`);
// Reorder the points so that the point closest to angle 0 comes first
const reorderedPoints = [
...sortedPoints.slice(closestIndex),
...sortedPoints.slice(0, closestIndex),
];
// console.log(
// `reorderedPoints: ${JSON.stringify(
// reorderedPoints.slice(0, 10),
// null,
// 2
// )}`
// );
// Create the spline function
const splineFunc = this.createSplineFunction(reorderedPoints);
return (angle) => {
// Normalize angle to [0, 2π]
const normalizedAngle = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
// Map angle to parameter t [0, 1]
const t = normalizedAngle / (2 * Math.PI);
// console.log(
// ` Polar(${angle}) -> normalizedAngle: ${normalizedAngle}; t: ${t}`
// );
// Get interpolated point
const point = splineFunc(t);
// Calculate radius from origin (0,0), not from centroid
return new AkimaPoint(point.X, point.Y);
};
}
at(t, decimalPlaces = 3) {
const pt = this.splineFunc(t);
const roundedPt = new AkimaPoint(Math.round(pt.X * 10 ** decimalPlaces) / 10 ** decimalPlaces, Math.round(pt.Y * 10 ** decimalPlaces) / 10 ** decimalPlaces);
return roundedPt;
}
/**
* Evaluates the polar spline function at the given parameter `t` and returns the corresponding point.
*
* @param t - The parameter value at which to evaluate the polar spline function.
* @returns The point on the polar spline corresponding to the parameter `t`. t should be in the range [0, 2 * Math.PI)
*/
atPolar(t) {
return this.polarSplineFunc(t);
}
/**
* Finds the corresponding point on the curve for a given angle in radians.
*
* This method converts the provided angle to a normalized unit value using `findRadian`,
* then retrieves the corresponding point on the curve using `angleSpline`.
*
* @param angle - The angle in radians for which to find the corresponding point.
* @returns The point on the curve corresponding to the given angle [0, Math.PI * 2].
*/
findPointforUnit(angle) {
const polarUnit = this.findRadian(angle); // e.g. this.findRadian(0.5 * Math.PI) calculates the unit value for angle 90°
// console.log(`polarUnit: ${polarUnit}`);
const { point } = this.angleSpline(polarUnit);
return point;
}
angleSpline(polar) {
if (polar < 0) {
throw new Error('Angle must be non-negative');
}
if (polar > 2 * Math.PI) {
throw new Error('Angle must be less than or equal to 2π');
}
const point = this.polarSplineFunc(polar);
const radian = getRadian(point);
// console.log(
// ` angleSpline(${polar}): [${point.x}, ${point.y}] -> radian: ${radian} -> ${point.radius}`
// );
return { point, radian };
}
/**
* Recursively finds the angle (in radians) for which the provided polar spline function
* produces a point whose `radian` property is closest to the specified target `radian`.
* Uses a binary search approach within the interval [`from`, `to`] (defaulting to [0, 2π]).
*
* @param polSpline - A function that takes an angle (in radians) and returns a `Point` object
* with an additional `radius` property.
* @param angle - The target radian value to search for.
* @param from - The lower bound of the search interval (inclusive). Defaults to 0.
* @param to - The upper bound of the search interval (inclusive). Defaults to 2 * Math.PI.
* @returns The angle (in radians) within [`from`, `to`] for which the spline's `radian` property
* is closest to the target `radian`, within a tolerance of 0.001.
*/
findRadian(angle, from = 0, to = 2 * Math.PI) {
// console.log(`findRadian(${radian}): [${from}, ${to}]`);
if (to - from < 0.000001) {
return from;
}
const bisectValue = (from + to) / 2;
const newPoint = this.angleSpline(bisectValue);
const delta = Math.abs(newPoint.radian - angle);
// console.log(` -> newPoint.radian: ${newPoint.radian} ---> ${delta}`);
if (delta < 0.000001) {
// console.log(`found it -> ${newAngle}`);
return bisectValue;
}
if (newPoint.radian < angle) {
// console.log(`${newPoint.radian} < ${radian} going lower`);
return this.findRadian(angle, bisectValue, to);
}
else {
// console.log(`${newPoint.radian} > ${radian} going higher`);
return this.findRadian(angle, from, bisectValue);
}
}
findPolar(angle, from = 0, to = 2 * Math.PI) {
console.log(`findRadian(${angle}): [${from}, ${to}]`);
if (to - from < 0.0001) {
console.log(`diff too small -> ${from}`);
return from;
}
const bisectValue = (from + to) / 2;
// const newPoint = this.angleSpline(bisectValue);
const newPoint = this.polarSplineFunc(bisectValue);
const atan = newPoint.relAtan(new AkimaPoint(0, 0));
// const delta = Math.abs(
// atan - angle > 6 ? Math.PI * 2 - (atan - angle) : atan - angle
// );
const delta = Math.abs(atan - angle);
console.log(` -> newPoint at ${Math.round(bisectValue * 1000) / 1000} = [${newPoint.X}, ${newPoint.Y}].atan: ${atan} ---> delta: ${delta}`);
if (delta < 0.0001) {
console.log(`found it -> ${bisectValue}`);
return bisectValue;
}
if (atan < angle) {
console.log(`${atan} < ${angle} going lower (${Math.round(bisectValue * 1000) / 1000}, ${Math.round(to * 1000) / 1000})`);
return this.findPolar(angle, bisectValue, to);
}
else {
console.log(`${atan} > ${angle} going higher (${Math.round(from * 1000) / 1000}, ${Math.round(bisectValue * 1000) / 1000})`);
return this.findPolar(angle, from, bisectValue);
}
}
/**
* Finds the point on the spline where x > 0 and y = 0 (intersection with positive x-axis)
* Uses binary search to find the point where y is closest to 0 and x is positive
* @param tolerance The tolerance for y-value (default: 0.0001)
* @returns The point where the spline intersects the positive x-axis, or null if no such point exists
*/
findPositiveXAxisIntersection(tolerance = 0.0001) {
const numSamples = 1000;
let bestPoint = null;
let minYDistance = Infinity;
let bestT = 0;
// First pass: find approximate location where y is closest to 0 and x > 0
for (let i = 0; i < numSamples; i++) {
const t = i / numSamples;
const point = this.splineFunc(t);
if (point.X > 0) {
const yDistance = Math.abs(point.Y);
if (yDistance < minYDistance) {
minYDistance = yDistance;
bestPoint = point;
bestT = t;
}
}
}
if (!bestPoint) {
return null; // No point found with x > 0
}
// If we're already within tolerance, return the point
if (minYDistance <= tolerance) {
console.log(`minYDistance <= tolerance`);
// return bestPoint;
return bestT;
}
// Second pass: use binary search to refine the solution around bestT
const searchRange = 1 / numSamples;
console.log(`bestT: ${bestT}, bestPoint: [${bestPoint.X}, ${bestPoint.Y}], searchRange: ${searchRange}`);
let tMin = Math.max(0, bestT - searchRange);
let tMax = Math.min(1, bestT + searchRange);
// Binary search for more precise y = 0 intersection
for (let iter = 0; iter < 50; iter++) {
const tMid = (tMin + tMax) / 2;
const pointMid = this.splineFunc(tMid);
console.log(`tMid: ${tMid} --> [${pointMid.X}, ${pointMid.Y}]`);
if (Math.abs(pointMid.Y) <= tolerance && pointMid.X > 0) {
// return pointMid;
return tMid;
}
const pointMin = this.splineFunc(tMin);
const pointMax = this.splineFunc(tMax);
// Choose the side that gets us closer to y = 0
if (Math.abs(pointMin.Y) < Math.abs(pointMax.Y)) {
tMax = tMid;
}
else {
tMin = tMid;
}
// Prevent infinite loop
if (Math.abs(tMax - tMin) < 0.000001) {
break;
}
}
// Return the best approximation found
const finalPoint = this.splineFunc((tMin + tMax) / 2);
// return finalPoint.X > 0 ? finalPoint : null;
return finalPoint.X > 0 ? (tMin + tMax) / 2 : null;
}
}
// ----------------------------- end of class -----------------------------
/**
* Create evenly spaced points along the spline
* @param splineFunc The spline function
* @param numPoints Number of points to generate
* @returns Array of interpolated points
*/
export function sampleSpline(catmull, numPoints) {
const points = [];
for (let i = 0; i < numPoints; i++) {
const t = i / numPoints;
points.push(catmull.at(t));
}
return points;
}
/**
* Calculates the angle in radians between the positive x-axis and the given point (x, y).
* The result is always in the range [0, 2π).
*
* @param point - The point for which to calculate the angle, with `x` and `y` coordinates.
* @returns The angle in radians in the range [0, 2π).
*/
export function getRadian(point) {
const PI2 = Math.PI * 2;
const atan2 = Math.atan2(point.Y, point.X);
return atan2 < 0 ? atan2 + PI2 : atan2;
}