Last active
February 6, 2026 17:37
-
-
Save estama/b72d75e77828f707d8c07d9e7c1ec290 to your computer and use it in GitHub Desktop.
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
| <!-- By estama --> | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Font to Curves</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/d3-delaunay@6"></script> | |
| <script src="https://unpkg.com/opentype.js@latest/dist/opentype.min.js"></script> | |
| <style> | |
| canvas { | |
| image-rendering: pixelated; | |
| } | |
| #canvasReconstruct, | |
| #testerCanvas { | |
| image-rendering: auto; | |
| } | |
| #testerCanvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .checkerboard { | |
| background-color: #f0f0f0; | |
| background-image: | |
| linear-gradient( | |
| 45deg, | |
| #ddd 25%, | |
| transparent 25%, | |
| transparent 75%, | |
| #ddd 75%, | |
| #ddd | |
| ), | |
| linear-gradient( | |
| 45deg, | |
| #ddd 25%, | |
| transparent 25%, | |
| transparent 75%, | |
| #ddd 75%, | |
| #ddd | |
| ); | |
| background-size: 20px 20px; | |
| background-position: | |
| 0 0, | |
| 10px 10px; | |
| } | |
| .cursor-grab { | |
| cursor: grab; | |
| } | |
| .cursor-grabbing { | |
| cursor: grabbing; | |
| } | |
| textarea::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| textarea::-webkit-scrollbar-track { | |
| background: #1f2937; | |
| } | |
| textarea::-webkit-scrollbar-thumb { | |
| background: #4b5563; | |
| border-radius: 4px; | |
| } | |
| textarea::-webkit-scrollbar-thumb:hover { | |
| background: #6b7280; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 min-h-screen p-6 font-sans"> | |
| <!-- SHADERS --> | |
| <script id="vs" type="x-shader/x-vertex"> | |
| attribute vec2 aQuad; // Unit quad vertex (0..1) | |
| // Instanced Attributes: The Raw Bezier Primitive | |
| attribute vec3 aP0; // x, y, r | |
| attribute vec3 aP1; // x, y, r (Control) | |
| attribute vec3 aP2; // x, y, r | |
| attribute vec2 aOffset; // Character Position (Layout) | |
| attribute vec4 aColor; // Instance Color (RGBA) | |
| uniform float uScale; // Font Size (Includes Zoom) | |
| uniform float uSkew; // Skew Factor | |
| uniform float uBold; // Bold Factor (Radius multiplier) | |
| uniform mat3 uViewMatrix; // Global Pan (Identity Scale) | |
| uniform vec2 uResolution; // Canvas Size | |
| // Varyings for Fragment Shader (World Space) | |
| varying vec2 vPos; | |
| varying vec2 vP0; | |
| varying vec2 vP1; | |
| varying vec2 vP2; | |
| varying float vR0; | |
| varying float vR1; | |
| varying float vR2; | |
| varying vec4 vColor; | |
| void main() { | |
| // --- 1. Model Transformation (GPU Side) --- | |
| // Radii: Scale * Bold | |
| // uScale now includes the Zoom factor | |
| float r0 = aP0.z * uScale * uBold; | |
| float r1 = aP1.z * uScale * uBold; | |
| float r2 = aP2.z * uScale * uBold; | |
| // Positions: Skew -> Offset -> Scale | |
| // Note: Skew applies to the local character space | |
| vec2 p0_local = vec2(aP0.x + aP0.y * uSkew, aP0.y); | |
| vec2 p1_local = vec2(aP1.x + aP1.y * uSkew, aP1.y); | |
| vec2 p2_local = vec2(aP2.x + aP2.y * uSkew, aP2.y); | |
| // Add Layout Offset and Scale to World units | |
| // World Units are now Screen Pixels (excluding Pan) because uScale includes Zoom | |
| vec2 p0 = (p0_local + aOffset) * uScale; | |
| vec2 p1 = (p1_local + aOffset) * uScale; | |
| vec2 p2 = (p2_local + aOffset) * uScale; | |
| // --- 2. Bounding Box Generation --- | |
| // We expand the quad to cover the curve + radius + padding for AA | |
| float maxR = max(r0, max(r1, r2)); | |
| // Calculate padding in World Units. | |
| // uViewMatrix is 1:1, so world units are pixels. | |
| float pixelSizeWorld = 1.0; | |
| float padding = maxR + (3.0 * pixelSizeWorld); | |
| vec2 minB = min(p0, min(p1, p2)) - padding; | |
| vec2 maxB = max(p0, max(p1, p2)) + padding; | |
| // Map the unit quad attribute (0..1) to this bounding box | |
| vec2 worldPos = mix(minB, maxB, aQuad); | |
| // --- 3. View Transformation & Projection --- | |
| // Transform World -> Screen Pixels | |
| // uViewMatrix only contains Translation now (Scale is 1.0) | |
| vec3 viewPos = uViewMatrix * vec3(worldPos, 1.0); | |
| // Convert to NDC (-1..1) with Y-Flip for standard 2D coords (Top-Left 0,0) | |
| vec2 ndc = viewPos.xy / uResolution; | |
| gl_Position = vec4(ndc.x * 2.0 - 1.0, 1.0 - ndc.y * 2.0, 0.0, 1.0); | |
| // --- 4. Pass Data to Fragment --- | |
| vPos = worldPos; | |
| vP0 = p0; | |
| vP1 = p1; | |
| vP2 = p2; | |
| vR0 = r0; | |
| vR1 = r1; | |
| vR2 = r2; | |
| vColor = aColor; | |
| } | |
| </script> | |
| <script id="fs" type="x-shader/x-fragment"> | |
| precision highp float; | |
| varying vec2 vPos; | |
| varying vec2 vP0; | |
| varying vec2 vP1; | |
| varying vec2 vP2; | |
| varying float vR0; | |
| varying float vR1; | |
| varying float vR2; | |
| varying vec4 vColor; | |
| uniform float uPixelSize; // World space size of 1 pixel (for constant width AA) | |
| float sdBezierUnevenCapsule(vec2 pos, vec2 A, vec2 B, vec2 C, float r0, float r1, float r2, out vec2 outQ, out float outT) { | |
| // Geometry | |
| vec2 b = A - 2.0*B + C; | |
| vec2 c = 2.0*(B - A); | |
| vec2 d0 = A - pos; | |
| vec2 chord = C - A; | |
| // Monotonic Radius Coefficients | |
| float d10 = r1 - r0; | |
| float d20 = r2 - r0; | |
| float slopeMid = sign(d10) * min(abs(d20), 4.0 * min(abs(d10), abs(r2 - r1))) * step(0.0, d10 * (r2 - r1)); | |
| float cubicA = 4.0 * (d20 - slopeMid); | |
| float cubicC = 4.0 * d10 + d20 - 2.0 * slopeMid; | |
| float cubicB = d20 - cubicA - cubicC; | |
| float kB2 = 2.0 * cubicB; | |
| // Polynomial Coefficients | |
| float q4 = dot(b,b); | |
| float q3 = 2.0 * dot(c,b); | |
| float q2 = dot(c,c) + 2.0 * dot(d0,b); | |
| float q1 = 2.0 * dot(d0,c); | |
| float q0 = dot(d0, d0); | |
| // Loop Constants (Optimized) | |
| float q4_2 = 2.0 * q4; | |
| float q4_6 = 6.0 * q4; | |
| float q3_15 = 1.5 * q3; | |
| float q3_3 = 3.0 * q3; | |
| float kA3 = 3.0 * cubicA; | |
| float kA6 = 6.0 * cubicA; | |
| // Initial Candidates | |
| float bestD = sqrt(q0) - r0; | |
| float bestT = 0.0; | |
| // Check Endpoint C (Optimized: reuse d0+chord) | |
| vec2 dC = C - pos; | |
| float dEnd = sqrt(max(dot(dC, dC), 0.0)) - r2; | |
| if (dEnd < bestD) { bestD = dEnd; bestT = 1.0; } | |
| vec4 t = vec4(clamp(-dot(d0, chord) / (dot(chord,chord) + 1e-12), 0.0, 1.0), 0.2, 0.5, 0.8); | |
| // Newton Iteration | |
| for(int j = 0; j < 4; j++) { | |
| vec4 invS = inversesqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 1e-12)); | |
| vec4 termB = t * (t * q4_2 + q3_15) + q2; | |
| vec4 dr = t * (t * kA3 + kB2) + cubicC; | |
| vec4 denGeom = t * (t * q4_6 + q3_3) + q2; // Optimized S''/2 | |
| vec4 k = (t * termB + 0.5 * q1) * invS; | |
| t = clamp(t - (k - dr) / (invS * (denGeom - k * dr) - (t * kA6 + kB2)), 0.0, 1.0); | |
| } | |
| // Reduction | |
| vec4 err = sqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 0.0)) - (((cubicA * t + cubicB) * t + cubicC) * t + r0); | |
| vec2 bestPair1 = (err.x < err.y) ? vec2(err.x, t.x) : vec2(err.y, t.y); | |
| vec2 bestPair2 = (err.z < err.w) ? vec2(err.z, t.z) : vec2(err.w, t.w); | |
| vec2 finalPair = (bestPair1.x < bestPair2.x) ? bestPair1 : bestPair2; | |
| if(finalPair.x < bestD) { bestD = finalPair.x; bestT = finalPair.y; } | |
| outT = bestT; | |
| outQ = A + bestT * (c + bestT * b); | |
| return bestD; | |
| } | |
| void main() { | |
| vec2 closestQ; | |
| float closestT; | |
| // Calculate signed distance | |
| // Since we scale the primitives, vPos is in screen pixels (mostly) | |
| float d = sdBezierUnevenCapsule(vPos, vP0, vP1, vP2, vR0, vR1, vR2, closestQ, closestT); | |
| // uPixelSize is 1.0 in the new logic | |
| float alpha = 1.0 - smoothstep(-uPixelSize, uPixelSize, d); | |
| // Keep edges for blending | |
| if (alpha < 0.001) discard; | |
| // Render with instance color | |
| gl_FragColor = vec4(vColor.rgb, vColor.a * alpha); | |
| } | |
| </script> | |
| <div class="max-w-[95rem] mx-auto"> | |
| <header class="mb-4 border-b border-gray-700 pb-4"> | |
| <div class="flex items-baseline gap-3"> | |
| <h1 class="text-3xl font-bold text-indigo-400"> | |
| Font to Curves | |
| </h1> | |
| <span | |
| class="text-sm text-gray-500 bg-gray-800 px-2 py-0.5 rounded border border-gray-700" | |
| >v9</span | |
| > | |
| </div> | |
| <p class="text-gray-400"> | |
| Render → SDF → Voronoi → | |
| <span class="text-pink-400">Quadratic Beziers</span> → | |
| <span class="text-green-400">WebGL GPU</span> | |
| </p> | |
| </header> | |
| <!-- TAB NAVIGATION --> | |
| <div class="flex border-b border-gray-700 mb-6"> | |
| <button | |
| id="tabGen" | |
| class="px-6 py-3 text-sm font-bold text-indigo-400 border-b-2 border-indigo-400 focus:outline-none transition hover:text-indigo-300" | |
| > | |
| 1. Generator | |
| </button> | |
| <button | |
| id="tabTest" | |
| class="px-6 py-3 text-sm font-bold text-gray-400 hover:text-gray-200 focus:outline-none transition border-b-2 border-transparent hover:border-gray-600" | |
| > | |
| 2. Tester (WebGL) | |
| </button> | |
| <!-- 3rd TAB: INDEPENDENT CODE BLOCK START --> | |
| <button | |
| id="tabUneven" | |
| class="px-6 py-3 text-sm font-bold text-gray-400 hover:text-gray-200 focus:outline-none transition border-b-2 border-transparent hover:border-gray-600" | |
| > | |
| 3. Uneven Bezier Demo | |
| </button> | |
| <!-- 3rd TAB: INDEPENDENT CODE BLOCK END --> | |
| </div> | |
| <!-- VIEW: GENERATOR --> | |
| <div id="viewGenerator" class="block"> | |
| <!-- Controls --> | |
| <div | |
| class="bg-gray-800 p-6 rounded-lg shadow-lg mb-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-end" | |
| > | |
| <div class="flex flex-col gap-2"> | |
| <div class="flex justify-between items-center"> | |
| <label | |
| class="block text-sm font-medium text-gray-400" | |
| >Font & Export</label | |
| > | |
| <span | |
| id="batchStatus" | |
| class="text-xs text-yellow-400 font-mono hidden" | |
| >Processing... 0%</span | |
| > | |
| </div> | |
| <div class="flex gap-2"> | |
| <div class="relative flex-1 flex gap-2 min-w-0"> | |
| <select | |
| id="fontInput" | |
| class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-2 text-sm focus:outline-none focus:border-indigo-500 transition text-ellipsis overflow-hidden" | |
| ></select> | |
| <button | |
| id="uploadBtn" | |
| class="bg-indigo-600 hover:bg-indigo-500 px-3 py-2 rounded text-sm font-bold text-white transition shrink-0" | |
| title="Load local font file" | |
| > | |
| 📁 | |
| </button> | |
| <input | |
| type="file" | |
| id="fontFileInput" | |
| accept=".ttf,.otf,.woff,.woff2" | |
| class="hidden" | |
| /> | |
| </div> | |
| <button | |
| id="btnGenerateASCII" | |
| class="bg-green-600 hover:bg-green-500 text-white px-3 py-2 rounded text-sm font-bold transition shadow-lg whitespace-nowrap shrink-0" | |
| > | |
| Export ASCII | |
| </button> | |
| <button | |
| id="btnExportAll" | |
| class="bg-purple-600 hover:bg-purple-500 text-white px-3 py-2 rounded text-sm font-bold transition shadow-lg whitespace-nowrap shrink-0" | |
| title="Export all characters found in uploaded font" | |
| > | |
| Export All | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <label | |
| class="block text-sm font-medium text-gray-400 mb-1" | |
| >Character</label | |
| > | |
| <div class="flex gap-2"> | |
| <input | |
| type="text" | |
| id="charInput" | |
| value="&" | |
| maxlength="1" | |
| class="w-16 bg-gray-700 border border-gray-600 rounded px-2 py-2 text-center text-xl focus:outline-none focus:border-indigo-500 transition" | |
| /> | |
| <select | |
| id="glyphSelect" | |
| class="flex-1 bg-gray-700 border border-gray-600 rounded px-2 py-2 text-sm focus:outline-none focus:border-indigo-500 transition opacity-50 cursor-not-allowed" | |
| disabled | |
| > | |
| <option value="">Load Custom Font...</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div> | |
| <label | |
| class="block text-sm font-medium text-gray-400 mb-1" | |
| >Style</label | |
| > | |
| <div class="flex gap-2 h-[42px] items-center"> | |
| <label | |
| class="flex items-center space-x-2 bg-gray-700 px-3 py-2 rounded cursor-pointer hover:bg-gray-600 h-full" | |
| > | |
| <input | |
| type="checkbox" | |
| id="boldInput" | |
| class="form-checkbox text-indigo-500" | |
| /> | |
| <span>Bold</span> | |
| </label> | |
| <label | |
| class="flex items-center space-x-2 bg-gray-700 px-3 py-2 rounded cursor-pointer hover:bg-gray-600 h-full" | |
| > | |
| <input | |
| type="checkbox" | |
| id="italicInput" | |
| class="form-checkbox text-indigo-500" | |
| /> | |
| <span>Italic</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label | |
| class="block text-sm font-medium text-yellow-400" | |
| >Pruning Strength</label | |
| > | |
| <span | |
| id="valPrune" | |
| class="text-xs text-yellow-400 font-mono" | |
| >1.15</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="pruneInput" | |
| min="1.0" | |
| max="2.0" | |
| step="0.05" | |
| value="1.15" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| <div | |
| class="flex justify-between text-xs text-gray-500 mt-1" | |
| > | |
| <span>Subtle</span> | |
| <span>Aggressive</span> | |
| </div> | |
| </div> | |
| <!-- REMOVED RDP SLIDER FROM HERE --> | |
| <!-- NEW: Bezier Fit Slider --> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label | |
| class="block text-sm font-medium text-green-400" | |
| >Bezier Simplification</label | |
| > | |
| <span | |
| id="valBezier" | |
| class="text-xs text-green-400 font-mono" | |
| >4.0</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="bezierInput" | |
| min="0.0" | |
| max="40.0" | |
| step="0.1" | |
| value="4.0" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| <div | |
| class="flex justify-between text-xs text-gray-500 mt-1" | |
| > | |
| <span>Off (Smooth)</span> | |
| <span>Loose Fit</span> | |
| </div> | |
| </div> | |
| <!-- Decimals Slider --> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label | |
| class="block text-sm font-medium text-pink-400" | |
| >JSON Decimals</label | |
| > | |
| <span | |
| id="valDecimals" | |
| class="text-xs text-pink-400 font-mono" | |
| >1</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="decimalsInput" | |
| min="0" | |
| max="4" | |
| step="1" | |
| value="1" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| <div | |
| class="flex justify-between text-xs text-gray-500 mt-1" | |
| > | |
| <span>Integer</span> | |
| <span>Float(4)</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" | |
| > | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <h3 class="font-semibold text-white text-xs"> | |
| 1. Render | |
| </h3> | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center checkerboard" | |
| > | |
| <canvas | |
| id="canvasRender" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square" | |
| ></canvas> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <h3 class="font-semibold text-white text-xs"> | |
| 2. SDF | |
| </h3> | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center bg-black" | |
| > | |
| <canvas | |
| id="canvasSDF" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square" | |
| ></canvas> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <div class="flex items-center"> | |
| <h3 class="font-semibold text-white text-xs"> | |
| 3. Raw Voronoi | |
| </h3> | |
| </div> | |
| <span | |
| id="rawCount" | |
| class="text-xs bg-gray-900 px-2 py-0.5 rounded text-gray-300 font-mono" | |
| >0 segs</span | |
| > | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center bg-gray-900 overflow-hidden" | |
| > | |
| <canvas | |
| id="canvasVoronoi" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square cursor-grab" | |
| ></canvas> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col ring-2 ring-yellow-500" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <div class="flex items-center"> | |
| <h3 class="font-semibold text-white text-xs"> | |
| 4. Pruned Topology | |
| </h3> | |
| </div> | |
| <span | |
| id="prunedCount" | |
| class="text-xs bg-gray-900 px-2 py-0.5 rounded text-gray-300 font-mono" | |
| >0 segs</span | |
| > | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center bg-gray-900 overflow-hidden" | |
| > | |
| <canvas | |
| id="canvasPruned" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square cursor-grab" | |
| ></canvas> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col ring-2 ring-indigo-500" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <div class="flex items-center"> | |
| <h3 class="font-semibold text-white text-xs"> | |
| 5. Bezier Skeleton | |
| </h3> | |
| </div> | |
| <span | |
| id="rdpOnlyCount" | |
| class="text-xs bg-gray-900 px-2 py-0.5 rounded text-gray-300 font-mono" | |
| >0 segs</span | |
| > | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center bg-gray-900 overflow-hidden" | |
| > | |
| <canvas | |
| id="canvasRDPOnly" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square cursor-grab" | |
| ></canvas> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col ring-2 ring-pink-500" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <div class="flex items-center"> | |
| <h3 class="font-semibold text-white text-xs"> | |
| 6. Reconstruction | |
| </h3> | |
| <span class="text-[10px] text-gray-400 ml-2" | |
| >(GPU)</span | |
| > | |
| </div> | |
| <span | |
| id="reconstructInfo" | |
| class="text-xs bg-gray-900 px-2 py-0.5 rounded text-gray-300 font-mono min-w-[60px] text-center" | |
| >-</span | |
| > | |
| </div> | |
| <div | |
| class="flex-1 p-2 flex items-center justify-center bg-gray-900 overflow-hidden" | |
| > | |
| <canvas | |
| id="canvasReconstruct" | |
| width="1024" | |
| height="1024" | |
| class="border border-gray-500 shadow-sm w-full aspect-square cursor-grab" | |
| ></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-8 bg-gray-800 rounded-lg shadow-lg p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-semibold text-gray-200"> | |
| Geometry & Metrics JSON | |
| </h3> | |
| <span class="text-xs text-gray-500" | |
| >Format: curves: [ x0,y0,r0, x1,y1,r1, x2,y2,r2, ... | |
| ]</span | |
| > | |
| </div> | |
| <textarea | |
| id="jsonOutput" | |
| readonly | |
| class="w-full h-64 bg-gray-900 text-pink-400 font-mono text-xs p-4 rounded border border-gray-700 focus:outline-none focus:border-indigo-500 shadow-inner resize-y" | |
| spellcheck="false" | |
| ></textarea> | |
| </div> | |
| </div> | |
| <!-- VIEW: TESTER --> | |
| <div id="viewTester" class="hidden"> | |
| <div class="bg-gray-800 p-6 rounded-lg shadow-lg mb-8"> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| <div class="flex flex-col h-full"> | |
| <div class="flex justify-between mb-2"> | |
| <label | |
| class="block text-sm font-medium text-gray-400" | |
| >1. JSON Data Source</label | |
| > | |
| <span | |
| id="testerSourceLabel" | |
| class="text-xs text-gray-500" | |
| >Source: None</span | |
| > | |
| </div> | |
| <div | |
| class="flex-1 flex flex-col items-start justify-center gap-4" | |
| > | |
| <div class="flex gap-2 w-full items-center"> | |
| <button | |
| id="btnLoadJson" | |
| class="bg-purple-600 hover:bg-purple-500 text-white px-4 py-3 rounded font-bold transition shadow-lg flex items-center justify-center gap-2" | |
| > | |
| <span>📁 Load JSON File</span> | |
| </button> | |
| <input | |
| type="file" | |
| id="testerFileInput" | |
| accept=".json" | |
| class="hidden" | |
| /> | |
| <span | |
| id="testerStatusText" | |
| class="text-gray-500 text-xs italic ml-2" | |
| > | |
| No font loaded. | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col gap-4"> | |
| <div> | |
| <label | |
| class="block text-sm font-medium text-gray-400 mb-2" | |
| >2. Test Text (Multiline supported)</label | |
| > | |
| <textarea | |
| id="testerText" | |
| rows="3" | |
| class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2 text-white focus:outline-none focus:border-indigo-500 shadow-inner text-lg resize-none" | |
| > | |
| Hello World! | |
| 123 & | |
| PolyQuadratic</textarea | |
| > | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-2"> | |
| <label | |
| class="block text-sm font-medium text-gray-400" | |
| >3. Scale / Size</label | |
| > | |
| <span | |
| id="scaleVal" | |
| class="text-xs text-indigo-400 font-mono" | |
| >0.10</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="testerScale" | |
| min="0.02" | |
| max="0.5" | |
| step="0.01" | |
| value="0.10" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-2"> | |
| <label | |
| class="block text-sm font-medium text-gray-400" | |
| >4. Bold Coef</label | |
| > | |
| <span | |
| id="boldCoefVal" | |
| class="text-xs text-indigo-400 font-mono" | |
| >1.00</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="boldCoef" | |
| min="0.1" | |
| max="3.0" | |
| step="0.1" | |
| value="1.0" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-2"> | |
| <label | |
| class="block text-sm font-medium text-gray-400" | |
| >5. Skew Coef</label | |
| > | |
| <span | |
| id="skewCoefVal" | |
| class="text-xs text-indigo-400 font-mono" | |
| >0.00</span | |
| > | |
| </div> | |
| <input | |
| type="range" | |
| id="skewCoef" | |
| min="-1.0" | |
| max="1.0" | |
| step="0.01" | |
| value="0.0" | |
| class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| class="bg-gray-800 p-4 rounded-lg shadow-lg overflow-hidden flex flex-col items-center" | |
| > | |
| <div class="w-full flex justify-between mb-2 px-2"> | |
| <h3 class="text-sm font-medium text-gray-400"> | |
| Preview Canvas (WebGL) | |
| </h3> | |
| <span class="text-xs text-gray-500" | |
| >Instance Rendering</span | |
| > | |
| </div> | |
| <div | |
| id="testerCanvasContainer" | |
| class="bg-black rounded border border-gray-600 overflow-hidden w-full flex justify-center min-h-[300px]" | |
| > | |
| <canvas id="testerCanvas"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 3rd TAB VIEW: INDEPENDENT CODE BLOCK START --> | |
| <div id="viewUneven" class="hidden"> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[800px]"> | |
| <!-- Left: Live Demo (iframe) --> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col h-full" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <h3 class="font-semibold text-white text-xs"> | |
| Live Interactive Demo | |
| </h3> | |
| </div> | |
| <div class="flex-1 relative bg-black"> | |
| <iframe | |
| id="unevenFrame" | |
| class="w-full h-full border-none" | |
| sandbox="allow-scripts allow-same-origin" | |
| ></iframe> | |
| </div> | |
| </div> | |
| <!-- Right: Source Code --> | |
| <div | |
| class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col h-full" | |
| > | |
| <div | |
| class="p-3 bg-gray-700 border-b border-gray-600 flex justify-between items-center" | |
| > | |
| <h3 class="font-semibold text-white text-xs"> | |
| Source Code (unevenbezier.html) | |
| </h3> | |
| <button | |
| id="copyCodeBtn" | |
| class="text-xs bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded transition" | |
| > | |
| Copy | |
| </button> | |
| </div> | |
| <textarea | |
| id="unevenCode" | |
| readonly | |
| class="w-full h-full bg-gray-900 text-green-400 font-mono text-xs p-4 resize-none focus:outline-none" | |
| spellcheck="false" | |
| ></textarea> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 3rd TAB VIEW: INDEPENDENT CODE BLOCK END --> | |
| </div> | |
| <script> | |
| // --- Configuration --- | |
| const WIDTH = 1024; | |
| const HEIGHT = 1024; | |
| const INF = 1e9; | |
| // --- Global State --- | |
| let viewTransform = { k: 1, x: 0, y: 0 }; | |
| let isDragging = false; | |
| let lastMouse = { x: 0, y: 0 }; | |
| // TESTER Global State | |
| let testerViewTransform = { k: 1, x: 0, y: 0 }; | |
| let isTesterDragging = false; | |
| let lastTesterMouse = { x: 0, y: 0 }; | |
| let testerInstanceCount = 0; // Track how many instances to draw | |
| let genInstanceCount = 0; | |
| let hoveredRawEdgeIndex = -1; | |
| let hoveredPrunedSegment = null; | |
| let hoveredRDPOnlySegment = null; | |
| let hoveredCapsuleIndex = -1; | |
| let highlightedPolylineIndex = -1; | |
| // Data Cache | |
| let cachedGrid = null; | |
| let cachedSDF = null; | |
| let cachedVoronoiData = null; | |
| let cachedRawEdges = []; | |
| let cachedPrunedData = null; | |
| let cachedRDPOnlyData = null; | |
| let cachedCurves = []; | |
| let cachedFlatCurveData = null; // NEW: Stores the integer-rounded data for View 6 | |
| let currentMetrics = { | |
| advance: 0, | |
| lineHeight: 0, | |
| baseline: 0, | |
| originX: 0, | |
| }; | |
| let loadedFontCharCodes = []; | |
| let customFontName = null; | |
| const TOP_MARGIN = 10; | |
| const ghostCanvas = document.createElement("canvas"); | |
| ghostCanvas.width = WIDTH; | |
| ghostCanvas.height = HEIGHT; | |
| const ghostCtx = ghostCanvas.getContext("2d"); | |
| // --- DOM Elements --- | |
| const canvasRender = document.getElementById("canvasRender"); | |
| const ctxRender = canvasRender.getContext("2d", { | |
| willReadFrequently: true, | |
| }); | |
| const canvasSDF = document.getElementById("canvasSDF"); | |
| const ctxSDF = canvasSDF.getContext("2d"); | |
| const canvasVoronoi = document.getElementById("canvasVoronoi"); | |
| const ctxVoronoi = canvasVoronoi.getContext("2d"); | |
| const canvasPruned = document.getElementById("canvasPruned"); | |
| const ctxPruned = canvasPruned.getContext("2d"); | |
| const canvasRDPOnly = document.getElementById("canvasRDPOnly"); | |
| const ctxRDPOnly = canvasRDPOnly.getContext("2d"); | |
| // CHANGED: Use WebGL for Reconstruct canvas | |
| const canvasReconstruct = | |
| document.getElementById("canvasReconstruct"); | |
| // We will initialize WebGL context in initWebGL() | |
| const uiRawCount = document.getElementById("rawCount"); | |
| const uiPrunedCount = document.getElementById("prunedCount"); | |
| const uiRdpOnlyCount = document.getElementById("rdpOnlyCount"); | |
| const uiReconstructInfo = | |
| document.getElementById("reconstructInfo"); | |
| const jsonOutput = document.getElementById("jsonOutput"); | |
| const btnGenerateASCII = | |
| document.getElementById("btnGenerateASCII"); | |
| const btnExportAll = document.getElementById("btnExportAll"); | |
| const batchStatus = document.getElementById("batchStatus"); | |
| // Tester DOM | |
| const tabGen = document.getElementById("tabGen"); | |
| const tabTest = document.getElementById("tabTest"); | |
| const viewGenerator = document.getElementById("viewGenerator"); | |
| const viewTester = document.getElementById("viewTester"); | |
| const testerFileInput = document.getElementById("testerFileInput"); | |
| const btnLoadJson = document.getElementById("btnLoadJson"); | |
| const testerStatusText = | |
| document.getElementById("testerStatusText"); | |
| const testerSourceLabel = | |
| document.getElementById("testerSourceLabel"); | |
| const testerCanvasContainer = document.getElementById( | |
| "testerCanvasContainer", | |
| ); | |
| const testerText = document.getElementById("testerText"); | |
| const testerScale = document.getElementById("testerScale"); | |
| const testerCanvas = document.getElementById("testerCanvas"); | |
| const scaleVal = document.getElementById("scaleVal"); | |
| const boldCoef = document.getElementById("boldCoef"); | |
| const boldCoefVal = document.getElementById("boldCoefVal"); | |
| const skewCoef = document.getElementById("skewCoef"); | |
| const skewCoefVal = document.getElementById("skewCoefVal"); | |
| // --- Inputs Declaration (Fixed Redeclaration Error) --- | |
| const inputs = { | |
| char: document.getElementById("charInput"), | |
| glyphSelect: document.getElementById("glyphSelect"), | |
| font: document.getElementById("fontInput"), | |
| fileInput: document.getElementById("fontFileInput"), | |
| uploadBtn: document.getElementById("uploadBtn"), | |
| bold: document.getElementById("boldInput"), | |
| italic: document.getElementById("italicInput"), | |
| // REMOVED RDP INPUT | |
| prune: document.getElementById("pruneInput"), | |
| bezier: document.getElementById("bezierInput"), | |
| decimals: document.getElementById("decimalsInput"), | |
| }; | |
| const uiVals = { | |
| prune: document.getElementById("valPrune"), | |
| // REMOVED RDP VAL | |
| bezier: document.getElementById("valBezier"), | |
| decimals: document.getElementById("valDecimals"), | |
| }; | |
| let testerData = null; | |
| // --- WebGL Globals --- | |
| // Helper Class to manage contexts | |
| class WebGLContextWrapper { | |
| constructor(canvas) { | |
| this.canvas = canvas; | |
| this.gl = canvas.getContext("webgl", { | |
| alpha: true, | |
| antialias: false, | |
| depth: false, | |
| }); // Disable depth buffer | |
| this.program = null; | |
| this.extInstanced = null; | |
| this.buffers = {}; | |
| } | |
| init() { | |
| if (!this.gl) return false; | |
| const gl = this.gl; | |
| this.extInstanced = gl.getExtension( | |
| "ANGLE_instanced_arrays", | |
| ); | |
| // Compile Shaders | |
| const vs = gl.createShader(gl.VERTEX_SHADER); | |
| gl.shaderSource(vs, document.getElementById("vs").text); | |
| gl.compileShader(vs); | |
| const fs = gl.createShader(gl.FRAGMENT_SHADER); | |
| gl.shaderSource(fs, document.getElementById("fs").text); | |
| gl.compileShader(fs); | |
| if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) { | |
| console.error(gl.getShaderInfoLog(fs)); | |
| return false; | |
| } | |
| this.program = gl.createProgram(); | |
| gl.attachShader(this.program, vs); | |
| gl.attachShader(this.program, fs); | |
| gl.linkProgram(this.program); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
| gl.disable(gl.DEPTH_TEST); // IMPORTANT: Fixes flickering/corruption on overlap | |
| // Unit Quad Buffer | |
| this.buffers.quad = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.quad); | |
| gl.bufferData( | |
| gl.ARRAY_BUFFER, | |
| new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), | |
| gl.STATIC_DRAW, | |
| ); | |
| // Instance Buffer (Dynamic) | |
| this.buffers.instance = gl.createBuffer(); | |
| return true; | |
| } | |
| draw(instanceCount, transform, scale, bold, skew) { | |
| if (instanceCount === 0 || !this.program) return; | |
| const gl = this.gl; | |
| gl.viewport(0, 0, this.canvas.width, this.canvas.height); | |
| gl.clearColor(0.067, 0.094, 0.153, 1.0); // Match bg-gray-900 | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| gl.useProgram(this.program); | |
| // ViewMatrix is Identity scale, only translation | |
| const mat = [1, 0, 0, 0, 1, 0, transform.x, transform.y, 1]; | |
| gl.uniformMatrix3fv( | |
| gl.getUniformLocation(this.program, "uViewMatrix"), | |
| false, | |
| mat, | |
| ); | |
| gl.uniform2f( | |
| gl.getUniformLocation(this.program, "uResolution"), | |
| this.canvas.width, | |
| this.canvas.height, | |
| ); | |
| // PixelSize is 1.0 because ViewMapping is 1:1 | |
| gl.uniform1f( | |
| gl.getUniformLocation(this.program, "uPixelSize"), | |
| 1.0, | |
| ); | |
| // FIX: Pass scale directly. Do NOT multiply by transform.k here. | |
| // The caller is responsible for providing the correct scale for uScale uniform. | |
| gl.uniform1f( | |
| gl.getUniformLocation(this.program, "uScale"), | |
| scale, | |
| ); | |
| gl.uniform1f( | |
| gl.getUniformLocation(this.program, "uBold"), | |
| bold, | |
| ); | |
| gl.uniform1f( | |
| gl.getUniformLocation(this.program, "uSkew"), | |
| skew, | |
| ); | |
| // Quad Attr (Static Geometry) | |
| gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.quad); | |
| const aQuad = gl.getAttribLocation(this.program, "aQuad"); | |
| gl.enableVertexAttribArray(aQuad); | |
| gl.vertexAttribPointer(aQuad, 2, gl.FLOAT, false, 0, 0); | |
| // --- FIX IS HERE: Force divisor to 0 for the static geometry --- | |
| this.extInstanced.vertexAttribDivisorANGLE(aQuad, 0); | |
| // Instance Attrs | |
| gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.instance); | |
| // Stride: 11 floats (geometry) + 4 floats (color) = 15 floats * 4 bytes = 60 bytes | |
| const stride = 15 * 4; | |
| this.bindAttrib("aP0", 3, stride, 0); | |
| this.bindAttrib("aP1", 3, stride, 12); | |
| this.bindAttrib("aP2", 3, stride, 24); | |
| this.bindAttrib("aOffset", 2, stride, 36); | |
| this.bindAttrib("aColor", 4, stride, 44); | |
| this.extInstanced.drawArraysInstancedANGLE( | |
| gl.TRIANGLE_STRIP, | |
| 0, | |
| 4, | |
| instanceCount, | |
| ); | |
| } | |
| bindAttrib(name, size, stride, offset) { | |
| const loc = this.gl.getAttribLocation(this.program, name); | |
| if (loc === -1) return; | |
| this.gl.enableVertexAttribArray(loc); | |
| this.gl.vertexAttribPointer( | |
| loc, | |
| size, | |
| this.gl.FLOAT, | |
| false, | |
| stride, | |
| offset, | |
| ); | |
| this.extInstanced.vertexAttribDivisorANGLE(loc, 1); | |
| } | |
| } | |
| let webglTester = null; | |
| let webglGen = null; // For canvasReconstruct | |
| // --- MATH HELPERS --- | |
| function distToSegmentSquared(p, v, w) { | |
| const l2 = (v[0] - w[0]) ** 2 + (v[1] - w[1]) ** 2; | |
| if (l2 === 0) return (p[0] - v[0]) ** 2 + (p[1] - v[1]) ** 2; | |
| let t = | |
| ((p[0] - v[0]) * (w[0] - v[0]) + | |
| (p[1] - v[1]) * (w[1] - v[1])) / | |
| l2; | |
| t = Math.max(0, Math.min(1, t)); | |
| return ( | |
| (p[0] - (v[0] + t * (w[0] - v[0]))) ** 2 + | |
| (p[1] - (v[1] + t * (w[1] - v[1]))) ** 2 | |
| ); | |
| } | |
| function getBinaryGrid(imageData) { | |
| const data = imageData.data; | |
| const grid = new Uint8Array(WIDTH * HEIGHT); | |
| for (let i = 0; i < data.length; i += 4) | |
| grid[i / 4] = data[i + 3] > 128 ? 1 : 0; | |
| return grid; | |
| } | |
| function computeEDT(grid, targetVal) { | |
| const dists = new Float32Array(WIDTH * HEIGHT).fill(INF); | |
| for (let i = 0; i < grid.length; i++) | |
| if (grid[i] === targetVal) dists[i] = 0; | |
| const f = new Float32Array(Math.max(WIDTH, HEIGHT)); | |
| const d = new Float32Array(Math.max(WIDTH, HEIGHT)); | |
| const z = new Float32Array(Math.max(WIDTH, HEIGHT) + 1); | |
| const v = new Int32Array(Math.max(WIDTH, HEIGHT)); | |
| const edt1D = (f, n) => { | |
| let k = 0; | |
| v[0] = 0; | |
| z[0] = -INF; | |
| z[1] = INF; | |
| for (let q = 1; q < n; q++) { | |
| let s = | |
| (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / | |
| (2 * q - 2 * v[k]); | |
| while (s <= z[k]) { | |
| k--; | |
| s = | |
| (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / | |
| (2 * q - 2 * v[k]); | |
| } | |
| k++; | |
| v[k] = q; | |
| z[k] = s; | |
| z[k + 1] = INF; | |
| } | |
| k = 0; | |
| for (let q = 0; q < n; q++) { | |
| while (z[k + 1] < q) k++; | |
| d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; | |
| } | |
| }; | |
| const idx = (x, y) => y * WIDTH + x; | |
| for (let x = 0; x < WIDTH; x++) { | |
| for (let y = 0; y < HEIGHT; y++) f[y] = dists[idx(x, y)]; | |
| edt1D(f, HEIGHT); | |
| for (let y = 0; y < HEIGHT; y++) dists[idx(x, y)] = d[y]; | |
| } | |
| for (let y = 0; y < HEIGHT; y++) { | |
| for (let x = 0; x < WIDTH; x++) f[x] = dists[idx(x, y)]; | |
| edt1D(f, WIDTH); | |
| for (let x = 0; x < WIDTH; x++) dists[idx(x, y)] = d[x]; | |
| } | |
| return dists; | |
| } | |
| function visualizeSDF(sdf, minVal, maxVal) { | |
| const imgData = ctxSDF.createImageData(WIDTH, HEIGHT); | |
| const data = imgData.data; | |
| for (let i = 0; i < sdf.length; i++) { | |
| const val = sdf[i]; | |
| const pIdx = i * 4; | |
| if (val < 0) { | |
| const intensity = Math.max(0, 255 + val * 5); | |
| data[pIdx] = 0; | |
| data[pIdx + 1] = intensity * 0.5; | |
| data[pIdx + 2] = 255; | |
| data[pIdx + 3] = 255; | |
| } else { | |
| const intensity = Math.max(0, 255 - val * 5); | |
| data[pIdx] = 255; | |
| data[pIdx + 1] = intensity * 0.2; | |
| data[pIdx + 2] = 0; | |
| data[pIdx + 3] = 255; | |
| } | |
| if (Math.abs(val) < 0.8) { | |
| data[pIdx] = 255; | |
| data[pIdx + 1] = 255; | |
| data[pIdx + 2] = 255; | |
| } | |
| } | |
| ctxSDF.putImageData(imgData, 0, 0); | |
| } | |
| // --- GRAPH ALGORITHMS --- | |
| function getSDFRadiusBilinear(x, y) { | |
| const x0 = Math.floor(x), | |
| y0 = Math.floor(y); | |
| const x0c = Math.max(0, Math.min(WIDTH - 1, x0)); | |
| const y0c = Math.max(0, Math.min(HEIGHT - 1, y0)); | |
| const x1c = Math.max(0, Math.min(WIDTH - 1, x0 + 1)); | |
| const y1c = Math.max(0, Math.min(HEIGHT - 1, y0 + 1)); | |
| const dx = x - x0, | |
| dy = y - y0; | |
| if (!cachedSDF) return 1.0; | |
| const v00 = Math.abs(cachedSDF[y0c * WIDTH + x0c]); | |
| const v10 = Math.abs(cachedSDF[y0c * WIDTH + x1c]); | |
| const v01 = Math.abs(cachedSDF[y1c * WIDTH + x0c]); | |
| const v11 = Math.abs(cachedSDF[y1c * WIDTH + x1c]); | |
| return ( | |
| (v00 * (1 - dx) + v10 * dx) * (1 - dy) + | |
| (v01 * (1 - dx) + v11 * dx) * dy | |
| ); | |
| } | |
| function buildAdjacencyFromVoronoi( | |
| delaunay, | |
| voronoi, | |
| isInsideGrid, | |
| ) { | |
| const adj = new Map(), | |
| nodeData = new Map(); | |
| const { halfedges } = delaunay; | |
| const circumcenters = voronoi.circumcenters; | |
| const addNode = (id, x, y) => { | |
| if (!nodeData.has(id)) nodeData.set(id, { x, y }); | |
| }; | |
| for (let i = 0; i < halfedges.length; i++) { | |
| const j = halfedges[i]; | |
| if (j < i) continue; | |
| if (j === -1) continue; | |
| const t1 = Math.floor(i / 3), | |
| t2 = Math.floor(j / 3); | |
| const x1 = circumcenters[t1 * 2], | |
| y1 = circumcenters[t1 * 2 + 1]; | |
| const x2 = circumcenters[t2 * 2], | |
| y2 = circumcenters[t2 * 2 + 1]; | |
| if (isInsideGrid(x1, y1) && isInsideGrid(x2, y2)) { | |
| addNode(t1, x1, y1); | |
| addNode(t2, x2, y2); | |
| if (!adj.has(t1)) adj.set(t1, []); | |
| if (!adj.has(t2)) adj.set(t2, []); | |
| adj.get(t1).push(t2); | |
| adj.get(t2).push(t1); | |
| } | |
| } | |
| return { adj, nodeData }; | |
| } | |
| function tracePolylines(adj, nodeData) { | |
| const nodes = Array.from(adj.keys()); | |
| const junctions = nodes.filter((n) => { | |
| const deg = adj.get(n)?.length || 0; | |
| return deg !== 2; | |
| }); | |
| const processedEdges = new Set(); | |
| const getEdgeKey = (a, b) => | |
| a < b ? `${a}-${b}` : `${b}-${a}`; | |
| const rawPolylines = []; | |
| const trace = (start, next) => { | |
| const path = [start, next]; | |
| processedEdges.add(getEdgeKey(start, next)); | |
| let curr = next, | |
| prev = start; | |
| while (adj.has(curr) && adj.get(curr).length === 2) { | |
| const nbs = adj.get(curr); | |
| const nextNode = nbs[0] === prev ? nbs[1] : nbs[0]; | |
| if ( | |
| nextNode === undefined || | |
| processedEdges.has(getEdgeKey(curr, nextNode)) | |
| ) | |
| break; | |
| path.push(nextNode); | |
| processedEdges.add(getEdgeKey(curr, nextNode)); | |
| prev = curr; | |
| curr = nextNode; | |
| if (curr === start) break; | |
| } | |
| return path; | |
| }; | |
| for (const startNode of junctions) { | |
| const neighbors = adj.get(startNode); | |
| if (!neighbors) continue; | |
| for (const nextNode of neighbors) { | |
| if (processedEdges.has(getEdgeKey(startNode, nextNode))) | |
| continue; | |
| rawPolylines.push( | |
| trace(startNode, nextNode).map((id) => { | |
| const n = nodeData.get(id); | |
| return [ | |
| n.x, | |
| n.y, | |
| getSDFRadiusBilinear(n.x, n.y), | |
| ]; | |
| }), | |
| ); | |
| } | |
| } | |
| for (const startNode of nodes) { | |
| if (adj.get(startNode).length === 2) { | |
| const neighbors = adj.get(startNode); | |
| for (const nextNode of neighbors) { | |
| if ( | |
| !processedEdges.has( | |
| getEdgeKey(startNode, nextNode), | |
| ) | |
| ) { | |
| rawPolylines.push( | |
| trace(startNode, nextNode).map((id) => { | |
| const n = nodeData.get(id); | |
| return [ | |
| n.x, | |
| n.y, | |
| getSDFRadiusBilinear(n.x, n.y), | |
| ]; | |
| }), | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| return rawPolylines; | |
| } | |
| function pruneHairs(polylines, pruneCoef) { | |
| const graph = new Map(); | |
| const getKey = (p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`; | |
| const addNode = (p) => { | |
| const k = getKey(p); | |
| if (!graph.has(k)) | |
| graph.set(k, { | |
| x: p[0], | |
| y: p[1], | |
| r: p[2] || getSDFRadiusBilinear(p[0], p[1]), | |
| neighbors: new Set(), | |
| }); | |
| return k; | |
| }; | |
| polylines.forEach((poly) => { | |
| for (let i = 0; i < poly.length - 1; i++) { | |
| const k1 = addNode(poly[i]), | |
| k2 = addNode(poly[i + 1]); | |
| if (k1 !== k2) { | |
| graph.get(k1).neighbors.add(k2); | |
| graph.get(k2).neighbors.add(k1); | |
| } | |
| } | |
| }); | |
| let changed = true; | |
| while (changed) { | |
| changed = false; | |
| const hairs = []; | |
| const visitedAsHair = new Set(); | |
| for (const [key, node] of graph.entries()) { | |
| if ( | |
| node.neighbors.size === 1 && | |
| !visitedAsHair.has(key) | |
| ) { | |
| let currentKey = key, | |
| path = [currentKey], | |
| length = 0, | |
| prevKey = null; | |
| while (true) { | |
| visitedAsHair.add(currentKey); | |
| const currentNode = graph.get(currentKey); | |
| let nextKey = null; | |
| for (const n of currentNode.neighbors) { | |
| if (n !== prevKey) { | |
| nextKey = n; | |
| break; | |
| } | |
| } | |
| if (!nextKey) break; | |
| const nextNode = graph.get(nextKey); | |
| length += Math.sqrt( | |
| (nextNode.x - currentNode.x) ** 2 + | |
| (nextNode.y - currentNode.y) ** 2, | |
| ); | |
| path.push(nextKey); | |
| if (nextNode.neighbors.size > 2) { | |
| hairs.push({ | |
| startKey: key, | |
| endKey: nextKey, | |
| pathKeys: path, | |
| length: length, | |
| looseEndRadius: graph.get(key).r, | |
| junctionRadius: nextNode.r, | |
| }); | |
| break; | |
| } else if (nextNode.neighbors.size === 1) break; | |
| prevKey = currentKey; | |
| currentKey = nextKey; | |
| } | |
| } | |
| } | |
| hairs.sort((a, b) => a.length - b.length); | |
| for (const hair of hairs) { | |
| if ( | |
| hair.length < 10.0 || | |
| hair.length + hair.looseEndRadius < | |
| hair.junctionRadius * pruneCoef | |
| ) { | |
| const junctionKey = | |
| hair.pathKeys[hair.pathKeys.length - 1]; | |
| const nodeBeforeJunction = | |
| hair.pathKeys[hair.pathKeys.length - 2]; | |
| if (graph.has(junctionKey)) | |
| graph | |
| .get(junctionKey) | |
| .neighbors.delete(nodeBeforeJunction); | |
| for (let i = 0; i < hair.pathKeys.length - 1; i++) | |
| graph.delete(hair.pathKeys[i]); | |
| changed = true; | |
| break; | |
| } | |
| } | |
| } | |
| const newPolylines = []; | |
| const visitedEdges = new Set(); | |
| const getEdgeKey = (k1, k2) => | |
| k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`; | |
| const allNodes = Array.from(graph.keys()); | |
| const processingOrder = [ | |
| ...allNodes.filter( | |
| (k) => graph.get(k).neighbors.size !== 2, | |
| ), | |
| ...allNodes, | |
| ]; | |
| for (const startKey of processingOrder) { | |
| const startNode = graph.get(startKey); | |
| if (!startNode) continue; | |
| for (const neighborKey of startNode.neighbors) { | |
| const edgeKey = getEdgeKey(startKey, neighborKey); | |
| if (visitedEdges.has(edgeKey)) continue; | |
| const poly = [[startNode.x, startNode.y, startNode.r]]; | |
| let currKey = neighborKey, | |
| prevKey = startKey; | |
| visitedEdges.add(edgeKey); | |
| while (true) { | |
| const currNode = graph.get(currKey); | |
| poly.push([currNode.x, currNode.y, currNode.r]); | |
| if (currNode.neighbors.size !== 2) break; | |
| let nextKey = null; | |
| for (const n of currNode.neighbors) { | |
| if (n !== prevKey) { | |
| nextKey = n; | |
| break; | |
| } | |
| } | |
| if (!nextKey) break; | |
| if (nextKey === startKey) { | |
| const startN = graph.get(startKey); | |
| poly.push([startN.x, startN.y, startN.r]); | |
| visitedEdges.add(getEdgeKey(currKey, startKey)); | |
| break; | |
| } | |
| visitedEdges.add(getEdgeKey(currKey, nextKey)); | |
| prevKey = currKey; | |
| currKey = nextKey; | |
| } | |
| newPolylines.push(poly); | |
| } | |
| } | |
| return newPolylines; | |
| } | |
| function calculateJunctions(polylines) { | |
| const junctions = [], | |
| counts = new Map(); | |
| const key = (p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`; | |
| polylines.forEach((poly) => { | |
| if (poly.length < 1) return; | |
| const s = key(poly[0]), | |
| e = key(poly[poly.length - 1]); | |
| counts.set(s, (counts.get(s) || 0) + 1); | |
| counts.set(e, (counts.get(e) || 0) + 1); | |
| }); | |
| for (const [k, count] of counts.entries()) | |
| if (count > 2) junctions.push(k.split(",").map(Number)); | |
| return junctions; | |
| } | |
| function buildRawEdgesCache(delaunay, voronoi, isInside) { | |
| cachedRawEdges = []; | |
| const { halfedges } = delaunay; | |
| const circumcenters = voronoi.circumcenters; | |
| for (let i = 0; i < halfedges.length; i++) { | |
| const j = halfedges[i]; | |
| if (j < i) continue; | |
| const t1 = Math.floor(i / 3), | |
| t2 = Math.floor(j / 3); | |
| const x1 = circumcenters[t1 * 2], | |
| y1 = circumcenters[t1 * 2 + 1]; | |
| const x2 = circumcenters[t2 * 2], | |
| y2 = circumcenters[t2 * 2 + 1]; | |
| if (isInside(x1, y1) && isInside(x2, y2)) | |
| cachedRawEdges.push({ x1, y1, x2, y2, id: i }); | |
| } | |
| } | |
| function buildPolyquadraticCurves(polylines) { | |
| cachedCurves = []; | |
| polylines.forEach((poly, polyIdx) => { | |
| if (poly.length < 2) return; | |
| // OPTIMIZATION: Single Segment | |
| if (poly.length === 2) { | |
| const p0 = poly[0], | |
| p1 = poly[1]; | |
| const midX = (p0[0] + p1[0]) / 2, | |
| midY = (p0[1] + p1[1]) / 2; | |
| // Linear interp for mid radius if segment is linear | |
| const midR = (p0[2] + p1[2]) / 2; | |
| cachedCurves.push({ | |
| xStart: p0[0], | |
| yStart: p0[1], | |
| rStart: p0[2], | |
| xCtrl: midX, | |
| yCtrl: midY, | |
| rCtrl: midR, | |
| xEnd: p1[0], | |
| yEnd: p1[1], | |
| rEnd: p1[2], | |
| polyIndex: polyIdx, | |
| }); | |
| return; | |
| } | |
| const sm = poly; | |
| const len = sm.length; | |
| // Check if loop | |
| const pStart = sm[0]; | |
| const pEnd = sm[len - 1]; | |
| const isClosed = | |
| Math.abs(pStart[0] - pEnd[0]) < 0.1 && | |
| Math.abs(pStart[1] - pEnd[1]) < 0.1; | |
| if (isClosed) { | |
| const loopPts = sm.slice(0, len - 1); | |
| const L = loopPts.length; | |
| for (let i = 0; i < L; i++) { | |
| const prev = loopPts[(i - 1 + L) % L]; | |
| const curr = loopPts[i]; | |
| const next = loopPts[(i + 1) % L]; | |
| const startX = (prev[0] + curr[0]) / 2; | |
| const startY = (prev[1] + curr[1]) / 2; | |
| const startR = (prev[2] + curr[2]) / 2; | |
| const endX = (curr[0] + next[0]) / 2; | |
| const endY = (curr[1] + next[1]) / 2; | |
| const endR = (curr[2] + next[2]) / 2; | |
| cachedCurves.push({ | |
| xStart: startX, | |
| yStart: startY, | |
| rStart: startR, | |
| xCtrl: curr[0], | |
| yCtrl: curr[1], | |
| rCtrl: curr[2], | |
| xEnd: endX, | |
| yEnd: endY, | |
| rEnd: endR, | |
| polyIndex: polyIdx, | |
| }); | |
| } | |
| return; | |
| } | |
| // 1. FIRST BEZIER | |
| const p0 = sm[0], | |
| p1 = sm[1]; | |
| const mid01 = [ | |
| (p0[0] + p1[0]) / 2, | |
| (p0[1] + p1[1]) / 2, | |
| (p0[2] + p1[2]) / 2, | |
| ]; | |
| const c0 = [ | |
| p0[0] + (p1[0] - p0[0]) * 0.25, | |
| p0[1] + (p1[1] - p0[1]) * 0.25, | |
| p0[2] + (p1[2] - p0[2]) * 0.25, | |
| ]; | |
| cachedCurves.push({ | |
| xStart: p0[0], | |
| yStart: p0[1], | |
| rStart: p0[2], | |
| xCtrl: c0[0], | |
| yCtrl: c0[1], | |
| rCtrl: c0[2], | |
| xEnd: mid01[0], | |
| yEnd: mid01[1], | |
| rEnd: mid01[2], | |
| polyIndex: polyIdx, | |
| }); | |
| // 2. MIDDLE BEZIERS | |
| for (let i = 1; i < len - 1; i++) { | |
| const prev = sm[i - 1], | |
| curr = sm[i], | |
| next = sm[i + 1]; | |
| const startX = (prev[0] + curr[0]) / 2, | |
| startY = (prev[1] + curr[1]) / 2, | |
| startR = (prev[2] + curr[2]) / 2; | |
| const endX = (curr[0] + next[0]) / 2, | |
| endY = (curr[1] + next[1]) / 2, | |
| endR = (curr[2] + next[2]) / 2; | |
| cachedCurves.push({ | |
| xStart: startX, | |
| yStart: startY, | |
| rStart: startR, | |
| xCtrl: curr[0], | |
| yCtrl: curr[1], | |
| rCtrl: curr[2], | |
| xEnd: endX, | |
| yEnd: endY, | |
| rEnd: endR, | |
| polyIndex: polyIdx, | |
| }); | |
| } | |
| // 3. LAST BEZIER | |
| const pn = sm[len - 1], | |
| pn1 = sm[len - 2]; | |
| const midEnd = [ | |
| (pn1[0] + pn[0]) / 2, | |
| (pn1[1] + pn[1]) / 2, | |
| (pn1[2] + pn[2]) / 2, | |
| ]; | |
| const cn = [ | |
| pn1[0] + (pn[0] - pn1[0]) * 0.75, | |
| pn1[1] + (pn[1] - pn1[1]) * 0.75, | |
| pn1[2] + (pn[2] - pn1[2]) * 0.75, | |
| ]; | |
| cachedCurves.push({ | |
| xStart: midEnd[0], | |
| yStart: midEnd[1], | |
| rStart: midEnd[2], | |
| xCtrl: cn[0], | |
| yCtrl: cn[1], | |
| rCtrl: cn[2], | |
| xEnd: pn[0], | |
| yEnd: pn[1], | |
| rEnd: pn[2], | |
| polyIndex: polyIdx, | |
| }); | |
| }); | |
| } | |
| function ramerDouglasPeucker(points, epsilon) { | |
| if (points.length < 3) return points; | |
| let dmax = 0, | |
| index = 0; | |
| const end = points.length - 1; | |
| for (let i = 1; i < end; i++) { | |
| const d = perpendicularDistance( | |
| points[i], | |
| points[0], | |
| points[end], | |
| ); | |
| if (d > dmax) { | |
| index = i; | |
| dmax = d; | |
| } | |
| } | |
| if (dmax > epsilon) { | |
| const res1 = ramerDouglasPeucker( | |
| points.slice(0, index + 1), | |
| epsilon, | |
| ); | |
| const res2 = ramerDouglasPeucker( | |
| points.slice(index, end + 1), | |
| epsilon, | |
| ); | |
| return res1.slice(0, res1.length - 1).concat(res2); | |
| } else { | |
| return [points[0], points[end]]; | |
| } | |
| } | |
| // IMPORTANT: This RDP implementation is 3D (X, Y, Radius). | |
| // Do NOT simplify to 2D (X, Y) as it will lose stroke width variations. | |
| function perpendicularDistance(p, lineStart, lineEnd) { | |
| let dx = lineEnd[0] - lineStart[0]; | |
| let dy = lineEnd[1] - lineStart[1]; | |
| let dr = lineEnd[2] - lineStart[2]; // Radius/Z component | |
| let magSq = dx * dx + dy * dy + dr * dr; | |
| if (magSq > 0) { | |
| // Project point onto the line segment (3D dot product) | |
| const t = Math.max( | |
| 0, | |
| Math.min( | |
| 1, | |
| ((p[0] - lineStart[0]) * dx + | |
| (p[1] - lineStart[1]) * dy + | |
| (p[2] - lineStart[2]) * dr) / | |
| magSq, | |
| ), | |
| ); | |
| // Distance from point to the closest point on segment | |
| return Math.sqrt( | |
| (p[0] - (lineStart[0] + t * dx)) ** 2 + | |
| (p[1] - (lineStart[1] + t * dy)) ** 2 + | |
| (p[2] - (lineStart[2] + t * dr)) ** 2, | |
| ); | |
| } else { | |
| // Start and end are the same point | |
| return Math.sqrt( | |
| (p[0] - lineStart[0]) ** 2 + | |
| (p[1] - lineStart[1]) ** 2 + | |
| (p[2] - lineStart[2]) ** 2, | |
| ); | |
| } | |
| } | |
| // --- NEW MATH: 3D Bezier Fit --- | |
| function vectorLen3(a) { | |
| return Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); | |
| } | |
| function vectorSub3(a, b) { | |
| return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; | |
| } | |
| function vectorAdd3(a, b) { | |
| return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; | |
| } | |
| function vectorScale3(a, s) { | |
| return [a[0] * s, a[1] * s, a[2] * s]; | |
| } | |
| // Chord length parameterization | |
| function parameterize3(points) { | |
| const u = [0]; | |
| let total = 0; | |
| for (let i = 1; i < points.length; i++) { | |
| total += vectorLen3(vectorSub3(points[i], points[i - 1])); | |
| u.push(total); | |
| } | |
| if (total === 0) return u; // All points same | |
| for (let i = 0; i < u.length; i++) u[i] /= total; | |
| return u; | |
| } | |
| function fitQuadratic3D(points, u) { | |
| if (points.length < 3) { | |
| // Linear case: P1 is midpoint | |
| const p0 = points[0]; | |
| const p2 = points[points.length - 1]; | |
| return [ | |
| (p0[0] + p2[0]) / 2, | |
| (p0[1] + p2[1]) / 2, | |
| (p0[2] + p2[2]) / 2, | |
| ]; | |
| } | |
| const p0 = points[0]; | |
| const p2 = points[points.length - 1]; | |
| let num = [0, 0, 0]; | |
| let den = 0; | |
| for (let i = 1; i < points.length - 1; i++) { | |
| const t = u[i]; | |
| const tInv = 1 - t; | |
| // B(t) = (1-t)^2 P0 + 2t(1-t) P1 + t^2 P2 | |
| // Rearrange: 2t(1-t) P1 = Pi - (1-t)^2 P0 - t^2 P2 | |
| // W = 2t(1-t) | |
| // A = (1-t)^2 P0 + t^2 P2 | |
| // Solve W * P1 = Pi - A | |
| const w = 2 * t * tInv; | |
| const termA = vectorAdd3( | |
| vectorScale3(p0, tInv * tInv), | |
| vectorScale3(p2, t * t), | |
| ); | |
| const diff = vectorSub3(points[i], termA); | |
| // Weighted Least Squares accumulation | |
| // P1 = (Sum W * Diff) / (Sum W^2) | |
| num = vectorAdd3(num, vectorScale3(diff, w)); | |
| den += w * w; | |
| } | |
| if (den === 0) | |
| return [ | |
| (p0[0] + p2[0]) / 2, | |
| (p0[1] + p2[1]) / 2, | |
| (p0[2] + p2[2]) / 2, | |
| ]; | |
| const p1 = vectorScale3(num, 1 / den); | |
| // --- HYBRID RDP LOGIC (Updated) --- | |
| // Force P1 radius to be the interpolated radius at t=0.5 | |
| // We find the segment of the original data containing t=0.5 | |
| // and linear interpolate the radius. | |
| let rTarget = (p0[2] + p2[2]) / 2; // Default fallback | |
| let found = false; | |
| for (let i = 0; i < u.length - 1; i++) { | |
| if (u[i] <= 0.5 && u[i + 1] >= 0.5) { | |
| const tSpan = u[i + 1] - u[i]; | |
| if (tSpan > 1e-6) { | |
| const localT = (0.5 - u[i]) / tSpan; | |
| rTarget = | |
| points[i][2] * (1.0 - localT) + | |
| points[i + 1][2] * localT; | |
| } else { | |
| rTarget = points[i][2]; | |
| } | |
| found = true; | |
| break; | |
| } | |
| } | |
| // If 0.5 is not covered (rare/floating point issues), rely on closest search or default. | |
| // But since u goes 0->1, it should be covered. | |
| p1[2] = rTarget; | |
| return p1; | |
| } | |
| function computeMaxError3D(points, p0, p1, p2, u) { | |
| let maxDist = 0; | |
| let splitPoint = 0; | |
| // --- Radius Interpolation (Monotonic Cubic) --- | |
| // Matches sdBezierUnevenCapsule logic | |
| const r0 = p0[2]; | |
| const r1 = p1[2]; | |
| const r2 = p2[2]; | |
| const d10 = r1 - r0; | |
| const d20 = r2 - r0; | |
| const stepVal = d10 * (r2 - r1) >= 0.0 ? 1.0 : 0.0; | |
| const slopeMid = | |
| Math.sign(d10) * | |
| Math.min( | |
| Math.abs(d20), | |
| 4.0 * Math.min(Math.abs(d10), Math.abs(r2 - r1)), | |
| ) * | |
| stepVal; | |
| const cubicA = 4.0 * (d20 - slopeMid); | |
| const cubicC = 4.0 * d10 + d20 - 2.0 * slopeMid; | |
| const cubicB = d20 - cubicA - cubicC; | |
| for (let i = 1; i < points.length - 1; i++) { | |
| const t = u[i]; | |
| const tInv = 1 - t; | |
| // Eval Bezier Position (Quadratic) | |
| // B(t) = (1-t)^2 P0 + 2t(1-t) P1 + t^2 P2 | |
| const bx = | |
| tInv * tInv * p0[0] + | |
| 2 * t * tInv * p1[0] + | |
| t * t * p2[0]; | |
| const by = | |
| tInv * tInv * p0[1] + | |
| 2 * t * tInv * p1[1] + | |
| t * t * p2[1]; | |
| // Eval Radius (Monotonic Cubic) | |
| const br = ((cubicA * t + cubicB) * t + cubicC) * t + r0; | |
| const b = [bx, by, br]; | |
| // This distance check includes the Z (radius) dimension. | |
| const dist = vectorLen3(vectorSub3(points[i], b)); | |
| if (dist > maxDist) { | |
| maxDist = dist; | |
| splitPoint = i; | |
| } | |
| } | |
| return { maxDist, splitPoint }; | |
| } | |
| function simplifyBezierRDP(points, epsilon) { | |
| if (points.length < 3) { | |
| // Just return linear segment as bezier | |
| if (points.length < 2) return []; | |
| const p0 = points[0], | |
| p2 = points[points.length - 1]; | |
| const p1 = [ | |
| (p0[0] + p2[0]) / 2, | |
| (p0[1] + p2[1]) / 2, | |
| (p0[2] + p2[2]) / 2, | |
| ]; | |
| return [{ p0, p1, p2 }]; | |
| } | |
| const u = parameterize3(points); | |
| const p0 = points[0]; | |
| const p2 = points[points.length - 1]; | |
| // Calculate the best-fit curve | |
| const p1 = fitQuadratic3D(points, u); | |
| const { maxDist, splitPoint } = computeMaxError3D( | |
| points, | |
| p0, | |
| p1, | |
| p2, | |
| u, | |
| ); | |
| if (maxDist < epsilon) { | |
| return [{ p0: p0, p1: p1, p2: p2 }]; | |
| } else { | |
| const left = simplifyBezierRDP( | |
| points.slice(0, splitPoint + 1), | |
| epsilon, | |
| ); | |
| const right = simplifyBezierRDP( | |
| points.slice(splitPoint), | |
| epsilon, | |
| ); | |
| return left.concat(right); | |
| } | |
| } | |
| // --- PIPELINE & MAIN LOGIC --- | |
| // Moved compute functions here to ensure definition before usage in process() | |
| // NEW: Dynamic precision helper | |
| const getPrec = () => { | |
| const d = parseInt(inputs.decimals.value); | |
| if (d === 0) return Math.round; | |
| return (n) => Number(n.toFixed(d)); | |
| }; | |
| function computeVectorPipelineHeadless(grid) { | |
| const points = []; | |
| const step = 1; // Hardcoded high detail (was inputs.threshold default 0.1 -> step 1) | |
| const prec = getPrec(); | |
| const bezierErr = parseFloat(inputs.bezier.value); | |
| for (let y = 1; y < HEIGHT - 1; y += 1) { | |
| for (let x = 1; x < WIDTH - 1; x += 1) { | |
| if (grid[y * WIDTH + x] === 1) { | |
| if ( | |
| grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0 || | |
| grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0 | |
| ) { | |
| if ( | |
| ((grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0) && | |
| x % step === 0) || | |
| ((grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0) && | |
| y % step === 0) || | |
| ((grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0) && | |
| (grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0)) | |
| ) | |
| points.push([x + 0.5, y + 0.5]); | |
| } | |
| } | |
| } | |
| } | |
| if (points.length < 3) | |
| return { advance: currentMetrics.advance, curves: [] }; | |
| const delaunay = d3.Delaunay.from(points); | |
| const voronoi = delaunay.voronoi([0, 0, WIDTH, HEIGHT]); | |
| const isInside = (x, y) => { | |
| const ix = Math.floor(x), | |
| iy = Math.floor(y); | |
| return ( | |
| ix >= 0 && | |
| ix < WIDTH && | |
| iy >= 0 && | |
| iy < HEIGHT && | |
| grid[iy * WIDTH + ix] === 1 | |
| ); | |
| }; | |
| let { adj, nodeData } = buildAdjacencyFromVoronoi( | |
| delaunay, | |
| voronoi, | |
| isInside, | |
| ); | |
| let rawPolylines = tracePolylines(adj, nodeData); | |
| if (parseFloat(inputs.prune.value) > 0) | |
| rawPolylines = pruneHairs( | |
| rawPolylines, | |
| parseFloat(inputs.prune.value), | |
| ); | |
| // CHANGED: No RDP Simplification Step | |
| let simplified = rawPolylines; | |
| const curvesFlat = []; | |
| if (bezierErr > 0.0) { | |
| // NEW PATH: Bezier RDP | |
| simplified.forEach((simplePoly, polyIdx) => { | |
| if (simplePoly.length < 2) return; | |
| let polyToFit = simplePoly; // Just use the polyline directly | |
| const fitted = simplifyBezierRDP(polyToFit, bezierErr); | |
| fitted.forEach((c) => { | |
| curvesFlat.push( | |
| prec(c.p0[0] - currentMetrics.originX), | |
| prec(c.p0[1] - TOP_MARGIN), | |
| prec(c.p0[2]), | |
| prec(c.p1[0] - currentMetrics.originX), | |
| prec(c.p1[1] - TOP_MARGIN), | |
| prec(c.p1[2]), | |
| prec(c.p2[0] - currentMetrics.originX), | |
| prec(c.p2[1] - TOP_MARGIN), | |
| prec(c.p2[2]), | |
| ); | |
| }); | |
| }); | |
| } else { | |
| // OLD PATH: Polyquadratic | |
| let curveGeometry = simplified.map((poly) => | |
| poly.map((pt) => [...pt]), | |
| ); | |
| curveGeometry.forEach((poly) => { | |
| if (poly.length < 2) return; | |
| const sm = poly; | |
| const len = sm.length; | |
| if (poly.length === 2) { | |
| const p0 = poly[0], | |
| p1 = poly[1]; | |
| const mx = (p0[0] + p1[0]) / 2, | |
| my = (p0[1] + p1[1]) / 2, | |
| mr = (p0[2] + p1[2]) / 2; | |
| curvesFlat.push( | |
| prec(p0[0] - currentMetrics.originX), | |
| prec(p0[1] - TOP_MARGIN), | |
| prec(p0[2]), | |
| prec(mx - currentMetrics.originX), | |
| prec(my - TOP_MARGIN), | |
| prec(mr), | |
| prec(p1[0] - currentMetrics.originX), | |
| prec(p1[1] - TOP_MARGIN), | |
| prec(p1[2]), | |
| ); | |
| return; | |
| } | |
| const isClosed = | |
| Math.abs(sm[0][0] - sm[len - 1][0]) < 0.1 && | |
| Math.abs(sm[0][1] - sm[len - 1][1]) < 0.1; | |
| if (isClosed) { | |
| const loopPts = sm.slice(0, len - 1); | |
| const L = loopPts.length; | |
| for (let i = 0; i < L; i++) { | |
| const prev = loopPts[(i - 1 + L) % L], | |
| curr = loopPts[i], | |
| next = loopPts[(i + 1) % L]; | |
| curvesFlat.push( | |
| prec( | |
| (prev[0] + curr[0]) / 2 - | |
| currentMetrics.originX, | |
| ), | |
| prec((prev[1] + curr[1]) / 2 - TOP_MARGIN), | |
| prec((prev[2] + curr[2]) / 2), | |
| prec(curr[0] - currentMetrics.originX), | |
| prec(curr[1] - TOP_MARGIN), | |
| prec(curr[2]), | |
| prec( | |
| (curr[0] + next[0]) / 2 - | |
| currentMetrics.originX, | |
| ), | |
| prec((curr[1] + next[1]) / 2 - TOP_MARGIN), | |
| prec((curr[2] + next[2]) / 2), | |
| ); | |
| } | |
| return; | |
| } | |
| // Open curve: 1st segment | |
| let p0 = sm[0], | |
| p1 = sm[1]; | |
| let c0 = [ | |
| p0[0] + (p1[0] - p0[0]) * 0.25, | |
| p0[1] + (p1[1] - p0[1]) * 0.25, | |
| p0[2] + (p1[2] - p0[2]) * 0.25, | |
| ]; | |
| curvesFlat.push( | |
| prec(p0[0] - currentMetrics.originX), | |
| prec(p0[1] - TOP_MARGIN), | |
| prec(p0[2]), | |
| prec(c0[0] - currentMetrics.originX), | |
| prec(c0[1] - TOP_MARGIN), | |
| prec(c0[2]), | |
| prec((p0[0] + p1[0]) / 2 - currentMetrics.originX), | |
| prec((p0[1] + p1[1]) / 2 - TOP_MARGIN), | |
| prec((p0[2] + p1[2]) / 2), | |
| ); | |
| // Middle | |
| for (let i = 1; i < len - 1; i++) { | |
| const prev = sm[i - 1], | |
| curr = sm[i], | |
| next = sm[i + 1]; | |
| curvesFlat.push( | |
| prec( | |
| (prev[0] + curr[0]) / 2 - | |
| currentMetrics.originX, | |
| ), | |
| prec((prev[1] + curr[1]) / 2 - TOP_MARGIN), | |
| prec((prev[2] + curr[2]) / 2), | |
| prec(curr[0] - currentMetrics.originX), | |
| prec(curr[1] - TOP_MARGIN), | |
| prec(curr[2]), | |
| prec( | |
| (curr[0] + next[0]) / 2 - | |
| currentMetrics.originX, | |
| ), | |
| prec((curr[1] + next[1]) / 2 - TOP_MARGIN), | |
| prec((curr[2] + next[2]) / 2), | |
| ); | |
| } | |
| // Last | |
| let pn = sm[len - 1], | |
| pn1 = sm[len - 2]; | |
| let cn = [ | |
| pn1[0] + (pn[0] - pn1[0]) * 0.75, | |
| pn1[1] + (pn[1] - pn1[1]) * 0.75, | |
| pn1[2] + (pn[2] - pn1[2]) * 0.75, | |
| ]; | |
| curvesFlat.push( | |
| prec((pn1[0] + pn[0]) / 2 - currentMetrics.originX), | |
| prec((pn1[1] + pn[1]) / 2 - TOP_MARGIN), | |
| prec((pn1[2] + pn[2]) / 2), | |
| prec(cn[0] - currentMetrics.originX), | |
| prec(cn[1] - TOP_MARGIN), | |
| prec(cn[2]), | |
| prec(pn[0] - currentMetrics.originX), | |
| prec(pn[1] - TOP_MARGIN), | |
| prec(pn[2]), | |
| ); | |
| }); | |
| } | |
| return { advance: currentMetrics.advance, curves: curvesFlat }; | |
| } | |
| function computeVectorPipeline(grid) { | |
| cachedGrid = grid; | |
| updateGhostCanvas(grid); | |
| const step = 1; // Hardcoded high detail | |
| const prec = getPrec(); | |
| const bezierErr = parseFloat(inputs.bezier.value); | |
| const points = []; | |
| for (let y = 1; y < HEIGHT - 1; y += 1) { | |
| for (let x = 1; x < WIDTH - 1; x += 1) { | |
| if (grid[y * WIDTH + x] === 1) { | |
| if ( | |
| grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0 || | |
| grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0 | |
| ) { | |
| if ( | |
| ((grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0) && | |
| x % step === 0) || | |
| ((grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0) && | |
| y % step === 0) || | |
| ((grid[(y - 1) * WIDTH + x] === 0 || | |
| grid[(y + 1) * WIDTH + x] === 0) && | |
| (grid[y * WIDTH + x - 1] === 0 || | |
| grid[y * WIDTH + x + 1] === 0)) | |
| ) | |
| points.push([x + 0.5, y + 0.5]); | |
| } | |
| } | |
| } | |
| } | |
| if (points.length < 3) return; | |
| const delaunay = d3.Delaunay.from(points); | |
| const voronoi = delaunay.voronoi([0, 0, WIDTH, HEIGHT]); | |
| cachedVoronoiData = { delaunay, voronoi }; | |
| const isInside = (x, y) => { | |
| const ix = Math.floor(x), | |
| iy = Math.floor(y); | |
| return ( | |
| ix >= 0 && | |
| ix < WIDTH && | |
| iy >= 0 && | |
| iy < HEIGHT && | |
| grid[iy * WIDTH + ix] === 1 | |
| ); | |
| }; | |
| buildRawEdgesCache(delaunay, voronoi, isInside); | |
| let { adj, nodeData } = buildAdjacencyFromVoronoi( | |
| delaunay, | |
| voronoi, | |
| isInside, | |
| ); | |
| let rawPolylines = tracePolylines(adj, nodeData); | |
| if (parseFloat(inputs.prune.value) > 0) | |
| rawPolylines = pruneHairs( | |
| rawPolylines, | |
| parseFloat(inputs.prune.value), | |
| ); | |
| cachedPrunedData = { | |
| polylines: rawPolylines, | |
| junctions: calculateJunctions(rawPolylines), | |
| }; | |
| // CHANGED: No RDP Simplification Step. simplified is just raw polylines. | |
| let simplified = rawPolylines; | |
| cachedRDPOnlyData = { | |
| polylines: simplified, | |
| junctions: calculateJunctions(simplified), | |
| isBezier: false, | |
| }; | |
| cachedCurves = []; | |
| if (bezierErr > 0.0) { | |
| // NEW PATH: Bezier Fitting | |
| const bezierVisuals = []; | |
| simplified.forEach((simplePoly, polyIdx) => { | |
| if (simplePoly.length < 2) return; | |
| let polyToFit = simplePoly; | |
| const fitted = simplifyBezierRDP(polyToFit, bezierErr); | |
| // Construct Visual Polygon for View 5 (Start -> Control -> End) | |
| const vizPoly = []; | |
| if (fitted.length > 0) { | |
| vizPoly.push(fitted[0].p0); | |
| fitted.forEach((c) => { | |
| vizPoly.push(c.p1); | |
| vizPoly.push(c.p2); | |
| }); | |
| } | |
| bezierVisuals.push(vizPoly); | |
| fitted.forEach((c) => { | |
| cachedCurves.push({ | |
| xStart: c.p0[0], | |
| yStart: c.p0[1], | |
| rStart: c.p0[2], | |
| xCtrl: c.p1[0], | |
| yCtrl: c.p1[1], | |
| rCtrl: c.p1[2], | |
| xEnd: c.p2[0], | |
| yEnd: c.p2[1], | |
| rEnd: c.p2[2], | |
| polyIndex: polyIdx, | |
| }); | |
| }); | |
| }); | |
| // Update View 5 Data to show Bezier Skeleton | |
| cachedRDPOnlyData = { | |
| polylines: bezierVisuals, | |
| junctions: [], | |
| isBezier: true, | |
| }; | |
| } else { | |
| // OLD PATH: Polyquadratic (Exact Mode now) | |
| let curveGeometry = simplified.map((poly) => | |
| poly.map((pt) => [...pt]), | |
| ); | |
| buildPolyquadraticCurves(curveGeometry); | |
| } | |
| if (jsonOutput) { | |
| const flatGeometry = []; | |
| cachedCurves.forEach((c) => { | |
| flatGeometry.push( | |
| prec(c.xStart - currentMetrics.originX), | |
| prec(c.yStart - TOP_MARGIN), | |
| prec(c.rStart), | |
| prec(c.xCtrl - currentMetrics.originX), | |
| prec(c.yCtrl - TOP_MARGIN), | |
| prec(c.rCtrl), | |
| prec(c.xEnd - currentMetrics.originX), | |
| prec(c.yEnd - TOP_MARGIN), | |
| prec(c.rEnd), | |
| ); | |
| }); | |
| // Save this flatGeometry for View 6 rendering! | |
| cachedFlatCurveData = flatGeometry; | |
| const charData = { | |
| charCode: (inputs.char.value || " ").charCodeAt(0), | |
| advance: currentMetrics.advance, | |
| curves: flatGeometry, | |
| }; | |
| jsonOutput.value = `{\n "metrics": ${JSON.stringify({ lineHeight: currentMetrics.lineHeight, baseline: currentMetrics.baseline - TOP_MARGIN })},\n "chars": [\n ${JSON.stringify(charData)}\n ]\n}`; | |
| } | |
| drawVectorLayers(); | |
| } | |
| function updateUIValues() { | |
| uiVals.prune.innerText = inputs.prune.value; | |
| // REMOVED RDP UI UPDATE | |
| uiVals.bezier.innerText = inputs.bezier.value; | |
| uiVals.decimals.innerText = inputs.decimals.value; | |
| } | |
| function process() { | |
| if (inputs.font.value === "__SCAN__") return; | |
| updateUIValues(); | |
| const char = inputs.char.value || "A"; | |
| renderCharacter(char); | |
| const imageData = ctxRender.getImageData(0, 0, WIDTH, HEIGHT); | |
| const grid = getBinaryGrid(imageData); | |
| const distOutside = computeEDT(grid, 1), | |
| distInside = computeEDT(grid, 0); | |
| const sdf = new Float32Array(WIDTH * HEIGHT); | |
| let minVal = INF, | |
| maxVal = -INF; | |
| for (let i = 0; i < sdf.length; i++) { | |
| sdf[i] = | |
| Math.sqrt(distOutside[i]) - Math.sqrt(distInside[i]); | |
| if (sdf[i] < minVal) minVal = sdf[i]; | |
| if (sdf[i] > maxVal) maxVal = sdf[i]; | |
| } | |
| cachedSDF = sdf; | |
| visualizeSDF(sdf, minVal, maxVal); | |
| computeVectorPipeline(grid); | |
| } | |
| // --- BATCH & PIPELINE --- | |
| btnGenerateASCII.addEventListener("click", () => | |
| generateAtlas("ascii"), | |
| ); | |
| btnExportAll.addEventListener("click", () => generateAtlas("all")); | |
| async function generateAtlas(mode) { | |
| const activeBtn = | |
| mode === "ascii" ? btnGenerateASCII : btnExportAll; | |
| activeBtn.disabled = true; | |
| activeBtn.classList.add("opacity-50"); | |
| batchStatus.classList.remove("hidden"); | |
| let charCodes = | |
| mode === "ascii" | |
| ? Array.from({ length: 95 }, (_, i) => i + 32) | |
| : loadedFontCharCodes; | |
| if ( | |
| mode === "all" && | |
| (!loadedFontCharCodes.length || !customFontName) | |
| ) { | |
| // Replaced alert with UI status message | |
| batchStatus.innerText = "Error: No custom font loaded."; | |
| batchStatus.classList.remove("hidden"); | |
| activeBtn.disabled = false; | |
| activeBtn.classList.remove("opacity-50"); | |
| return; | |
| } | |
| renderCharacter("H"); | |
| const globalMetrics = { | |
| lineHeight: currentMetrics.lineHeight, | |
| baseline: currentMetrics.baseline - TOP_MARGIN, | |
| }; | |
| const charsData = []; | |
| const originalChar = inputs.char.value; | |
| for (let i = 0; i < charCodes.length; i++) { | |
| const char = String.fromCharCode(charCodes[i]); | |
| batchStatus.innerText = `Processing '${char}' (${i + 1}/${charCodes.length})...`; | |
| await new Promise((resolve) => setTimeout(resolve, 0)); | |
| renderCharacter(char); | |
| const grid = getBinaryGrid( | |
| ctxRender.getImageData(0, 0, WIDTH, HEIGHT), | |
| ); | |
| const distOutside = computeEDT(grid, 1), | |
| distInside = computeEDT(grid, 0); | |
| const sdf = new Float32Array(WIDTH * HEIGHT); | |
| for (let k = 0; k < sdf.length; k++) | |
| sdf[k] = | |
| Math.sqrt(distOutside[k]) - | |
| Math.sqrt(distInside[k]); | |
| cachedSDF = sdf; | |
| const fullData = computeVectorPipelineHeadless(grid); | |
| charsData.push({ | |
| charCode: charCodes[i], | |
| advance: fullData.advance, | |
| curves: fullData.curves, | |
| }); | |
| } | |
| inputs.char.value = originalChar; | |
| batchStatus.innerText = "Done!"; | |
| activeBtn.disabled = false; | |
| activeBtn.classList.remove("opacity-50"); | |
| const blob = new Blob( | |
| [ | |
| `{\n "metrics": ${JSON.stringify(globalMetrics)},\n "chars": [\n ` + | |
| charsData | |
| .map((c) => JSON.stringify(c)) | |
| .join(",\n ") + | |
| "\n ]\n}", | |
| ], | |
| { type: "application/json" }, | |
| ); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); | |
| // Changed filenames to font_curve_ascii.json / font_curve_complete.json | |
| a.download = | |
| mode === "ascii" | |
| ? "font_curve_ascii.json" | |
| : "font_curve_complete.json"; | |
| a.click(); | |
| process(); | |
| } | |
| function renderCharacter(char) { | |
| ctxRender.clearRect(0, 0, WIDTH, HEIGHT); | |
| const fontSize = Math.floor(HEIGHT * 0.7); | |
| ctxRender.font = `${inputs.italic.checked ? "italic " : ""}${inputs.bold.checked ? "bold " : ""}${fontSize}px "${inputs.font.value}"`; | |
| ctxRender.fillStyle = "black"; | |
| ctxRender.textAlign = "left"; | |
| ctxRender.textBaseline = "alphabetic"; | |
| const refMetrics = ctxRender.measureText("H"); | |
| const baselineY = | |
| TOP_MARGIN + | |
| (refMetrics.fontBoundingBoxAscent || | |
| refMetrics.actualBoundingBoxAscent + fontSize * 0.2); | |
| const metrics = ctxRender.measureText(char); | |
| const drawX = Math.floor( | |
| WIDTH / 2 - | |
| (metrics.actualBoundingBoxRight - | |
| metrics.actualBoundingBoxLeft) / | |
| 2 - | |
| metrics.actualBoundingBoxLeft, | |
| ); | |
| ctxRender.fillText(char, drawX, baselineY); | |
| currentMetrics = { | |
| advance: Math.round(metrics.width), | |
| lineHeight: Math.round(fontSize * 1.2), | |
| baseline: Math.round(baselineY), | |
| originX: drawX, | |
| }; | |
| } | |
| // --- VISUALIZATION HELPERS --- | |
| function updateGhostCanvas(grid) { | |
| const imgData = ghostCtx.createImageData(WIDTH, HEIGHT); | |
| for (let i = 0; i < grid.length; i++) | |
| if (grid[i] === 1) { | |
| const idx = i * 4; | |
| imgData.data[idx] = 31; | |
| imgData.data[idx + 1] = 41; | |
| imgData.data[idx + 2] = 55; | |
| imgData.data[idx + 3] = 255; | |
| } | |
| ghostCtx.putImageData(imgData, 0, 0); | |
| } | |
| function drawVectorLayers() { | |
| if ( | |
| !cachedVoronoiData || | |
| !cachedRDPOnlyData || | |
| !cachedPrunedData | |
| ) | |
| return; | |
| drawRawVoronoi(); | |
| drawPrunedGraph(); | |
| drawRDPOnlyGraph(); | |
| drawReconstruction(); | |
| } | |
| function drawRawVoronoi() { | |
| ctxVoronoi.setTransform(1, 0, 0, 1, 0, 0); | |
| ctxVoronoi.fillStyle = "#111827"; | |
| ctxVoronoi.fillRect(0, 0, WIDTH, HEIGHT); | |
| ctxVoronoi.translate(viewTransform.x, viewTransform.y); | |
| ctxVoronoi.scale(viewTransform.k, viewTransform.k); | |
| ctxVoronoi.drawImage(ghostCanvas, 0, 0); | |
| ctxVoronoi.lineWidth = 1; | |
| ctxVoronoi.strokeStyle = "#4ade80"; | |
| ctxVoronoi.beginPath(); | |
| cachedRawEdges.forEach((edge) => { | |
| if (edge.id !== hoveredRawEdgeIndex) { | |
| ctxVoronoi.moveTo(edge.x1, edge.y1); | |
| ctxVoronoi.lineTo(edge.x2, edge.y2); | |
| } | |
| }); | |
| ctxVoronoi.stroke(); | |
| if (hoveredRawEdgeIndex !== -1) { | |
| const e = cachedRawEdges.find( | |
| (e) => e.id === hoveredRawEdgeIndex, | |
| ); | |
| if (e) { | |
| ctxVoronoi.lineWidth = 3; | |
| ctxVoronoi.strokeStyle = "#facc15"; | |
| ctxVoronoi.beginPath(); | |
| ctxVoronoi.moveTo(e.x1, e.y1); | |
| ctxVoronoi.lineTo(e.x2, e.y2); | |
| ctxVoronoi.stroke(); | |
| } | |
| } | |
| uiRawCount.innerText = `${cachedRawEdges.length} segs`; | |
| } | |
| function drawPrunedGraph() { | |
| ctxPruned.setTransform(1, 0, 0, 1, 0, 0); | |
| ctxPruned.fillStyle = "#111827"; | |
| ctxPruned.fillRect(0, 0, WIDTH, HEIGHT); | |
| ctxPruned.translate(viewTransform.x, viewTransform.y); | |
| ctxPruned.scale(viewTransform.k, viewTransform.k); | |
| ctxPruned.drawImage(ghostCanvas, 0, 0); | |
| ctxPruned.lineWidth = 1; | |
| ctxPruned.strokeStyle = "#f59e0b"; | |
| ctxPruned.beginPath(); | |
| let c = 0; | |
| cachedPrunedData.polylines.forEach((p) => { | |
| for (let i = 1; i < p.length; i++) { | |
| ctxPruned.moveTo(p[i - 1][0], p[i - 1][1]); | |
| ctxPruned.lineTo(p[i][0], p[i][1]); | |
| c++; | |
| } | |
| }); | |
| ctxPruned.stroke(); | |
| uiPrunedCount.innerText = `${c} segs`; | |
| } | |
| function drawRDPOnlyGraph() { | |
| ctxRDPOnly.setTransform(1, 0, 0, 1, 0, 0); | |
| ctxRDPOnly.fillStyle = "#111827"; | |
| ctxRDPOnly.fillRect(0, 0, WIDTH, HEIGHT); | |
| ctxRDPOnly.translate(viewTransform.x, viewTransform.y); | |
| ctxRDPOnly.scale(viewTransform.k, viewTransform.k); | |
| ctxRDPOnly.drawImage(ghostCanvas, 0, 0); | |
| const isBezierMode = cachedRDPOnlyData.isBezier; | |
| // Draw Lines | |
| ctxRDPOnly.lineWidth = isBezierMode ? 1.5 : 2; | |
| ctxRDPOnly.strokeStyle = isBezierMode ? "#4ade80" : "#a855f7"; // Green if Bezier, Purple if Linear | |
| ctxRDPOnly.beginPath(); | |
| let c = 0; | |
| cachedRDPOnlyData.polylines.forEach((p) => { | |
| if (p.length < 2) return; | |
| ctxRDPOnly.moveTo(p[0][0], p[0][1]); | |
| for (let i = 1; i < p.length; i++) { | |
| ctxRDPOnly.lineTo(p[i][0], p[i][1]); | |
| c++; | |
| } | |
| }); | |
| ctxRDPOnly.stroke(); | |
| // Draw Dots | |
| cachedRDPOnlyData.polylines.forEach((p) => { | |
| for (let i = 0; i < p.length; i++) { | |
| const pt = p[i]; | |
| ctxRDPOnly.beginPath(); | |
| if (isBezierMode) { | |
| // In Bezier mode: Even indices are Anchors, Odd are Control Points | |
| const isAnchor = i % 2 === 0; | |
| if (isAnchor) { | |
| ctxRDPOnly.fillStyle = "#fff"; | |
| ctxRDPOnly.arc(pt[0], pt[1], 3, 0, Math.PI * 2); | |
| ctxRDPOnly.fill(); | |
| } else { | |
| // Control Point | |
| ctxRDPOnly.fillStyle = "#4ade80"; // Green | |
| ctxRDPOnly.rect(pt[0] - 2, pt[1] - 2, 4, 4); | |
| ctxRDPOnly.fill(); | |
| } | |
| } else { | |
| // Standard RDP | |
| ctxRDPOnly.fillStyle = "#e9d5ff"; | |
| ctxRDPOnly.arc(pt[0], pt[1], 2, 0, Math.PI * 2); | |
| ctxRDPOnly.fill(); | |
| } | |
| } | |
| }); | |
| // Draw Junctions (Red Dots) | |
| if (!isBezierMode) { | |
| ctxRDPOnly.fillStyle = "#ef4444"; | |
| for (const j of cachedRDPOnlyData.junctions) { | |
| ctxRDPOnly.beginPath(); | |
| ctxRDPOnly.arc(j[0], j[1], 4, 0, Math.PI * 2); | |
| ctxRDPOnly.fill(); | |
| } | |
| } | |
| // Highlighted Polyline Logic | |
| if ( | |
| highlightedPolylineIndex !== -1 && | |
| cachedRDPOnlyData.polylines[highlightedPolylineIndex] | |
| ) { | |
| const p = | |
| cachedRDPOnlyData.polylines[highlightedPolylineIndex]; | |
| ctxRDPOnly.strokeStyle = "#facc15"; | |
| ctxRDPOnly.lineWidth = 4; | |
| ctxRDPOnly.beginPath(); | |
| ctxRDPOnly.moveTo(p[0][0], p[0][1]); | |
| for (let i = 1; i < p.length; i++) { | |
| ctxRDPOnly.lineTo(p[i][0], p[i][1]); | |
| } | |
| ctxRDPOnly.stroke(); | |
| } | |
| uiRdpOnlyCount.innerText = | |
| `${c} segs` + (isBezierMode ? " (Bz)" : ""); | |
| } | |
| // CHANGED: WebGL Reconstruction Logic | |
| function drawReconstruction() { | |
| if (!webglGen) return; | |
| // Update UI Count | |
| if (uiReconstructInfo) { | |
| uiReconstructInfo.innerText = `${cachedCurves.length} prims`; | |
| } | |
| // Build Instance Data from cachedCurves | |
| // Each curve: 11 floats + 4 floats (color) = 15 floats | |
| const instances = []; | |
| const ox = currentMetrics.originX; | |
| const oy = TOP_MARGIN; | |
| cachedCurves.forEach((c, i) => { | |
| // Highlighting | |
| const isHighlighted = | |
| highlightedPolylineIndex !== -1 && | |
| c.polyIndex === highlightedPolylineIndex; | |
| const isHovered = i === hoveredCapsuleIndex; | |
| // Filter by Highlighting (if active, only show highlights?) | |
| // No, standard behavior is show all, highlight specific. | |
| // But code in prev version did: if (highlightedPolylineIndex !== -1 && c.polyIndex !== highlightedPolylineIndex) return; | |
| // Let's keep that logic to focus view. | |
| if ( | |
| highlightedPolylineIndex !== -1 && | |
| c.polyIndex !== highlightedPolylineIndex | |
| ) | |
| return; | |
| // Color: Yellow if hovered, White otherwise | |
| // If highlighted section, tint slight yellow? | |
| let r = 1, | |
| g = 1, | |
| b = 1, | |
| a = 1; | |
| if (isHovered) { | |
| r = 1.0; | |
| g = 0.8; | |
| b = 0.1; | |
| a = 1.0; | |
| } // Yellow | |
| else { | |
| r = 1.0; | |
| g = 1.0; | |
| b = 1.0; | |
| a = 1.0; | |
| } // White | |
| // NEW: Use Integer-Rounded JSON data if available to match export visualization | |
| let x0, y0, z0, x1, y1, z1, x2, y2, z2; | |
| if ( | |
| cachedFlatCurveData && | |
| cachedFlatCurveData.length > i * 9 + 8 | |
| ) { | |
| const k = i * 9; | |
| // Add offsets back because JSON data is relative to char origin | |
| x0 = cachedFlatCurveData[k] + ox; | |
| y0 = cachedFlatCurveData[k + 1] + oy; | |
| z0 = cachedFlatCurveData[k + 2]; | |
| x1 = cachedFlatCurveData[k + 3] + ox; | |
| y1 = cachedFlatCurveData[k + 4] + oy; | |
| z1 = cachedFlatCurveData[k + 5]; | |
| x2 = cachedFlatCurveData[k + 6] + ox; | |
| y2 = cachedFlatCurveData[k + 7] + oy; | |
| z2 = cachedFlatCurveData[k + 8]; | |
| } else { | |
| // Fallback to high-precision floats (should rarely happen if pipeline is consistent) | |
| x0 = c.xStart; | |
| y0 = c.yStart; | |
| z0 = c.rStart; | |
| x1 = c.xCtrl; | |
| y1 = c.yCtrl; | |
| z1 = c.rCtrl; | |
| x2 = c.xEnd; | |
| y2 = c.yEnd; | |
| z2 = c.rEnd; | |
| } | |
| instances.push( | |
| x0, | |
| y0, | |
| z0, // P0 | |
| x1, | |
| y1, | |
| z1, // P1 | |
| x2, | |
| y2, | |
| z2, // P2 | |
| 0, | |
| 0, // Offset (0 because coords are absolute in gen view) | |
| r, | |
| g, | |
| b, | |
| a, // Color | |
| ); | |
| }); | |
| const instanceCount = instances.length / 15; | |
| if (instanceCount === 0) { | |
| // Just clear | |
| const gl = webglGen.gl; | |
| gl.viewport( | |
| 0, | |
| 0, | |
| canvasReconstruct.width, | |
| canvasReconstruct.height, | |
| ); | |
| gl.clearColor(0.067, 0.094, 0.153, 1.0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| return; | |
| } | |
| // Upload Data | |
| const gl = webglGen.gl; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, webglGen.buffers.instance); | |
| gl.bufferData( | |
| gl.ARRAY_BUFFER, | |
| new Float32Array(instances), | |
| gl.DYNAMIC_DRAW, | |
| ); | |
| // Draw. | |
| // View 6 (Gen) doesn't pre-scale vertices on CPU. | |
| // So we MUST pass the zoom factor (viewTransform.k) as the uScale uniform. | |
| webglGen.draw( | |
| instanceCount, | |
| viewTransform, | |
| viewTransform.k, | |
| 1.0, | |
| 0.0, | |
| ); | |
| } | |
| function setupInteractions() { | |
| const canvases = [ | |
| canvasVoronoi, | |
| canvasPruned, | |
| canvasRDPOnly, | |
| canvasReconstruct, | |
| ]; | |
| canvases.forEach((c) => { | |
| c.addEventListener("wheel", (e) => { | |
| e.preventDefault(); | |
| const s = e.deltaY < 0 ? 1.1 : 0.909; | |
| const rect = e.target.getBoundingClientRect(); | |
| const mx = | |
| (e.clientX - rect.left) * | |
| (e.target.width / rect.width), | |
| my = | |
| (e.clientY - rect.top) * | |
| (e.target.height / rect.height); | |
| const wx = (mx - viewTransform.x) / viewTransform.k, | |
| wy = (my - viewTransform.y) / viewTransform.k; | |
| viewTransform.k *= s; | |
| viewTransform.x = mx - wx * viewTransform.k; | |
| viewTransform.y = my - wy * viewTransform.k; | |
| drawVectorLayers(); | |
| }); | |
| c.addEventListener("mousedown", (e) => { | |
| isDragging = true; | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| }); | |
| }); | |
| window.addEventListener("mousemove", (e) => { | |
| if (isDragging) { | |
| const s = | |
| canvasVoronoi.width / | |
| canvasVoronoi.getBoundingClientRect().width; | |
| viewTransform.x += (e.clientX - lastMouse.x) * s; | |
| viewTransform.y += (e.clientY - lastMouse.y) * s; | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| drawVectorLayers(); | |
| } else { | |
| // GUARD: Check if the target is actually one of our interaction canvases | |
| if (!e.target || !e.target.getBoundingClientRect) | |
| return; | |
| // NEW: Hover logic for RDP Filtering | |
| const rect = e.target.getBoundingClientRect(); | |
| if (!rect) return; | |
| const scaleX = e.target.width / rect.width; | |
| const scaleY = e.target.height / rect.height; | |
| const mx = (e.clientX - rect.left) * scaleX; | |
| const my = (e.clientY - rect.top) * scaleY; | |
| const wx = (mx - viewTransform.x) / viewTransform.k; | |
| const wy = (my - viewTransform.y) / viewTransform.k; | |
| if (e.target === canvasRDPOnly && cachedRDPOnlyData) { | |
| let closestDist = 15 / viewTransform.k; | |
| let closestPolyIdx = -1; | |
| const { polylines } = cachedRDPOnlyData; | |
| for (let p = 0; p < polylines.length; p++) { | |
| const poly = polylines[p]; | |
| if (poly.length < 2) continue; | |
| for (let i = 1; i < poly.length; i++) { | |
| const d = distToSegmentSquared( | |
| [wx, wy], | |
| poly[i - 1], | |
| poly[i], | |
| ); | |
| if (d < closestDist * closestDist) { | |
| closestDist = Math.sqrt(d); | |
| closestPolyIdx = p; | |
| } | |
| } | |
| } | |
| if (highlightedPolylineIndex !== closestPolyIdx) { | |
| highlightedPolylineIndex = closestPolyIdx; | |
| // Redraw BOTH RDP (to show highlight) and Reconstruct (to show filtered view) | |
| drawRDPOnlyGraph(); | |
| drawReconstruction(); | |
| } | |
| } else { | |
| // Clear highlight if we leave the canvas or hover elsewhere | |
| if (highlightedPolylineIndex !== -1) { | |
| highlightedPolylineIndex = -1; | |
| drawRDPOnlyGraph(); | |
| drawReconstruction(); | |
| } | |
| } | |
| } | |
| }); | |
| window.addEventListener("mouseup", () => (isDragging = false)); | |
| } | |
| // --- WEBGL INIT & RENDER --- | |
| function initWebGL() { | |
| // Init Tester | |
| webglTester = new WebGLContextWrapper(testerCanvas); | |
| if (!webglTester.init()) | |
| console.error("Failed to init WebGL for Tester"); | |
| // Init Generator | |
| webglGen = new WebGLContextWrapper(canvasReconstruct); | |
| if (!webglGen.init()) | |
| console.error("Failed to init WebGL for Gen"); | |
| } | |
| // NEW: Separated Data Update from Draw | |
| let lastRenderedText = ""; | |
| let lastRenderedZoomScale = -1; // NEW: Track the scale to trigger re-baking | |
| function updateInstanceBuffer(zoomScale) { | |
| // Accepts zoomScale now | |
| if (!testerData || !webglTester) return; | |
| const text = testerText.value; | |
| const metrics = testerData.metrics || { | |
| lineHeight: 100, | |
| baseline: 80, | |
| }; | |
| const rawStartX = 50; | |
| // Apply zoomScale to layout cursor | |
| let cursorX = rawStartX * zoomScale; | |
| let cursorY = 100 * zoomScale; | |
| const instances = []; | |
| for (const char of text) { | |
| if (char === "\n") { | |
| cursorX = rawStartX * zoomScale; | |
| cursorY += metrics.lineHeight * zoomScale; | |
| continue; | |
| } | |
| const data = testerData.chars[String(char.charCodeAt(0))]; | |
| if (!data) { | |
| cursorX += 35 * zoomScale; | |
| continue; | |
| } | |
| const curves = data.curves; | |
| if (curves) { | |
| // New Format: 9 floats per curve + 2 floats per offset = 11 floats | |
| // + 4 floats color = 15 floats total | |
| for (let i = 0; i < curves.length; i += 9) { | |
| // SCALE IS APPLIED HERE BEFORE GPU | |
| instances.push( | |
| curves[i] * zoomScale, | |
| curves[i + 1] * zoomScale, | |
| curves[i + 2] * zoomScale, // P0 | |
| curves[i + 3] * zoomScale, | |
| curves[i + 4] * zoomScale, | |
| curves[i + 5] * zoomScale, // P1 | |
| curves[i + 6] * zoomScale, | |
| curves[i + 7] * zoomScale, | |
| curves[i + 8] * zoomScale, // P2 | |
| // Offset (already scaled) | |
| cursorX, | |
| cursorY, | |
| 1, | |
| 1, | |
| 1, | |
| 1, // Color (White) | |
| ); | |
| } | |
| } | |
| cursorX += data.advance * zoomScale; // Advance cursor for next character | |
| } | |
| testerInstanceCount = instances.length / 15; | |
| if (testerInstanceCount === 0) return; | |
| const gl = webglTester.gl; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, webglTester.buffers.instance); | |
| gl.bufferData( | |
| gl.ARRAY_BUFFER, | |
| new Float32Array(instances), | |
| gl.DYNAMIC_DRAW, | |
| ); | |
| lastRenderedText = text; | |
| lastRenderedZoomScale = zoomScale; // Update tracked scale | |
| } | |
| function runTestRender(forceRebuild = false) { | |
| if (!testerData || !webglTester) return; | |
| const scale = parseFloat(testerScale.value); | |
| const bold = parseFloat(boldCoef.value); | |
| const skew = parseFloat(skewCoef.value); | |
| // Calculate the combined zoom factor here (Font Size * Camera Zoom) | |
| const zoomScale = scale * testerViewTransform.k; | |
| // Check if we need to update the buffer (Text changed OR Scale changed) | |
| // We use a small epsilon for float comparison | |
| if ( | |
| forceRebuild || | |
| testerText.value !== lastRenderedText || | |
| Math.abs(zoomScale - lastRenderedZoomScale) > 0.0001 | |
| ) { | |
| updateInstanceBuffer(zoomScale); | |
| } | |
| // Pass 1.0 for uScale, as scaling is now fully baked into the instance buffer | |
| webglTester.draw( | |
| testerInstanceCount, | |
| testerViewTransform, | |
| 1.0, | |
| bold, | |
| skew, | |
| ); | |
| } | |
| // --- Init --- | |
| function populateFonts(extras = []) { | |
| const current = inputs.font.value; | |
| inputs.font.innerHTML = ""; | |
| if ("queryLocalFonts" in window) { | |
| const scan = document.createElement("option"); | |
| scan.value = "__SCAN__"; | |
| scan.text = "🔄 Scan Local Fonts..."; | |
| inputs.font.add(scan); | |
| const divider = document.createElement("option"); | |
| divider.disabled = true; | |
| divider.text = "──────────"; | |
| inputs.font.add(divider); | |
| } | |
| let all = [ | |
| ...new Set([ | |
| ...extras, | |
| "Arial", | |
| "Courier New", | |
| "Verdana", | |
| "Georgia", | |
| "Times New Roman", | |
| ]), | |
| ].sort(); | |
| if (customFontName) { | |
| all = all.filter((f) => f !== customFontName); | |
| inputs.font.add( | |
| new Option("Custom: " + customFontName, customFontName), | |
| ); | |
| } | |
| all.forEach((font) => inputs.font.add(new Option(font, font))); | |
| if ( | |
| current && | |
| (all.includes(current) || current === customFontName) | |
| ) | |
| inputs.font.value = current; | |
| else | |
| inputs.font.selectedIndex = | |
| "queryLocalFonts" in window ? 2 : 0; | |
| } | |
| populateFonts(); | |
| // NEW: Populate with Standard ASCII if no custom font is loaded | |
| function populateStandardASCII() { | |
| loadedFontCharCodes = []; | |
| for (let i = 32; i <= 126; i++) loadedFontCharCodes.push(i); | |
| inputs.glyphSelect.innerHTML = ""; | |
| inputs.glyphSelect.disabled = false; | |
| inputs.glyphSelect.classList.remove( | |
| "opacity-50", | |
| "cursor-not-allowed", | |
| ); | |
| const defaultOpt = document.createElement("option"); | |
| defaultOpt.text = "Standard ASCII (32-126)"; | |
| defaultOpt.value = ""; | |
| inputs.glyphSelect.add(defaultOpt); | |
| loadedFontCharCodes.forEach((code) => { | |
| const char = String.fromCharCode(code); | |
| const option = document.createElement("option"); | |
| option.value = char; | |
| option.text = `[${code.toString(16).toUpperCase()}] ${char}`; | |
| inputs.glyphSelect.add(option); | |
| }); | |
| } | |
| // NEW: Populate from parsed font | |
| function populateGlyphSelect(font) { | |
| const glyphs = font.tables.cmap.glyphIndexMap; | |
| const charCodes = Object.keys(glyphs) | |
| .map(Number) | |
| .sort((a, b) => a - b); | |
| loadedFontCharCodes = charCodes.filter((code) => code >= 32); | |
| inputs.glyphSelect.innerHTML = ""; | |
| inputs.glyphSelect.disabled = false; | |
| inputs.glyphSelect.classList.remove( | |
| "opacity-50", | |
| "cursor-not-allowed", | |
| ); | |
| const defaultOpt = document.createElement("option"); | |
| defaultOpt.text = `${loadedFontCharCodes.length} Characters Found`; | |
| defaultOpt.value = ""; | |
| inputs.glyphSelect.add(defaultOpt); | |
| const limit = 3000; | |
| let added = 0; | |
| loadedFontCharCodes.forEach((code) => { | |
| if (added > limit) return; | |
| const char = String.fromCharCode(code); | |
| const option = document.createElement("option"); | |
| option.value = char; | |
| option.text = `[${code.toString(16).toUpperCase()}] ${char}`; | |
| inputs.glyphSelect.add(option); | |
| added++; | |
| }); | |
| if (loadedFontCharCodes.length > limit) { | |
| const option = document.createElement("option"); | |
| option.text = `... and ${loadedFontCharCodes.length - limit} more (Available via Export All)`; | |
| option.disabled = true; | |
| inputs.glyphSelect.add(option); | |
| } | |
| } | |
| // --- 3rd TAB LOGIC: INDEPENDENT START --- | |
| // Source for the embedded demo (Independent constant) | |
| const getUnevenSource = () => `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Uneven Bezier - Mid Radius & Color Control</title> | |
| <style> | |
| body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #000; font-family: monospace; } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| #ui-container { position: absolute; top: 10px; left: 10px; background: rgba(0, 0, 0, 0.7); padding: 15px; border-radius: 8px; color: white; border: 1px solid #444; backdrop-filter: blur(4px); user-select: none; width: 200px; z-index: 10; } | |
| .control-group { margin-bottom: 5px; display: flex; align-items: center; justify-content: space-between; } | |
| .slider-group { margin-bottom: 10px; display: flex; flex-direction: column; } | |
| .slider-label { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 2px; color: #ccc; } | |
| input[type="range"] { width: 100%; cursor: pointer; } | |
| label { cursor: pointer; font-size: 13px; display: flex; align-items: center; } | |
| input[type="checkbox"] { transform: scale(1.2); cursor: pointer; } | |
| .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 8px; } | |
| .green { background: #0f0; } | |
| button { width: 100%; padding: 8px; background: #333; border: 1px solid #666; color: white; cursor: pointer; margin-top: 5px; font-family: inherit; border-radius: 4px; transition: background 0.2s; } | |
| button:hover { background: #444; } | |
| button.active { background: #006633; border-color: #00aa55; } | |
| button.active:hover { background: #007744; } | |
| .description { font-size: 10px; color: #aaa; margin-top: 4px; line-height: 1.3; } | |
| #error-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ff5555; background: rgba(0,0,0,0.8); padding: 20px; border-radius: 8px; display: none; z-index: 20; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="ui-container"> | |
| <div style="margin-bottom:10px; font-weight:bold; color:#fff; border-bottom: 1px solid #555; padding-bottom: 5px;">Debug Tools</div> | |
| <div class="control-group"><label for="checkGradient"><span class="status-dot green"></span>Gradient Health</label><input type="checkbox" id="checkGradient"></div> | |
| <div class="slider-group" style="margin-top: 15px;"><div class="slider-label"><span>Radius Start (r0)</span><span id="valR0">0.15</span></div><input type="range" id="sliderR0" min="0.0" max="1.0" step="0.01" value="0.15"></div> | |
| <div class="slider-group"><div class="slider-label"><span>Radius Mid (r1)</span><span id="valR1">0.15</span></div><input type="range" id="sliderR1" min="0.0" max="1.0" step="0.01" value="0.15"></div> | |
| <div class="slider-group"><div class="slider-label"><span>Radius End (r2)</span><span id="valR2">0.15</span></div><input type="range" id="sliderR2" min="0.0" max="1.0" step="0.01" value="0.15"></div> | |
| <button id="btnForceLinear">Force Midpoint: OFF</button> | |
| <div class="description" style="margin-top: 10px;">Visualizes numerical stability.<br>Green = Perfect (1.0)<br>Red = Bad Gradient</div> | |
| <button id="pauseButton" style="margin-top: 15px;">Pause Animation</button> | |
| </div> | |
| <canvas id="glcanvas"></canvas> | |
| <div id="error-message"></div> | |
| <script id="vs" type="x-shader/x-vertex">attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); }<\/script> | |
| <script id="fs" type="x-shader/x-fragment"> | |
| #extension GL_OES_standard_derivatives : enable | |
| precision highp float; | |
| uniform vec3 iResolution; uniform float iTime; uniform vec4 iMouse; uniform float iZoom; | |
| uniform float uShowGradient; uniform float uR0; uniform float uR1; uniform float uR2; uniform float uForceLinear; | |
| #define EPS 1e-7 | |
| float cro(in vec2 a, in vec2 b) { return a.x*b.y - a.y*b.x; } // Added Helper | |
| float sdBezierUnevenCapsule(vec2 pos, vec2 A, vec2 B, vec2 C, float r0, float r1, float r2, out vec2 outQ, out float outT) { | |
| // Geometry | |
| vec2 b = A - 2.0*B + C; | |
| vec2 c = 2.0*(B - A); | |
| vec2 d0 = A - pos; | |
| vec2 chord = C - A; | |
| // Monotonic Radius Coefficients | |
| float d10 = r1 - r0; | |
| float d20 = r2 - r0; | |
| float slopeMid = sign(d10) * min(abs(d20), 4.0 * min(abs(d10), abs(r2 - r1))) * step(0.0, d10 * (r2 - r1)); | |
| float cubicA = 4.0 * (d20 - slopeMid); | |
| float cubicC = 4.0 * d10 + d20 - 2.0 * slopeMid; | |
| float cubicB = d20 - cubicA - cubicC; | |
| float kB2 = 2.0 * cubicB; | |
| // Polynomial Coefficients | |
| float q4 = dot(b,b); | |
| float q3 = 2.0 * dot(c,b); | |
| float q2 = dot(c,c) + 2.0 * dot(d0,b); | |
| float q1 = 2.0 * dot(d0,c); | |
| float q0 = dot(d0, d0); | |
| // Loop Constants (Optimized) | |
| float q4_2 = 2.0 * q4; | |
| float q4_6 = 6.0 * q4; | |
| float q3_15 = 1.5 * q3; | |
| float q3_3 = 3.0 * q3; | |
| float kA3 = 3.0 * cubicA; | |
| float kA6 = 6.0 * cubicA; | |
| // Initial Candidates | |
| float bestD = sqrt(q0) - r0; | |
| float bestT = 0.0; | |
| // Check Endpoint C (Optimized: reuse d0+chord) | |
| vec2 dC = C - pos; | |
| float dEnd = sqrt(max(dot(dC, dC), 0.0)) - r2; | |
| if (dEnd < bestD) { bestD = dEnd; bestT = 1.0; } | |
| vec4 t = vec4(clamp(-dot(d0, chord) / (dot(chord,chord) + 1e-12), 0.0, 1.0), 0.2, 0.5, 0.8); | |
| // Newton Iteration | |
| for(int j = 0; j < 4; j++) { | |
| vec4 invS = inversesqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 1e-12)); | |
| vec4 termB = t * (t * q4_2 + q3_15) + q2; | |
| vec4 dr = t * (t * kA3 + kB2) + cubicC; | |
| vec4 denGeom = t * (t * q4_6 + q3_3) + q2; // Optimized S''/2 | |
| vec4 k = (t * termB + 0.5 * q1) * invS; | |
| t = clamp(t - (k - dr) / (invS * (denGeom - k * dr) - (t * kA6 + kB2)), 0.0, 1.0); | |
| } | |
| // Reduction | |
| vec4 err = sqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 0.0)) - (((cubicA * t + cubicB) * t + cubicC) * t + r0); | |
| vec2 bestPair1 = (err.x < err.y) ? vec2(err.x, t.x) : vec2(err.y, t.y); | |
| vec2 bestPair2 = (err.z < err.w) ? vec2(err.z, t.z) : vec2(err.w, t.w); | |
| vec2 finalPair = (bestPair1.x < bestPair2.x) ? bestPair1 : bestPair2; | |
| if(finalPair.x < bestD) { bestD = finalPair.x; bestT = finalPair.y; } | |
| outT = bestT; | |
| outQ = A + bestT * (c + bestT * b); | |
| return bestD; | |
| } | |
| float udSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa,ba)/max(dot(ba,ba), EPS), 0.0, 1.0); return length(pa - ba*h); } | |
| float linearStep(float edge0, float edge1, float x) { return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); } | |
| void mainImage(out vec4 fragColor, in vec2 fragCoord) { | |
| vec2 p = (2.0*fragCoord - iResolution.xy)/iResolution.y * iZoom; vec2 m = (2.0*iMouse.xy - iResolution.xy)/iResolution.y * iZoom; | |
| vec2 v0 = vec2(1.3,0.9)*cos(iTime*0.5 + vec2(0.0,5.0)); vec2 v2 = vec2(1.3,0.9)*cos(iTime*0.7 + vec2(2.0,0.0)); | |
| vec2 v1_animated = vec2(1.3,0.9)*cos(iTime*0.6 + vec2(3.0,4.0)); vec2 v1 = mix(v1_animated, (v0 + v2) * 0.5, uForceLinear); | |
| float r0 = uR0; float r1 = uR1; float r2 = uR2; vec2 q; float t; float d = sdBezierUnevenCapsule(p, v0,v1,v2, r0,r1,r2, q,t); | |
| vec3 insideA = vec3(0.0, 0.9, 0.9); vec3 insideMid = vec3(1.0, 0.8, 0.0); vec3 insideB = vec3(0.9, 0.0, 0.9); | |
| float dStart = length(q - v0); float f = linearStep(r0, max(dStart + length(q - v2) - r2, r0), dStart); | |
| vec3 colCtrl = 2.0 * insideMid - 0.5 * (insideA + insideB); vec3 insideCol = mix(mix(insideA, colCtrl, f), mix(colCtrl, insideB, f), f); insideCol = clamp(insideCol, 0.0, 1.0); | |
| vec3 outsideCol = vec3(0.90,0.60,0.30); outsideCol *= 1.0 - exp(-6.0*abs(d)); outsideCol *= 0.8 + 0.2*cos(110.0*d); | |
| vec3 col = (d > 0.0) ? outsideCol : insideCol; float w = max(0.75*fwidth(d), 0.0015); float edge = 1.0 - smoothstep(0.0, w, abs(d)); col = mix(col, vec3(1.0), edge); | |
| if (uShowGradient > 0.5) { vec2 h = vec2(0.001, 0.0); vec2 dq; float dt; float dX = sdBezierUnevenCapsule(p + h.xy, v0,v1,v2, r0,r1,r2, dq, dt); float dY = sdBezierUnevenCapsule(p + h.yx, v0,v1,v2, r0,r1,r2, dq, dt); vec2 gradient = vec2(dX - d, dY - d) / h.x; float err = abs(length(gradient) - 1.0); vec3 heatCol = mix(vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), smoothstep(0.01, 0.1, err)); col = mix(col, heatCol, 0.85); } | |
| if (iMouse.z > 0.001) { vec2 qm; float tm; float dm = sdBezierUnevenCapsule(m, v0,v1,v2, r0,r1,r2, qm,tm); col = mix(col, vec3(1.0,1.0,0.0), 1.0 - smoothstep(0.0, 0.005, abs(length(p-m) - abs(dm)) - 0.0025)); col = mix(col, vec3(1.0,1.0,0.0), 1.0 - smoothstep(0.0, 0.005, length(p-qm) - 0.015)); } | |
| if (cos(0.5*iTime) < -0.5) { float dl = min( udSegment(p, v0, v1), udSegment(p, v1, v2) ); dl = min( dl, length(p - v0) - r0 ); dl = min( dl, length(p - v2) - r2 ); col = mix(col, vec3(1,0,0), 1.0 - smoothstep(0.0, 0.007, dl) ); } | |
| float dV1 = length(p - v1); col = mix(col, vec3(0.0), 1.0 - smoothstep(0.02, 0.025, dV1)); col = mix(col, vec3(0.0, 1.0, 1.0), 1.0 - smoothstep(0.015, 0.02, dV1)); | |
| fragColor = vec4(col,1.0); | |
| } | |
| void main() { mainImage(gl_FragColor, gl_FragCoord.xy); } | |
| <\/script> | |
| <script> | |
| // Mini engine to run the shader | |
| const canvas = document.getElementById('glcanvas'); | |
| const gl = canvas.getContext('webgl', { extensions: ['OES_standard_derivatives'] }); | |
| if (!gl) { document.body.innerHTML = '<div style="color:red;padding:20px">WebGL not supported</div>'; } | |
| else { | |
| const ext = gl.getExtension('OES_standard_derivatives'); | |
| function createShader(gl, type, source) { | |
| const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return null; | |
| return shader; | |
| } | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, createShader(gl, gl.VERTEX_SHADER, document.getElementById('vs').textContent)); | |
| gl.attachShader(program, createShader(gl, gl.FRAGMENT_SHADER, document.getElementById('fs').textContent)); | |
| gl.linkProgram(program); | |
| const positionBuffer = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW); | |
| const positionLocation = gl.getAttribLocation(program, 'position'); | |
| gl.enableVertexAttribArray(positionLocation); | |
| gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); | |
| gl.useProgram(program); | |
| const locs = { | |
| iResolution: gl.getUniformLocation(program, 'iResolution'), iTime: gl.getUniformLocation(program, 'iTime'), | |
| iMouse: gl.getUniformLocation(program, 'iMouse'), iZoom: gl.getUniformLocation(program, 'iZoom'), | |
| uShowGradient: gl.getUniformLocation(program, 'uShowGradient'), uR0: gl.getUniformLocation(program, 'uR0'), | |
| uR1: gl.getUniformLocation(program, 'uR1'), uR2: gl.getUniformLocation(program, 'uR2'), uForceLinear: gl.getUniformLocation(program, 'uForceLinear') | |
| }; | |
| let state = { time: 0, lastFrame: performance.now(), paused: false, mouseX: 0, mouseY: 0, mouseClick: 0, zoom: 2.0, showGradient: false, forceLinear: false, r0: 0.15, r1: 0.15, r2: 0.15 }; | |
| document.getElementById('checkGradient').addEventListener('change', (e) => state.showGradient = e.target.checked); | |
| document.getElementById('sliderR0').addEventListener('input', (e) => { state.r0 = parseFloat(e.target.value); document.getElementById('valR0').innerText = state.r0.toFixed(2); }); | |
| document.getElementById('sliderR1').addEventListener('input', (e) => { state.r1 = parseFloat(e.target.value); document.getElementById('valR1').innerText = state.r1.toFixed(2); }); | |
| document.getElementById('sliderR2').addEventListener('input', (e) => { state.r2 = parseFloat(e.target.value); document.getElementById('valR2').innerText = state.r2.toFixed(2); }); | |
| document.getElementById('btnForceLinear').addEventListener('click', (e) => { state.forceLinear = !state.forceLinear; e.target.innerText = state.forceLinear ? "Force Midpoint: ON" : "Force Midpoint: OFF"; e.target.classList.toggle('active'); document.getElementById('sliderR1').disabled = state.forceLinear; }); | |
| document.getElementById('pauseButton').addEventListener('click', (e) => { state.paused = !state.paused; e.target.innerText = state.paused ? "Resume Animation" : "Pause Animation"; }); | |
| function updateMouse(x, y, click) { state.mouseX = x; state.mouseY = canvas.height - y; if (click !== null) state.mouseClick = click; } | |
| canvas.addEventListener('mousemove', e => updateMouse(e.clientX, e.clientY, state.mouseClick > 0 ? 1 : null)); | |
| canvas.addEventListener('mousedown', e => updateMouse(e.clientX, e.clientY, 1.0)); | |
| canvas.addEventListener('mouseup', () => state.mouseClick = 0.0); | |
| canvas.addEventListener('wheel', e => { e.preventDefault(); state.zoom *= (e.deltaY > 0 ? 1.05 : 0.95); state.zoom = Math.max(0.1, Math.min(state.zoom, 10.0)); }, { passive: false }); | |
| function render(now) { | |
| if (!now) now = state.lastFrame; const dt = (now - state.lastFrame) * 0.001; state.lastFrame = now; | |
| if (!state.paused) state.time += dt; | |
| if (state.forceLinear) { state.r1 = (state.r0 + state.r2) * 0.5; document.getElementById('sliderR1').value = state.r1; document.getElementById('valR1').innerText = state.r1.toFixed(2); } | |
| canvas.width = window.innerWidth; canvas.height = window.innerHeight; gl.viewport(0, 0, canvas.width, canvas.height); | |
| gl.uniform3f(locs.iResolution, canvas.width, canvas.height, 1.0); gl.uniform1f(locs.iTime, state.time); gl.uniform1f(locs.iZoom, state.zoom); | |
| gl.uniform4f(locs.iMouse, state.mouseX, state.mouseY, state.mouseClick, 0.0); | |
| gl.uniform1f(locs.uShowGradient, state.showGradient ? 1.0 : 0.0); gl.uniform1f(locs.uR0, state.r0); gl.uniform1f(locs.uR1, state.r1); gl.uniform1f(locs.uR2, state.r2); gl.uniform1f(locs.uForceLinear, state.forceLinear ? 1.0 : 0.0); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); requestAnimationFrame(render); | |
| } | |
| requestAnimationFrame(render); | |
| } | |
| <\/script> | |
| </body> | |
| </html>`; | |
| const unevenShaderCode = `// Quadratic Bézier "uneven capsule" with monotonic steffen radius interpolation | |
| // By Estama (MIT License) | |
| float sdBezierUnevenCapsule(vec2 pos, vec2 A, vec2 B, vec2 C, float r0, float r1, float r2, out vec2 outQ, out float outT) { | |
| // Geometry | |
| vec2 b = A - 2.0*B + C; | |
| vec2 c = 2.0*(B - A); | |
| vec2 d0 = A - pos; | |
| vec2 chord = C - A; | |
| // Monotonic Radius Coefficients | |
| float d10 = r1 - r0; | |
| float d20 = r2 - r0; | |
| float slopeMid = sign(d10) * min(abs(d20), 4.0 * min(abs(d10), abs(r2 - r1))) * step(0.0, d10 * (r2 - r1)); | |
| float cubicA = 4.0 * (d20 - slopeMid); | |
| float cubicC = 4.0 * d10 + d20 - 2.0 * slopeMid; | |
| float cubicB = d20 - cubicA - cubicC; | |
| float kB2 = 2.0 * cubicB; | |
| // Polynomial Coefficients | |
| float q4 = dot(b,b); | |
| float q3 = 2.0 * dot(c,b); | |
| float q2 = dot(c,c) + 2.0 * dot(d0,b); | |
| float q1 = 2.0 * dot(d0,c); | |
| float q0 = dot(d0, d0); | |
| // Loop Constants (Optimized) | |
| float q4_2 = 2.0 * q4; | |
| float q4_6 = 6.0 * q4; | |
| float q3_15 = 1.5 * q3; | |
| float q3_3 = 3.0 * q3; | |
| float kA3 = 3.0 * cubicA; | |
| float kA6 = 6.0 * cubicA; | |
| // Initial Candidates | |
| float bestD = sqrt(q0) - r0; | |
| float bestT = 0.0; | |
| // Check Endpoint C (Optimized: reuse d0+chord) | |
| vec2 dC = C - pos; | |
| float dEnd = sqrt(max(dot(dC, dC), 0.0)) - r2; | |
| if (dEnd < bestD) { bestD = dEnd; bestT = 1.0; } | |
| vec4 t = vec4(clamp(-dot(d0, chord) / (dot(chord,chord) + 1e-12), 0.0, 1.0), 0.2, 0.5, 0.8); | |
| // Newton Iteration | |
| for(int j = 0; j < 4; j++) { | |
| vec4 invS = inversesqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 1e-12)); | |
| vec4 termB = t * (t * q4_2 + q3_15) + q2; | |
| vec4 dr = t * (t * kA3 + kB2) + cubicC; | |
| vec4 denGeom = t * (t * q4_6 + q3_3) + q2; // Optimized S''/2 | |
| vec4 k = (t * termB + 0.5 * q1) * invS; | |
| t = clamp(t - (k - dr) / (invS * (denGeom - k * dr) - (t * kA6 + kB2)), 0.0, 1.0); | |
| } | |
| // Reduction | |
| vec4 err = sqrt(max((((q4 * t + q3) * t + q2) * t + q1) * t + q0, 0.0)) - (((cubicA * t + cubicB) * t + cubicC) * t + r0); | |
| vec2 bestPair1 = (err.x < err.y) ? vec2(err.x, t.x) : vec2(err.y, t.y); | |
| vec2 bestPair2 = (err.z < err.w) ? vec2(err.z, t.z) : vec2(err.w, t.w); | |
| vec2 finalPair = (bestPair1.x < bestPair2.x) ? bestPair1 : bestPair2; | |
| if(finalPair.x < bestD) { bestD = finalPair.x; bestT = finalPair.y; } | |
| outT = bestT; | |
| outQ = A + bestT * (c + bestT * b); | |
| return bestD; | |
| }`; | |
| // Initialize Independent Tab Logic safely | |
| (function initUnevenTab() { | |
| const btn = document.getElementById("tabUneven"); | |
| const frame = document.getElementById("unevenFrame"); | |
| const codeArea = document.getElementById("unevenCode"); | |
| const copyBtn = document.getElementById("copyCodeBtn"); | |
| if (btn && frame && codeArea && copyBtn) { | |
| btn.addEventListener("click", () => { | |
| // Only load iframe content once to prevent flicker/reload | |
| if (!frame.contentWindow.document.body.innerHTML) { | |
| const doc = frame.contentWindow.document; | |
| doc.open(); | |
| doc.write(getUnevenSource()); | |
| doc.close(); | |
| // Populate source code area | |
| codeArea.value = unevenShaderCode; | |
| } | |
| }); | |
| copyBtn.addEventListener("click", () => { | |
| codeArea.select(); | |
| document.execCommand("copy"); | |
| copyBtn.innerText = "Copied!"; | |
| setTimeout(() => (copyBtn.innerText = "Copy"), 2000); | |
| }); | |
| } | |
| })(); | |
| // --- 3rd TAB LOGIC: INDEPENDENT END --- | |
| // Refactored Tab Logic (Now Robust/Independent) | |
| const tabs = {}; | |
| function registerTab(key, btnId, viewId) { | |
| const btn = document.getElementById(btnId); | |
| const view = document.getElementById(viewId); | |
| if (btn && view) { | |
| tabs[key] = { btn, view }; | |
| // Add click listener dynamically | |
| btn.addEventListener("click", () => switchTab(key)); | |
| } | |
| } | |
| // Register Core Tabs | |
| registerTab("gen", "tabGen", "viewGenerator"); | |
| registerTab("test", "tabTest", "viewTester"); | |
| // Register Independent Tab (Safe to fail if HTML is removed) | |
| registerTab("uneven", "tabUneven", "viewUneven"); | |
| function switchTab(activeKey) { | |
| Object.keys(tabs).forEach((key) => { | |
| const t = tabs[key]; | |
| if (key === activeKey) { | |
| t.view.classList.remove("hidden"); | |
| // Reset all colors first | |
| t.btn.classList.remove( | |
| "text-gray-400", | |
| "border-transparent", | |
| ); | |
| // Active Style | |
| t.btn.classList.add( | |
| "text-indigo-400", | |
| "border-indigo-400", | |
| ); | |
| } else { | |
| t.view.classList.add("hidden"); | |
| // Inactive Style | |
| t.btn.classList.remove( | |
| "text-indigo-400", | |
| "border-indigo-400", | |
| ); | |
| t.btn.classList.add( | |
| "text-gray-400", | |
| "border-transparent", | |
| ); | |
| } | |
| }); | |
| if (activeKey === "test") resizeTesterCanvas(); | |
| } | |
| testerScale.addEventListener("input", (e) => { | |
| scaleVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| boldCoef.addEventListener("input", (e) => { | |
| boldCoefVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| skewCoef.addEventListener("input", (e) => { | |
| skewCoefVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| testerText.addEventListener("input", () => runTestRender()); | |
| // Tester Interaction | |
| testerCanvas.addEventListener("wheel", (e) => { | |
| e.preventDefault(); | |
| const s = e.deltaY < 0 ? 1.1 : 0.909; | |
| const rect = testerCanvas.getBoundingClientRect(); | |
| const mx = | |
| (e.clientX - rect.left) * (testerCanvas.width / rect.width); | |
| const my = | |
| (e.clientY - rect.top) * | |
| (testerCanvas.height / rect.height); | |
| const wx = (mx - testerViewTransform.x) / testerViewTransform.k, | |
| wy = (my - testerViewTransform.y) / testerViewTransform.k; | |
| testerViewTransform.k *= s; | |
| testerViewTransform.x = mx - wx * testerViewTransform.k; | |
| testerViewTransform.y = my - wy * testerViewTransform.k; | |
| runTestRender(); | |
| }); | |
| testerCanvas.addEventListener("mousedown", (e) => { | |
| isTesterDragging = true; | |
| lastTesterMouse = { x: e.clientX, y: e.clientY }; | |
| }); | |
| window.addEventListener("mousemove", (e) => { | |
| if (isTesterDragging) { | |
| const s = | |
| testerCanvas.width / | |
| testerCanvas.getBoundingClientRect().width; | |
| testerViewTransform.x += | |
| (e.clientX - lastTesterMouse.x) * s; | |
| testerViewTransform.y += | |
| (e.clientY - lastTesterMouse.y) * s; | |
| lastTesterMouse = { x: e.clientX, y: e.clientY }; | |
| runTestRender(); | |
| } | |
| }); | |
| window.addEventListener( | |
| "mouseup", | |
| () => (isTesterDragging = false), | |
| ); | |
| function resizeTesterCanvas() { | |
| if (!viewTester.classList.contains("hidden")) { | |
| const rect = testerCanvasContainer.getBoundingClientRect(); | |
| testerCanvas.width = rect.width; | |
| testerCanvas.height = rect.height; | |
| if (!webglTester) initWebGL(); | |
| runTestRender(true); | |
| } | |
| } | |
| window.addEventListener("resize", resizeTesterCanvas); | |
| // Initial Load | |
| window.onload = () => { | |
| initWebGL(); // Init both contexts immediately | |
| setupInteractions(); | |
| populateStandardASCII(); // NEW: Pre-populate list | |
| if (typeof d3 === "undefined") setTimeout(process, 500); | |
| else process(); | |
| }; | |
| // Font Handling | |
| inputs.font.addEventListener("change", async (e) => { | |
| if (e.target.value === "__SCAN__") { | |
| try { | |
| const f = await window.queryLocalFonts(); | |
| populateFonts(f.map((x) => x.family)); | |
| inputs.font.selectedIndex = 2; | |
| } catch { | |
| inputs.font.selectedIndex = 2; | |
| } | |
| } | |
| process(); | |
| }); | |
| // Upload & Tester File Load | |
| inputs.uploadBtn.addEventListener("click", () => | |
| inputs.fileInput.click(), | |
| ); | |
| // UPDATED: File Input Logic with opentype.js Parsing | |
| inputs.fileInput.addEventListener("change", async (e) => { | |
| if (e.target.files.length) { | |
| const f = e.target.files[0], | |
| n = "CustomFont"; | |
| const buf = await f.arrayBuffer(), | |
| face = new FontFace(n, buf); | |
| await face.load(); | |
| document.fonts.add(face); | |
| customFontName = n; | |
| populateFonts(); | |
| inputs.font.value = n; | |
| // Parse for Glyph List | |
| try { | |
| const font = opentype.parse(buf); | |
| populateGlyphSelect(font); | |
| } catch (err) { | |
| console.warn("Opentype parsing failed", err); | |
| } | |
| process(); | |
| } | |
| }); | |
| // NEW: Glyph Select Listener | |
| inputs.glyphSelect.addEventListener("change", (e) => { | |
| if (e.target.value) { | |
| inputs.char.value = e.target.value; | |
| process(); | |
| } | |
| }); | |
| btnLoadJson.addEventListener("click", () => | |
| testerFileInput.click(), | |
| ); | |
| testerFileInput.addEventListener("change", (e) => { | |
| if (e.target.files.length) { | |
| const r = new FileReader(); | |
| r.onload = (ev) => { | |
| try { | |
| testerData = JSON.parse(ev.target.result); | |
| if (Array.isArray(testerData.chars)) { | |
| const m = {}; | |
| testerData.chars.forEach( | |
| (c) => (m[String(c.charCode)] = c), | |
| ); | |
| testerData.chars = m; | |
| } | |
| testerSourceLabel.innerText = "Source: File"; | |
| testerStatusText.innerText = "Loaded"; | |
| testerStatusText.className = | |
| "text-green-400 text-xs italic ml-2"; | |
| resizeTesterCanvas(); | |
| } catch { | |
| testerStatusText.innerText = "Error"; | |
| testerStatusText.className = | |
| "text-red-400 text-xs italic ml-2"; | |
| } | |
| }; | |
| r.readAsText(e.target.files[0]); | |
| } | |
| }); | |
| // Main Process Triggers | |
| ["input", "change"].forEach((evt) => | |
| Object.values(inputs).forEach((el) => | |
| el.addEventListener(evt, process), | |
| ), | |
| ); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment