Created
August 3, 2026 07:20
-
-
Save raphlinus/8327967f0d1bdb15fd7aff345e72b0c0 to your computer and use it in GitHub Desktop.
AI-assisted draft of hyperbezier parameter mapping
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> | |
| <meta charset="utf-8"> | |
| <title>hyperbezier tester — checkpoint one-constant map</title> | |
| <style> | |
| body { font-family: system-ui, sans-serif; margin: 12px; background: #fafafa; } | |
| #wrap { display: flex; gap: 14px; } | |
| canvas { background: #fff; border: 1px solid #ccc; cursor: crosshair; touch-action: none; } | |
| #panel { width: 310px; font-size: 13px; } | |
| #status { white-space: pre; font-family: ui-monospace, monospace; font-size: 12px; | |
| background: #f0f0f0; padding: 8px; border-radius: 4px; } | |
| label { display: block; margin: 3px 0; } | |
| button { margin: 2px; } | |
| h3 { margin: 8px 0 4px 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="wrap"> | |
| <canvas id="cv"></canvas> | |
| <div id="panel"> | |
| <h3>hyperbezier — (q0,q1) representation</h3> | |
| <div>CHECKPOINT map + endpoint fade: q0 = u^2.2, q1 = v^2.2 (one constant, derived | |
| then shape-refined), qm from the normalization (1-D monotone solve), | |
| angles by the winding-pinned scalar solve. Angle-independent, so | |
| u=v=1 is an exact Euler spiral at every angle. Stateless. | |
| Grey ghost = cubic.</div> | |
| <h3>presets</h3> | |
| <button onclick="preset('circle')">circle 45</button> | |
| <button onclick="preset('e60')">Euler 60</button> | |
| <button onclick="preset('e90')">Euler 90</button> | |
| <button onclick="preset('e120')">Euler 120</button> | |
| <button onclick="preset('e135')">Euler 135</button> | |
| <button onclick="preset('s')">S</button> | |
| <button onclick="preset('corner')">near corner</button> | |
| <button onclick="preset('past')">past corner 2.4×</button> | |
| <h3>options</h3> | |
| <label>constant K = <span id="kval">2.20</span><br> | |
| <input type="range" id="kslider" min="2" max="3" step="0.005" value="2.2" | |
| style="width: 260px"></label> | |
| <label>corner steepening C = <span id="ckval">1.00</span><br> | |
| <input type="range" id="ckslider" min="0" max="12" step="0.25" value="1" | |
| style="width: 260px"></label> | |
| <label><input type="checkbox" id="showCubic" checked> cubic ghost</label> | |
| <label><input type="checkbox" id="showComb" checked> curvature comb</label> | |
| <h3>status</h3> | |
| <div id="status"></div> | |
| </div> | |
| </div> | |
| <script> | |
| "use strict"; | |
| /* | |
| * Hyperbezier tester. | |
| * | |
| * A hyperbezier is the curve traced with unit speed and tangent angle | |
| * theta(s), where curvature is a rational function of arc length s: | |
| * | |
| * kappa(s) = (a*s + b) / q(s)^(3/2), q(s) = c*s^2 + d*s + 1 | |
| * | |
| * on s in [0,1] (then scaled to its chord). q is the denominator | |
| * quadratic: q constant gives circles and Euler spirals exactly; q | |
| * dipping toward 0 gives a corner. The raw form has five constants (two | |
| * linear, three quadratic), but scaling (q, a, b) -> (t*q, t^1.5*a, | |
| * t^1.5*b) leaves the curve unchanged -- a one-parameter gauge freedom, | |
| * so only four are effective. Setting q(0) = 1 above is one gauge | |
| * choice; solveMap uses another. theta(s) = integral of kappa has a | |
| * closed form; the position integral of e^(i*theta) does not | |
| * (Fresnel-like), so it is the one thing done numerically. | |
| * | |
| * The tester maps a cubic-Bezier-style control polygon -- endpoint | |
| * tangent angles th0, th1 and arm lengths l0, l1, in the chord frame -- | |
| * to (a, b, c, d), and draws the result over the ghost cubic. | |
| */ | |
| /* Standard Gauss-Legendre nodes/weights (Newton on Legendre polynomials). */ | |
| /* ---------- Gauss-Legendre n=32 ---------- */ | |
| function gaussLegendre(n) { | |
| const x = new Float64Array(n), w = new Float64Array(n); | |
| for (let i = 0; i < n; i++) { | |
| let t = Math.cos(Math.PI*(i + 0.75)/(n + 0.5)); | |
| for (let it = 0; it < 100; it++) { | |
| let p0 = 1, p1 = t; | |
| for (let k = 2; k <= n; k++) { const p2 = ((2*k-1)*t*p1 - (k-1)*p0)/k; p0 = p1; p1 = p2; } | |
| const dp = n*(t*p1 - p0)/(t*t - 1), dt = p1/dp; | |
| t -= dt; if (Math.abs(dt) < 1e-15) break; | |
| } | |
| let p0 = 1, p1 = t; | |
| for (let k = 2; k <= n; k++) { const p2 = ((2*k-1)*t*p1 - (k-1)*p0)/k; p0 = p1; p1 = p2; } | |
| const dp = n*(t*p1 - p0)/(t*t - 1); | |
| x[i] = t; w[i] = 2/((1 - t*t)*dp*dp); | |
| } | |
| return { x, w }; | |
| } | |
| const GL = gaussLegendre(32); | |
| const wrapA = a => { const T = 2*Math.PI; return ((a + Math.PI) % T + T) % T - Math.PI; }; | |
| const cAbs = z => Math.hypot(z[0], z[1]); | |
| /* ---------- theta / integration machinery ---------- */ | |
| /* | |
| * Closed form of theta(t) = integral_0^t (a*s+b)/q^(3/2) ds, with q(0)=1: | |
| * theta = (A*t + B)/sqrt(q) + const. The second branch is a rearrangement | |
| * that avoids catastrophic cancellation when d^2 ~ 4c, i.e. the denominator | |
| * quadratic is a near-perfect square -- which includes the Euler/circle case q == 1. | |
| */ | |
| function makeTheta(a, b, c, d) { | |
| if (Math.abs(c) < 1e-12 && Math.abs(d) < 1e-12) return t => b*t + 0.5*a*t*t; | |
| const disc = d*d - 4*c, scale = Math.max(1, Math.abs(d*d), Math.abs(4*c)); | |
| if (Math.abs(disc) > 0.02*scale) { | |
| const B = (4*a - 2*b*d)/disc, A = b + 0.5*B*d; | |
| return t => { const q = (c*t + d)*t + 1; return (A*t + B)/Math.sqrt(q) - B; }; | |
| } | |
| return t => { | |
| const q = (c*t + d)*t + 1, sq = Math.sqrt(q), h = 2*c*t + d; | |
| return (t/sq)*(b + t*(4*a - 2*b*d)*(1 + (h*h + 4*c)/(d*h + 4*c*sq))/(4*(sq + 1)*(sq + 1))); | |
| }; | |
| } | |
| /* Roots of kappa'(t): a quadratic. Used to place integration cuts. */ | |
| function kappaExtrema(a, b, c, d) { | |
| const qa = -4*a*c, qb = -(a*d + 6*b*c), qc = 2*a - 3*b*d; | |
| let roots = []; | |
| if (Math.abs(qa) < 1e-14) { if (Math.abs(qb) > 1e-14) roots = [-qc/qb]; } | |
| else { | |
| const disc = qb*qb - 4*qa*qc; | |
| if (disc >= 0) { const s = Math.sqrt(disc); roots = [(-qb - s)/(2*qa), (-qb + s)/(2*qa)]; } | |
| } | |
| return roots.filter(r => r > 0 && r < 1).sort((p, q) => p - q); | |
| } | |
| /* | |
| * Cut list for piecewise integration: curvature extrema, plus a geometric | |
| * ladder of cuts around a deep minimum of q (near-corner), where the | |
| * q^(-3/2) factor makes theta vary on a scale ~ w0 = sqrt(q_min/c). | |
| */ | |
| function cutPoints(a, b, c, d, t) { | |
| const cuts = new Set(kappaExtrema(a, b, c, d).filter(w => w < t)); | |
| if (c > 1e-12) { | |
| const tv = -0.5*d/c; | |
| if (tv > 0 && tv < t) { | |
| const qv = (c*tv + d)*tv + 1; | |
| if (qv < 0.05) { | |
| const w0 = Math.sqrt(Math.max(qv, 1e-300)/c); | |
| cuts.add(tv); | |
| for (let k = 3; k*w0 < 0.5; k *= 3) { | |
| if (tv - k*w0 > 0 && tv - k*w0 < t) cuts.add(tv - k*w0); | |
| if (tv + k*w0 > 0 && tv + k*w0 < t) cuts.add(tv + k*w0); | |
| } | |
| } | |
| } | |
| } | |
| return [...cuts].sort((p, q) => p - q); | |
| } | |
| /* | |
| * Closure under subdivision: the piece on [t0,t1], reparameterized to | |
| * [0,1] and rescaled to the q(0) = 1 gauge choice, is again a hyperbezier. | |
| * Exact coefficient map; no approximation. | |
| */ | |
| function subseg(a, b, c, d, t0, t1) { | |
| const dt = t1 - t0, e = (c*t0 + d)*t0 + 1, s = 1/e, ps = dt*s*Math.sqrt(s); | |
| return [a*dt*ps, (b + a*t0)*ps, c*dt*dt*s, (d + 2*c*t0)*dt*s]; | |
| } | |
| /* | |
| * Position integral z(t) = integral_0^t e^(i*theta) ds. GL-32 per cut | |
| * interval; intervals not starting at 0 are re-expressed via subseg so | |
| * each gets a fresh, well-conditioned chart. | |
| */ | |
| function integrate(a, b, c, d, t, theta) { | |
| theta = theta || makeTheta(a, b, c, d); | |
| const pts = [0, ...cutPoints(a, b, c, d, t), t]; | |
| let zx = 0, zy = 0; | |
| for (let i = 0; i + 1 < pts.length; i++) { | |
| const w0 = pts[i], w1 = pts[i + 1]; | |
| if (w1 - w0 < 1e-15) continue; | |
| if (w0 === 0) { | |
| let sx = 0, sy = 0; | |
| for (let k = 0; k < 32; k++) { | |
| const u = 0.5*w1*(GL.x[k] + 1), th = theta(u); | |
| sx += GL.w[k]*Math.cos(th); sy += GL.w[k]*Math.sin(th); | |
| } | |
| zx += 0.5*w1*sx; zy += 0.5*w1*sy; | |
| } else { | |
| const sp = subseg(a, b, c, d, w0, w1), thS = makeTheta(...sp), off = theta(w0); | |
| let sx = 0, sy = 0; | |
| for (let k = 0; k < 32; k++) { | |
| const u = 0.5*(GL.x[k] + 1), th = off + thS(u); | |
| sx += GL.w[k]*Math.cos(th); sy += GL.w[k]*Math.sin(th); | |
| } | |
| zx += (w1 - w0)*0.5*sx; zy += (w1 - w0)*0.5*sy; | |
| } | |
| } | |
| return [zx, zy]; | |
| } | |
| /* | |
| * Dense polyline of the curve normalized to endpoints (0,0)-(1,0): | |
| * incremental midpoint quadrature on a uniform grid plus clusters near | |
| * the cuts, then one similarity transform dividing by the exact endpoint | |
| * integral I1 (so endpoints land exactly). | |
| */ | |
| function sampleCurve(a, b, c, d, N) { | |
| const theta = makeTheta(a, b, c, d); | |
| let ts = []; | |
| for (let i = 0; i <= 256; i++) ts.push(i/256); | |
| for (const w of cutPoints(a, b, c, d, 1)) | |
| for (let k = -20; k <= 20; k++) { const t = w + k*1e-4; if (t > 0 && t < 1) ts.push(t); } | |
| ts = [...new Set(ts)].sort((p, q) => p - q); | |
| const zs = [[0, 0]]; | |
| for (let i = 1; i < ts.length; i++) { | |
| const t0 = ts[i-1], t1 = ts[i], th = theta(0.5*(t0 + t1)); | |
| zs.push([zs[i-1][0] + (t1 - t0)*Math.cos(th), zs[i-1][1] + (t1 - t0)*Math.sin(th)]); | |
| } | |
| const I1 = integrate(a, b, c, d, 1, theta); | |
| const zE = zs[zs.length - 1], den = zE[0]*zE[0] + zE[1]*zE[1]; | |
| const fx = (I1[0]*zE[0] + I1[1]*zE[1])/den, fy = (I1[1]*zE[0] - I1[0]*zE[1])/den; | |
| const out = [], n2 = I1[0]*I1[0] + I1[1]*I1[1]; | |
| for (let i = 0; i < ts.length; i++) { | |
| const zx = zs[i][0]*fx - zs[i][1]*fy, zy = zs[i][0]*fy + zs[i][1]*fx; | |
| out.push([ts[i], (zx*I1[0] + zy*I1[1])/n2, (zy*I1[0] - zx*I1[1])/n2]); | |
| } | |
| return { pts: out, I1, theta }; | |
| } | |
| /* | |
| * Baseline arm length: the "parabola rule" (see raphlinus.github.io, | |
| * "Cleaner parallel curves with Euler spirals", 2021) -- the arm at which | |
| * the cubic's midpoint coincides with the circular arc's apex, exactly, | |
| * at every angle. Arms are measured as u = l/l*, so u = 1 means | |
| * "circle-like arms". | |
| */ | |
| const lstar = th => 2/(3*Math.max(1e-3, 1 + Math.cos(th))); | |
| /* | |
| * Map constants. K: baseline slope of ln q in ln u -- the family's | |
| * measured small-angle response (~2.2). CK: corner steepening; larger | |
| * values reach corner-like shapes at shorter arms (aesthetic dial). | |
| */ | |
| let K = 2.2; | |
| let CK = 1; | |
| /* | |
| * Given the denominator quadratic (c,d), solve (a,b) so the curve interpolates the | |
| * endpoint tangents (G1). theta is linear in (a,b), so the net turn | |
| * theta(1) = a*F(1) + b*G(1) = th1 - th0 eliminates b exactly -- and | |
| * pins the winding class, excluding stray full loops. What remains is a | |
| * scalar Newton on a: the residual is the start-tangent error, read off | |
| * the argument of the position integral I1. | |
| * | |
| * Init 1 uses the ghost cubic's midpoint tangent as an estimate of | |
| * theta(1/2); with the net-turn condition that is a closed-form 2x2 | |
| * (again by linearity). Init 2 is the small-angle/Euler formula. A | |
| * converged solution whose interior theta excursion leaves the principal | |
| * band (a hidden +-2pi round trip) is rejected and the next init tried. | |
| */ | |
| function fitAB(c, d, th0, th1, l0, l1) { | |
| const thF = makeTheta(1, 0, c, d), thG = makeTheta(0, 1, c, d); | |
| const F1 = thF(1), G1 = thG(1), dth = th1 - th0; | |
| if (Math.abs(G1) < 1e-30) return null; | |
| const bOf = a => (dth - a*F1)/G1; | |
| const r1 = a => { | |
| const b = bOf(a); | |
| const theta = t => a*thF(t) + b*thG(t); | |
| const I1 = integrate(a, b, c, d, 1, theta); | |
| if (cAbs(I1) < 1e-12) return null; | |
| return wrapA(-Math.atan2(I1[1], I1[0]) - th0); | |
| }; | |
| // init ladder: midpoint-tangent informed (closed-form 2x2), then Euler | |
| const inits = []; | |
| { | |
| const dx = 2 - l0*Math.cos(th0) - l1*Math.cos(th1); | |
| const dy = -l0*Math.sin(th0) - l1*Math.sin(th1); | |
| const raw = (Math.hypot(dx, dy) > 1e-9) ? Math.atan2(-dy, dx) : th0 + 0.5*dth; | |
| const estg = 0.5*dth + wrapA(raw - th0 - 0.5*dth); | |
| const Fh = thF(0.5), Gh = thG(0.5); | |
| const det = Fh*G1 - Gh*F1; | |
| if (Math.abs(det) > 1e-10*Math.max(Math.abs(Fh*G1), Math.abs(Gh*F1), 1e-300)) | |
| inits.push((estg*G1 - Gh*dth)/det); | |
| } | |
| inits.push(6*(th0 + th1)); | |
| for (let ii = 0; ii < inits.length; ii++) { | |
| const a0 = inits[ii]; | |
| let a = a0, r = r1(a); | |
| if (r === null) continue; | |
| for (let it = 0; it < 30; it++) { | |
| if (Math.abs(r) < 1e-11) break; | |
| const h = 1e-7*Math.max(1, Math.abs(a)); | |
| const rp = r1(a + h); | |
| if (rp === null) { r = 1; break; } | |
| const dr = (rp - r)/h; | |
| if (dr === 0) { r = 1; break; } | |
| let step = -r/dr, lam = 1, ok = false; | |
| for (let ls = 0; ls < 12; ls++) { | |
| const rn = r1(a + lam*step); | |
| if (rn !== null && Math.abs(rn) < Math.abs(r)) { a += lam*step; r = rn; ok = true; break; } | |
| lam *= 0.5; | |
| } | |
| if (!ok) break; | |
| } | |
| if (Math.abs(r) >= 1e-8) continue; | |
| const b = bOf(a); | |
| // reject interior-excursion loopers (net turn right, round trip inside) | |
| if (Math.abs(a) > 1e-14) { | |
| const tx = -b/a; | |
| if (tx > 0 && tx < 1) { | |
| const ex = a*thF(tx) + b*thG(tx); | |
| if (!(Math.min(0, dth) - Math.PI - 0.3 < ex && ex < Math.max(0, dth) + Math.PI + 0.3)) | |
| continue; | |
| } | |
| } | |
| return [a, b, ii === 0 ? 'mid' : 'euler-fallback']; | |
| } | |
| return null; | |
| } | |
| /* | |
| * The control-polygon -> hyperbezier map. | |
| * | |
| * u, v : arms normalized by l*. Written as l*1.5*(1+cos th) = l/l* | |
| * with a guard so u -> 0 continuously as a tangent approaches | |
| * +-180 deg. That fade is what makes rotating a control point | |
| * through the back direction continuous: the +-2pi winding | |
| * ambiguity collapses into a sub-pixel curl at the endpoint. | |
| * | |
| * ln q = K*ln u + CK*max(ln u, 0)^3, then sc(), a smooth clamp | |
| * (p-norm min with radius L): 5.75..8 above (corner headroom), | |
| * 15 below (deep enough that the residual seam at +-180 deg is | |
| * ~3e-5 of the chord). Note sc(0) = 0, so u = v = 1 maps to | |
| * q0 = q1 = 1: an exact Euler spiral or circle at every angle. | |
| * | |
| * qm : this representation fixes the gauge freedom by normalizing | |
| * integral_0^1 q^(-3/2) ds = 1 (rather than q(0) = 1), with | |
| * (q0, qm, q1) the Bernstein weights of q. The antiderivative | |
| * of q^(-3/2) is elementary and the condition telescopes to a | |
| * closed form for qm. | |
| * | |
| * Then convert to the q(0) = 1 gauge used above and solve the angles. | |
| */ | |
| function solveMap(th0, th1, l0, l1) { | |
| const t0 = performance.now(); | |
| const u = l0*1.5*Math.max(1 + Math.cos(th0), 1e-12); | |
| const v = l1*1.5*Math.max(1 + Math.cos(th1), 1e-12); | |
| const LHI = Math.min(8, 5 + 0.75*CK); | |
| const sc = x => { | |
| x = Math.max(x, -60); | |
| const L = x >= 0 ? LHI : 15; | |
| return x/Math.pow(1 + Math.pow(x/L, 4), 0.25); | |
| }; | |
| const lu = Math.log(Math.max(u, 1e-26)), lv = Math.log(Math.max(v, 1e-26)); | |
| const q0 = Math.exp(sc(K*lu + CK*Math.pow(Math.max(lu, 0), 3))); | |
| const q1 = Math.exp(sc(K*lv + CK*Math.pow(Math.max(lv, 0), 3))); | |
| const qm = 1/Math.sqrt(q0) + 1/Math.sqrt(q1) - Math.sqrt(q0*q1); | |
| const c = (q0 - 2*qm + q1)/q0, d = 2*(qm - q0)/q0; | |
| const ab = fitAB(c, d, th0, th1, l0, l1); | |
| if (!ab) return null; | |
| const clamped = Math.abs(Math.log(u)) > 1.4 || Math.abs(Math.log(v)) > 1.4; | |
| return { x: [ab[0], ab[1], c, d], q0, q1, qm, u, v, clamped, init: ab[2], | |
| ms: performance.now() - t0 }; | |
| } | |
| /* ---------- UI ---------- */ | |
| const cv = document.getElementById('cv'), ctx = cv.getContext('2d'); | |
| const CW = 820, CH = 620; | |
| (function hidpi() { | |
| const dpr = window.devicePixelRatio || 1; | |
| cv.style.width = CW + 'px'; cv.style.height = CH + 'px'; | |
| cv.width = Math.round(CW*dpr); cv.height = Math.round(CH*dpr); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| })(); | |
| let P = [[180, 430], [300, 240], [520, 240], [640, 430]]; | |
| let dragging = -1, lastSol = null, stalled = false; | |
| function preset(name) { | |
| const p0 = [180, 430], p3 = [640, 430], ch = 460; | |
| const set = (th0, th1, l0, l1) => { | |
| P = [p0, [p0[0] + l0*ch*Math.cos(th0), p0[1] - l0*ch*Math.sin(th0)], | |
| [p3[0] - l1*ch*Math.cos(th1), p3[1] + l1*ch*Math.sin(th1)], p3]; | |
| }; | |
| const d = Math.PI/180, D = Math.SQRT1_2; | |
| if (name === 'circle') set(45*d, -45*d, lstar(45*d), lstar(45*d)); | |
| for (const th of [60, 90, 120, 135]) | |
| if (name === 'e' + th) set(th*d, -th*d, lstar(th*d), lstar(th*d)); | |
| if (name === 's') set(30*d, 30*d, lstar(30*d), lstar(30*d)); | |
| if (name === 'corner') set(45*d, -45*d, 1.6*lstar(45*d), 1.6*lstar(45*d)); | |
| if (name === 'past') set(45*d, -45*d, 2.4*lstar(45*d), 2.4*lstar(45*d)); | |
| render(); | |
| } | |
| function polygonData() { | |
| const dx = P[3][0] - P[0][0], dy = -(P[3][1] - P[0][1]); | |
| const ch = Math.hypot(dx, dy), chA = Math.atan2(dy, dx); | |
| const a0 = Math.atan2(-(P[1][1] - P[0][1]), P[1][0] - P[0][0]); | |
| const a1 = Math.atan2(-(P[3][1] - P[2][1]), P[3][0] - P[2][0]); | |
| return { ch, chA, th0: wrapA(a0 - chA), th1: wrapA(a1 - chA), | |
| l0: Math.hypot(P[1][0]-P[0][0], P[1][1]-P[0][1])/ch, | |
| l1: Math.hypot(P[3][0]-P[2][0], P[3][1]-P[2][1])/ch }; | |
| } | |
| function toCanvas(zx, zy, g) { | |
| const ca = Math.cos(g.chA), sa = Math.sin(g.chA); | |
| const wx = (zx*ca - zy*sa)*g.ch, wy = (zx*sa + zy*ca)*g.ch; | |
| return [P[0][0] + wx, P[0][1] - wy]; | |
| } | |
| function render() { | |
| const g = polygonData(); | |
| ctx.clearRect(0, 0, CW, CH); | |
| ctx.strokeStyle = '#99b'; ctx.setLineDash([4, 4]); ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(...P[0]); for (let i = 1; i < 4; i++) ctx.lineTo(...P[i]); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| if (document.getElementById('showCubic').checked) { | |
| ctx.strokeStyle = '#bbb'; ctx.lineWidth = 5; ctx.globalAlpha = 0.55; | |
| ctx.beginPath(); ctx.moveTo(...P[0]); | |
| ctx.bezierCurveTo(P[1][0], P[1][1], P[2][0], P[2][1], P[3][0], P[3][1]); | |
| ctx.stroke(); ctx.globalAlpha = 1; | |
| } | |
| const sol = solveMap(g.th0, g.th1, g.l0, g.l1); | |
| stalled = !sol; | |
| const use = sol || lastSol; | |
| if (use) { | |
| if (sol) lastSol = sol; | |
| const [a, b, c, d] = use.x; | |
| const S = sampleCurve(a, b, c, d, 300); | |
| ctx.strokeStyle = stalled ? '#c88' : '#c22'; ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| S.pts.forEach((p, i) => { const q = toCanvas(p[1], p[2], g); i ? ctx.lineTo(...q) : ctx.moveTo(...q); }); | |
| ctx.stroke(); | |
| if (document.getElementById('showComb').checked) { | |
| const I1c = integrate(a, b, c, d, 1); | |
| const I1n = Math.hypot(...I1c), phiC = Math.atan2(I1c[1], I1c[0]); | |
| ctx.strokeStyle = '#2a7'; ctx.lineWidth = 0.7; | |
| for (let i = 0; i < S.pts.length; i += 6) { | |
| const [t, zx, zy] = S.pts[i], q = (c*t + d)*t + 1; | |
| const kap = (a*t + b)/(q*Math.sqrt(q))*I1n; | |
| const th = S.theta(t) - phiC; | |
| const nx = -Math.sin(th), ny = Math.cos(th); | |
| const L = Math.max(-0.25, Math.min(0.25, -0.05*kap)); | |
| const p1 = toCanvas(zx, zy, g), p2 = toCanvas(zx + L*nx, zy + L*ny, g); | |
| ctx.beginPath(); ctx.moveTo(...p1); ctx.lineTo(...p2); ctx.stroke(); | |
| } | |
| } | |
| } | |
| const names = ['P0', 'C1', 'C2', 'P3']; | |
| P.forEach((p, i) => { | |
| ctx.fillStyle = i === 0 || i === 3 ? '#036' : '#07c'; | |
| ctx.beginPath(); ctx.arc(p[0], p[1], 6, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#000'; ctx.fillText(names[i], p[0] + 9, p[1] - 6); | |
| }); | |
| const dd = 180/Math.PI; | |
| let txt = `th0 ${(g.th0*dd).toFixed(1)}° th1 ${(g.th1*dd).toFixed(1)}°\n` | |
| + `arms ${g.l0.toFixed(3)}, ${g.l1.toFixed(3)}\n`; | |
| if (use) { | |
| const T = use.T; | |
| const [a, b, c, d] = use.x; | |
| const tv = c > 0 ? -0.5*d/c : 0.5; | |
| const qm = (tv > 0 && tv < 1 && c > 0) ? (c*tv + d)*tv + 1 : Math.min(1, 1 + c + d); | |
| const eul = Math.abs(Math.log(use.q0)) + Math.abs(Math.log(use.q1)); | |
| txt += `u ${use.u.toFixed(3)} v ${use.v.toFixed(3)}` | |
| + (use.clamped ? ` [far field]` : ``) + `\n` | |
| + `q0 ${use.q0.toFixed(4)} q1 ${use.q1.toFixed(4)} qm ${use.qm.toFixed(4)}\n` | |
| + `q_min ${qm.toExponential(2)} init: ${use.init} ${use.ms.toFixed(1)} ms\n` | |
| + (eul < 0.02 ? `q ≈ const: EULER FAMILY (exact spiral/circle)` : ``) | |
| + (stalled ? `\nSTALLED — holding last shape` : ``); | |
| } else txt += `no solution yet (drag from a preset)`; | |
| document.getElementById('status').textContent = txt; | |
| } | |
| cv.addEventListener('pointerdown', e => { | |
| const r = cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; | |
| let best = -1, bd = 200; | |
| P.forEach((p, i) => { const d = (p[0]-x)**2 + (p[1]-y)**2; if (d < bd) { bd = d; best = i; } }); | |
| dragging = best; | |
| cv.setPointerCapture(e.pointerId); | |
| }); | |
| cv.addEventListener('pointermove', e => { | |
| if (dragging < 0) return; | |
| const r = cv.getBoundingClientRect(); | |
| const nx = e.clientX - r.left, ny = e.clientY - r.top; | |
| if (dragging === 0 || dragging === 3) { | |
| const h = dragging === 0 ? 1 : 2; | |
| P[h][0] += nx - P[dragging][0]; P[h][1] += ny - P[dragging][1]; | |
| } | |
| P[dragging] = [nx, ny]; | |
| render(); | |
| }); | |
| cv.addEventListener('pointerup', () => { dragging = -1; render(); }); | |
| document.querySelectorAll('input').forEach(el => el.addEventListener('change', render)); | |
| document.getElementById('ckslider').addEventListener('input', e => { | |
| CK = parseFloat(e.target.value); | |
| document.getElementById('ckval').textContent = CK.toFixed(2); | |
| render(); | |
| }); | |
| document.getElementById('kslider').addEventListener('input', e => { | |
| K = parseFloat(e.target.value); | |
| document.getElementById('kval').textContent = K.toFixed(2); | |
| render(); | |
| }); | |
| preset('circle'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment