UNPKG

frame.akima

Version:

A package for Akima interpolation

346 lines (345 loc) 15.4 kB
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; }