Skip to content

Instantly share code, notes, and snippets.

@estama
Last active February 6, 2026 17:37
Show Gist options
  • Select an option

  • Save estama/b72d75e77828f707d8c07d9e7c1ec290 to your computer and use it in GitHub Desktop.

Select an option

Save estama/b72d75e77828f707d8c07d9e7c1ec290 to your computer and use it in GitHub Desktop.
<!-- 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 &rarr; SDF &rarr; Voronoi &rarr;
<span class="text-pink-400">Quadratic Beziers</span> &rarr;
<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"
>
&#128193;
</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>&#128193; 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