Last active
October 15, 2025 13:40
-
-
Save erikvullings/40ea3396fc011f9ea3a0c6ce3f043788 to your computer and use it in GitHub Desktop.
Bezier computation, including arrow head and (optional) perpendicular lines
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ========================================================= | |
| // TYPE DEFINITIONS | |
| // ========================================================= | |
| /** A simple 2D vector [x, y] */ | |
| export type Vector = number[]; | |
| /** Options required to calculate the end geometry. */ | |
| export interface EndGeometryOptions { | |
| /** Backward distance from the arrow tip to the center of the arrow base. */ | |
| arrowTipToBaseDistance: number; | |
| /** Total width of the arrow base. */ | |
| arrowBaseWidth: number; | |
| /** Total length of a full perpendicular line (L). */ | |
| lineLength: number; | |
| /** Fixed pixel distance from the arrow tip to the center of the first line. */ | |
| firstLineDistance: number; | |
| /** Fixed pixel distance between the centers of subsequent lines. */ | |
| lineFixedSeparation: number; | |
| /** Maximum number of lines that could potentially be drawn. */ | |
| maxLines: number; | |
| /** Fractional number of lines to draw (e.g., 3.6 means 3 full lines, 1 fractional). */ | |
| lineProgress: number; | |
| /** The t-value (0.0 to 1.0) used to sample the tangent for the arrow's orientation (e.g., 0.9). */ | |
| tangentSampleT: number; | |
| } | |
| /** The computed geometry result. */ | |
| export interface EndGeometryResult { | |
| /** The three vertices of the arrow head triangle: [Tip, BaseRight, BaseLeft]. */ | |
| arrowHead: Vector[]; | |
| /** An array of line segments, each being [Start, End]. */ | |
| perpendicularLines: Vector[][]; | |
| } | |
| /** The main interface returned by the bezier factory function. */ | |
| export interface BezierInterpolator { | |
| /** Evaluates the curve position at parameter t (0 to 1). */ | |
| b: (t: number) => Vector; | |
| /** Evaluates the curve tangent (derivative) at parameter t (0 to 1). */ | |
| db_dt: (t: number) => Vector; | |
| /** Calculates the complex geometry (arrow and lines) at the end of the curve. */ | |
| calculateEndGeometry: (options: EndGeometryOptions) => EndGeometryResult; | |
| } | |
| // ========================================================= | |
| // UTILITY FUNCTIONS (Vector Math) | |
| // ========================================================= | |
| function add(u: Vector, v: Vector): Vector { return u.map((x, i) => x + v[i]); } | |
| function sub(u: Vector, v: Vector): Vector { return u.map((x, i) => x - v[i]); } | |
| function lerpV(x: Vector, y: Vector, t: number): Vector { return x.map((v, i) => (1 - t) * v + t * y[i]); } | |
| function magnitude(v: Vector): number { return Math.sqrt(v[0] * v[0] + v[1] * v[1]); } | |
| function dist(u: Vector, v: Vector): number { return magnitude(sub(u, v)); } | |
| function normalize(v: Vector): Vector { | |
| const mag = magnitude(v); | |
| if (mag === 0) return [0, 0]; | |
| return [v[0] / mag, v[1] / mag]; | |
| } | |
| // ========================================================= | |
| // BEZIER CORE LOGIC | |
| // ========================================================= | |
| /** | |
| * Creates a Bézier interpolator and a geometry calculator tailored for complex end-of-curve graphics. | |
| * @param points The array of control points [P0, P1, P2, P3, ...]. | |
| * @returns An object containing the curve evaluation functions and the geometry calculator. | |
| */ | |
| export function createBezierGeometry(points: Vector[]): BezierInterpolator { | |
| if (points.length === 0) throw new Error('Control points array cannot be empty.'); | |
| const n = points.length - 1; | |
| // --- Core Curve Functions --- | |
| function _bezierIterative(p: Vector[], n: number, t: number): Vector { | |
| let currentPoints = p.slice(); | |
| for (let i = n; i > 0; i--) { | |
| let nextPoints: Vector[] = []; | |
| for (let j = 0; j < i; j++) { | |
| nextPoints.push(lerpV(currentPoints[j], currentPoints[j + 1], t)); | |
| } | |
| currentPoints = nextPoints; | |
| } | |
| return currentPoints[0]; | |
| } | |
| function _bezierPrimeIterative(p: Vector[], n: number, t: number): Vector { | |
| if (n === 0) return [0, 0]; | |
| const derivativePoints: Vector[] = p.slice(0, n).map((p_i, i) => { | |
| const diffVector = sub(p[i + 1], p_i); | |
| return diffVector.map(v => v * n); | |
| }); | |
| let currentPoints: Vector[] = derivativePoints.slice(); | |
| const nPrime = n - 1; | |
| for (let i = nPrime; i > 0; i--) { | |
| let nextPoints: Vector[] = []; | |
| for (let j = 0; j < i; j++) { | |
| nextPoints.push(lerpV(currentPoints[j], currentPoints[j + 1], t)); | |
| } | |
| currentPoints = nextPoints; | |
| } | |
| return currentPoints[0]; | |
| } | |
| const b = (t: number) => _bezierIterative(points, n, t); | |
| const db_dt = (t: number) => _bezierPrimeIterative(points, n, t); | |
| /** | |
| * Finds the t-value corresponding to a point at a fixed chord-length distance L | |
| * measured backward from t_end (t=1.0 for the curve end). | |
| */ | |
| function findTByChordLength(t_end: number, L: number): number { | |
| if (L <= 0 || t_end <= 0) return t_end; | |
| let current_t = t_end; | |
| let current_P = b(current_t); | |
| let distance = 0; | |
| const step = 0.001; // Fine sampling step for arc-length approximation | |
| while (current_t > 0) { | |
| current_t -= step; | |
| if (current_t < 0) current_t = 0; | |
| const next_P = b(current_t); | |
| const segment_length = dist(current_P, next_P); | |
| distance += segment_length; | |
| if (distance >= L) { | |
| // Interpolate back to find the exact t-value | |
| const overshoot = distance - L; | |
| const ratio = overshoot / segment_length; | |
| return current_t + (ratio * step); | |
| } | |
| current_P = next_P; | |
| if (current_t === 0) break; | |
| } | |
| return 0; | |
| } | |
| /** | |
| * Calculates the coordinates for the arrow and perpendicular lines based on the geometry options. | |
| */ | |
| function calculateEndGeometry(options: EndGeometryOptions): EndGeometryResult { | |
| // --- 1. ARROW HEAD CALCULATION --- | |
| const T_sample = db_dt(Math.min(options.tangentSampleT, 1.0)); | |
| const unitT_arrow = normalize(T_sample); | |
| const unitN_arrow = [-unitT_arrow[1], unitT_arrow[0]]; // Normal for the arrow base | |
| const P_tip = b(1.0); | |
| const P_base_center = [ | |
| P_tip[0] - unitT_arrow[0] * options.arrowTipToBaseDistance, | |
| P_tip[1] - unitT_arrow[1] * options.arrowTipToBaseDistance | |
| ]; | |
| const halfBase = options.arrowBaseWidth / 2; | |
| const arrowHead: Vector[] = [ | |
| P_tip, | |
| sub(P_base_center, unitN_arrow.map(v => v * halfBase)), // BaseRight | |
| add(P_base_center, unitN_arrow.map(v => v * halfBase)) // BaseLeft | |
| ]; | |
| // --- 2. PERPENDICULAR LINES CALCULATION --- | |
| const perpendicularLines: Vector[][] = []; | |
| const lineHalfLengthBase = options.lineLength / 2; | |
| for (let i = 0; i < options.maxLines; i++) { | |
| // Stop if we exceed the progress | |
| if (i >= options.lineProgress && i > Math.floor(options.lineProgress)) continue; | |
| // Calculate the total fixed pixel distance from P_tip to the current line center | |
| const totalDistance = options.firstLineDistance + (i * options.lineFixedSeparation); | |
| // Find the t-value for P_center | |
| const t_k = findTByChordLength(1.0, totalDistance); | |
| if (t_k <= 0) break; | |
| const P_center = b(t_k); | |
| // Calculate Tangent and Normal at P_center (Optimization: only calculate once) | |
| const T_k = db_dt(t_k); | |
| const unitN_k = [-normalize(T_k)[1], normalize(T_k)[0]]; | |
| // --- Asymmetric Line Length Logic --- | |
| let lenSide1 = lineHalfLengthBase; // Positive Normal side (Side 1) | |
| let lenSide2 = lineHalfLengthBase; // Negative Normal side (Side 2) | |
| if (i === Math.floor(options.lineProgress)) { | |
| const progressFraction = options.lineProgress - i; | |
| if (progressFraction <= 0.5) { | |
| // Only Side 1 is drawn proportionally | |
| lenSide1 = lineHalfLengthBase * (progressFraction / 0.5); | |
| lenSide2 = 0; | |
| } else { | |
| // Side 1 is full; Side 2 is drawn fractionally based on remaining progress | |
| lenSide1 = lineHalfLengthBase; | |
| const side2Fraction = (progressFraction - 0.5) / 0.5; | |
| lenSide2 = lineHalfLengthBase * side2Fraction; | |
| } | |
| } | |
| if (lenSide1 <= 0 && lenSide2 <= 0) continue; | |
| // Calculate Line Endpoints (Translation from P_center) | |
| const P_lineStart = add(P_center, unitN_k.map(v => v * lenSide1)); | |
| const P_lineEnd = sub(P_center, unitN_k.map(v => v * lenSide2)); | |
| perpendicularLines.push([P_lineStart, P_lineEnd]); | |
| } | |
| return { arrowHead, perpendicularLines }; | |
| } | |
| return { b, db_dt, calculateEndGeometry }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Mithril Bézier Curve: Final Asymmetric Geometry</title> | |
| <script src="https://unpkg.com/[email protected]/mithril.js"></script> | |
| <style> | |
| body { font-family: sans-serif; } | |
| .container { display: flex; flex-direction: column; align-items: center; } | |
| svg { border: 1px solid #ddd; background-color: #f9f9f9; cursor: grab; } | |
| .control-point { fill: #3498db; cursor: grab; } | |
| .control-line { stroke: #3498db; stroke-dasharray: 5, 5; } | |
| .curve-path { fill: none; stroke: black; stroke-width: 3; } | |
| .arrow-head { fill: #e74c3c; stroke: #c0392b; stroke-width: 1; } | |
| .perp-line { stroke: #2ecc71; stroke-width: 2; } | |
| .curve-end-point { fill: black; r: 3; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <script> | |
| // ========================================================= | |
| // 1. BEZIER GEOMETRY LOGIC (FINAL ASYMMETRIC FRACTIONAL LINES) | |
| // ========================================================= | |
| const bezierMath = (function() { | |
| // --- Utility Functions (Untouched for stability) --- | |
| function add(u, v) { return u.map((x, i) => x + v[i]) } | |
| function sub(u, v) { return u.map((x, i) => x - v[i]) } | |
| function lerpV(x, y, t) { return x.map((v, i) => (1 - t) * v + t * y[i]) } | |
| function magnitude(v) { return Math.sqrt(v[0] * v[0] + v[1] * v[1]) } | |
| function normalize(v) { | |
| const mag = magnitude(v) | |
| if (mag === 0) return [0, 0] | |
| return [v[0] / mag, v[1] / mag] | |
| } | |
| function dist(u, v) { return magnitude(sub(u, v)) } | |
| function _bezierIterative(points, n, t) { | |
| let currentPoints = points.slice() | |
| for (let i = n; i > 0; i--) { | |
| let nextPoints = [] | |
| for (let j = 0; j < i; j++) { | |
| nextPoints.push(lerpV(currentPoints[j], currentPoints[j+1], t)) | |
| } | |
| currentPoints = nextPoints | |
| } | |
| return currentPoints[0] | |
| } | |
| function _bezierPrimeIterative(points, n, t) { | |
| if (n === 0) return points[0].map(() => 0) | |
| const derivativePoints = [] | |
| for (let i = 0; i < n; i++) { | |
| const diffVector = sub(points[i + 1], points[i]) | |
| derivativePoints.push(diffVector.map(v => v * n)) | |
| } | |
| let currentPoints = derivativePoints.slice() | |
| const nPrime = n - 1 | |
| for (let i = nPrime; i > 0; i--) { | |
| let nextPoints = [] | |
| for (let j = 0; j < i; j++) { | |
| nextPoints.push(lerpV(currentPoints[j], currentPoints[j+1], t)) | |
| } | |
| currentPoints = nextPoints | |
| } | |
| return currentPoints[0] | |
| } | |
| function bezier(points) { | |
| if (points.length === 0) throw new Error('no point to interpolate') | |
| const n = points.length - 1 | |
| const b = (t) => _bezierIterative(points, n, t); | |
| const db_dt = (t) => _bezierPrimeIterative(points, n, t); | |
| function findTByChordLength(t_end, L) { | |
| if (L === 0 || t_end <= 0) return t_end; | |
| let current_t = t_end; | |
| let current_P = b(current_t); | |
| let distance = 0; | |
| const step = 0.001; | |
| while (current_t > 0) { | |
| current_t -= step; | |
| if (current_t < 0) current_t = 0; | |
| const next_P = b(current_t); | |
| const segment_length = dist(current_P, next_P); | |
| distance += segment_length; | |
| if (distance >= L) { | |
| const overshoot = distance - L; | |
| const ratio = overshoot / segment_length; | |
| return current_t + (ratio * step); | |
| } | |
| current_P = next_P; | |
| if (current_t === 0) break; | |
| } | |
| return 0; | |
| } | |
| function calculateEndGeometry(options) { | |
| // --- ARROW HEAD --- | |
| const T_sample = db_dt(Math.min(options.tangentSampleT, 1.0)); | |
| const unitT_arrow = normalize(T_sample); | |
| const unitN_arrow = [-unitT_arrow[1], unitT_arrow[0]]; | |
| const P_tip = b(1.0); | |
| const P_base_center = [ | |
| P_tip[0] - unitT_arrow[0] * options.arrowTipToBaseDistance, | |
| P_tip[1] - unitT_arrow[1] * options.arrowTipToBaseDistance | |
| ]; | |
| const halfBase = options.arrowBaseWidth / 2; | |
| const arrowHead = [ | |
| P_tip, | |
| [P_base_center[0] - unitN_arrow[0] * halfBase, P_base_center[1] - unitN_arrow[1] * halfBase], | |
| [P_base_center[0] + unitN_arrow[0] * halfBase, P_base_center[1] + unitN_arrow[1] * halfBase] | |
| ]; | |
| // --- PERPENDICULAR LINES --- | |
| const perpendicularLines = []; | |
| const lineHalfLengthBase = options.lineLength / 2; | |
| for (let i = 0; i < options.maxLines; i++) { | |
| // Stop if we exceed the progress (i.e., we are past the last fractional line) | |
| if (i >= options.lineProgress && i > Math.floor(options.lineProgress)) continue; | |
| // --- 1. Find P_center via Fixed Pixel Distance --- | |
| const totalDistance = options.firstLineDistance + (i * options.lineFixedSeparation); | |
| const t_k = findTByChordLength(1.0, totalDistance); | |
| if (t_k <= 0) break; | |
| const P_center = b(t_k); | |
| // --- 2. Calculate Tangent and Normal at P_center --- | |
| const T_k = db_dt(t_k); | |
| const unitN_k = [-normalize(T_k)[1], normalize(T_k)[0]]; | |
| // --- 3. Determine Asymmetric Lengths --- | |
| let lenSide1 = lineHalfLengthBase; // Positive Normal side (first half) | |
| let lenSide2 = lineHalfLengthBase; // Negative Normal side (second half) | |
| if (i === Math.floor(options.lineProgress)) { | |
| const progressFraction = options.lineProgress - i; // e.g., 0.6 | |
| if (progressFraction <= 0.5) { | |
| // Progress is only on Side 1 | |
| lenSide1 = lineHalfLengthBase * (progressFraction / 0.5); // 0.6/0.5 * 0.5 * L = 0.6 * L | |
| lenSide2 = 0; | |
| } else { | |
| // Progress covers Side 1 fully, and fractionally covers Side 2 | |
| lenSide1 = lineHalfLengthBase; // Full Side 1 | |
| const side2Fraction = (progressFraction - 0.5) / 0.5; // (0.6 - 0.5) / 0.5 = 0.2 | |
| lenSide2 = lineHalfLengthBase * side2Fraction; | |
| } | |
| } | |
| // Only draw if we have any length | |
| if (lenSide1 <= 0 && lenSide2 <= 0) continue; | |
| // --- 4. Calculate Line Endpoints --- | |
| // P_lineStart (Positive Normal side) | |
| const P_lineStart = [ | |
| P_center[0] + unitN_k[0] * lenSide1, | |
| P_center[1] + unitN_k[1] * lenSide1 | |
| ]; | |
| // P_lineEnd (Negative Normal side) | |
| const P_lineEnd = [ | |
| P_center[0] - unitN_k[0] * lenSide2, | |
| P_center[1] - unitN_k[1] * lenSide2 | |
| ]; | |
| perpendicularLines.push([P_lineStart, P_lineEnd]); | |
| } | |
| return { arrowHead, perpendicularLines }; | |
| } | |
| return { | |
| b: b, | |
| db_dt: db_dt, | |
| calculateEndGeometry: calculateEndGeometry | |
| } | |
| } | |
| return { bezier }; | |
| })(); | |
| // ========================================================= | |
| // 2. MITHRIL COMPONENT (UI) | |
| // ========================================================= | |
| const App = { | |
| // State | |
| points: [ | |
| [100, 100], | |
| [50, 400], | |
| [450, 50], | |
| [400, 400] | |
| ], | |
| // SVG Size | |
| width: 500, | |
| height: 500, | |
| // Geometry options | |
| geoOptions: { | |
| arrowTipToBaseDistance: 15, | |
| arrowBaseWidth: 25, | |
| lineLength: 20, | |
| firstLineDistance: 25, | |
| lineFixedSeparation: 30, | |
| maxLines: 5, | |
| lineProgress: 3.6, // Progress value is now split 0-0.5 for side 1, 0.5-1.0 for side 2 | |
| tangentSampleT: 0.9, | |
| }, | |
| // Dragging state | |
| draggingIndex: null, | |
| onmousedown: (index) => (e) => { | |
| e.redraw = false; | |
| App.draggingIndex = index; | |
| }, | |
| onmousemove: (e) => { | |
| if (App.draggingIndex !== null) { | |
| const svgRect = e.currentTarget.getBoundingClientRect(); | |
| let newX = e.clientX - svgRect.left; | |
| let newY = e.clientY - svgRect.top; | |
| newX = Math.max(0, Math.min(App.width, newX)); | |
| newY = Math.max(0, Math.min(App.height, newY)); | |
| App.points[App.draggingIndex] = [newX, newY]; | |
| m.redraw(); | |
| } | |
| }, | |
| onmouseup: () => { | |
| App.draggingIndex = null; | |
| m.redraw(); | |
| }, | |
| view: () => { | |
| const { bezier } = bezierMath; | |
| const curve = bezier(App.points); | |
| const pathData = `M ${App.points[0][0]},${App.points[0][1]} C ${App.points[1][0]},${App.points[1][1]}, ${App.points[2][0]},${App.points[2][1]}, ${App.points[3][0]},${App.points[3][1]}`; | |
| const geometry = curve.calculateEndGeometry(App.geoOptions); | |
| const P_end = App.points[App.points.length - 1]; | |
| return m('.container', [ | |
| m('h3', 'Final Optimized Bézier Geometry (Asymmetric Line Progress)'), | |
| m('p', `Curve End/Arrow Tip: (${P_end[0].toFixed(1)}, ${P_end[1].toFixed(1)})`), | |
| m('p', `Line Progress ${App.geoOptions.lineProgress}: ${Math.floor(App.geoOptions.lineProgress)} full lines, 1 partial line.`), | |
| m('svg', { | |
| width: App.width, | |
| height: App.height, | |
| onmousemove: App.onmousemove, | |
| onmouseup: App.onmouseup, | |
| onmouseleave: App.onmouseup, | |
| }, [ | |
| // 1. Control lines | |
| App.points.map((p, i) => i < App.points.length - 1 ? m('line', { | |
| class: 'control-line', | |
| x1: p[0], y1: p[1], | |
| x2: App.points[i + 1][0], y2: App.points[i + 1][1] | |
| }) : null), | |
| // 2. The main Bézier path | |
| m('path', { | |
| class: 'curve-path', | |
| d: pathData | |
| }), | |
| // 3. Perpendicular Lines (Green) | |
| geometry.perpendicularLines.map((line, i) => m('line', { | |
| class: 'perp-line', | |
| x1: line[0][0], y1: line[0][1], | |
| x2: line[1][0], y2: line[1][1], | |
| key: `line-${i}` | |
| })), | |
| // 4. Arrow Head (Red Polygon) | |
| m('polygon', { | |
| class: 'arrow-head', | |
| points: geometry.arrowHead.map(p => p.join(',')).join(' ') | |
| }), | |
| // 5. Visual dot at the curve end / arrow tip | |
| m('circle', { | |
| class: 'curve-end-point', | |
| cx: P_end[0], cy: P_end[1] | |
| }), | |
| // 6. Control Points (circles) | |
| App.points.map((p, i) => m('circle', { | |
| class: 'control-point', | |
| cx: p[0], cy: p[1], | |
| r: 8, | |
| key: i, | |
| onmousedown: App.onmousedown(i), | |
| style: { cursor: 'pointer' } | |
| })) | |
| ]), | |
| m('h4', 'Geometry Options:'), | |
| m('div', { style: { display: 'flex', flexWrap: 'wrap', maxWidth: '500px' } }, [ | |
| // Input controls for geometry options | |
| Object.keys(App.geoOptions).map(key => { | |
| const isT = key.includes('T'); | |
| const isProgress = key.includes('Progress'); | |
| const isMaxLines = key.includes('maxLines'); | |
| const maxVal = isT ? 1.0 : (isMaxLines ? 10 : 100); | |
| const minVal = isT ? 0.01 : (isMaxLines ? 0 : 0); | |
| const stepVal = isT || isProgress ? 0.01 : 1; | |
| return m('label', { style: { marginRight: '20px', marginBottom: '10px' } }, [ | |
| `${key}: `, | |
| m('input[type=range]', { | |
| min: minVal, | |
| max: maxVal, | |
| step: stepVal, | |
| value: App.geoOptions[key], | |
| oninput: (e) => { | |
| App.geoOptions[key] = parseFloat(e.target.value); | |
| } | |
| }), | |
| m('span', { style: { marginLeft: '5px' } }, App.geoOptions[key].toFixed(isT || isProgress ? 2 : 0)) | |
| ]) | |
| }) | |
| ]) | |
| ]); | |
| } | |
| }; | |
| m.mount(document.getElementById('app'), App); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment