Last active
December 16, 2025 18:05
-
-
Save estama/db4103472833aa1a148807bf7f56671b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- By estama --> | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SDF & Smart Pruning Generator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Import D3 Delaunay for Vector Voronoi calculations --> | |
| <script src="https://cdn.jsdelivr.net/npm/d3-delaunay@6"></script> | |
| <!-- Import Opentype.js for font parsing --> | |
| <script src="https://unpkg.com/opentype.js@latest/dist/opentype.min.js"></script> | |
| <style> | |
| canvas { | |
| image-rendering: pixelated; | |
| } | |
| /* Enable antialiasing for the vector reconstruction canvases */ | |
| #canvasReconstruct, #testerCanvas { | |
| image-rendering: auto; | |
| } | |
| /* Make tester canvas use 100% of container size via CSS */ | |
| #testerCanvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .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; | |
| } | |
| /* Custom Scrollbar for Textareas */ | |
| 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"> | |
| <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-blue-400">Font to lines convertor</h1> | |
| <span class="text-sm text-gray-500 bg-gray-800 px-2 py-0.5 rounded border border-gray-700">v1.1</span> | |
| </div> | |
| <p class="text-gray-400">Render → SDF → Voronoi → <span class="text-yellow-400">Pruning</span> → <span class="text-blue-400">RDP</span> → Reconstruction</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-blue-400 border-b-2 border-blue-400 focus:outline-none transition hover:text-blue-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 | |
| </button> | |
| </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"> | |
| <!-- 1. Font & Export (Merged) --> | |
| <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-blue-500 transition text-ellipsis overflow-hidden"> | |
| <!-- Options populated by JS now --> | |
| </select> | |
| <button id="uploadBtn" class="bg-blue-600 hover:bg-blue-500 px-3 py-2 rounded text-sm font-bold text-white transition shrink-0" title="Load local font file"> | |
| 📁 | |
| </button> | |
| <input type="file" id="fontFileInput" accept=".ttf,.otf,.woff,.woff2" class="hidden"> | |
| </div> | |
| <button id="btnGenerateASCII" class="bg-green-600 hover:bg-green-500 text-white px-3 py-2 rounded text-sm font-bold transition shadow-lg whitespace-nowrap shrink-0"> | |
| Export ASCII | |
| </button> | |
| <button id="btnExportAll" class="bg-purple-600 hover:bg-purple-500 text-white px-3 py-2 rounded text-sm font-bold transition shadow-lg whitespace-nowrap shrink-0" title="Export all characters found in uploaded font"> | |
| Export All | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 2. Character Selection --> | |
| <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-blue-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-blue-500 transition opacity-50 cursor-not-allowed" disabled> | |
| <option value="">Load Custom Font...</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- 3. Style --> | |
| <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-blue-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-blue-500"> | |
| <span>Italic</span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- 4. Detail --> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label class="block text-sm font-medium text-gray-400">Detail (Voronoi)</label> | |
| <span id="valThreshold" class="text-xs text-blue-400 font-mono">0.1</span> | |
| </div> | |
| <input type="range" id="thresholdInput" min="0.1" max="2.0" step="0.1" value="0.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>High</span> | |
| <span>Low</span> | |
| </div> | |
| </div> | |
| <!-- 5. Pruning --> | |
| <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.2</span> | |
| </div> | |
| <input type="range" id="pruneInput" min="1.0" max="2.0" step="0.1" value="1.2" 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> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label class="block text-sm font-medium text-gray-400">Simplification (RDP)</label> | |
| <span id="valRDP" class="text-xs text-blue-400 font-mono">1.5</span> | |
| </div> | |
| <input type="range" id="rdpInput" min="0.1" max="50.0" step="0.1" value="1.5" 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>Exact</span> | |
| <span>Abstract</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Visualization Grid --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| <!-- Card 1: Render --> | |
| <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> | |
| <!-- Card 2: SDF --> | |
| <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> | |
| <!-- Card 3: Vector Voronoi --> | |
| <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> | |
| <span class="text-[10px] text-gray-400 ml-2">(Hover/Scroll)</span> | |
| </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> | |
| <!-- Card 4: Pruned (Initial) --> | |
| <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> | |
| <span class="text-[10px] text-gray-400 ml-2">(Hover/Scroll)</span> | |
| </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> | |
| <!-- Card 5: RDP Simplified --> | |
| <div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col ring-2 ring-purple-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. RDP Geometry</h3> | |
| <span class="text-[10px] text-gray-400 ml-2">(Hover/Scroll)</span> | |
| </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> | |
| <!-- Card 6: Reconstruction --> | |
| <div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden flex flex-col ring-2 ring-green-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. Reconstructed</h3> | |
| <span class="text-[10px] text-gray-400 ml-2">(Hover/Scroll)</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> | |
| <!-- JSON Output Section --> | |
| <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: { metrics: {lineHeight, baseline}, chars: [ { "charCode": 65, advance, lines: [...] } ] }</span> | |
| </div> | |
| <textarea id="jsonOutput" readonly class="w-full h-64 bg-gray-900 text-green-400 font-mono text-xs p-4 rounded border border-gray-700 focus:outline-none focus:border-blue-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> | |
| <!-- REPLACED TEXTAREA WITH SIMPLIFIED UI BLOCK --> | |
| <div class="flex-1 flex flex-col items-start justify-center gap-4"> | |
| <div class="flex gap-2 w-full items-center"> | |
| <button id="btnLoadJson" class="bg-purple-600 hover:bg-purple-500 text-white px-4 py-3 rounded font-bold transition shadow-lg flex items-center justify-center gap-2"> | |
| <span>📁 Load JSON File</span> | |
| </button> | |
| <input type="file" id="testerFileInput" accept=".json" class="hidden"> | |
| <span id="testerStatusText" class="text-gray-500 text-xs italic ml-2"> | |
| No font loaded. | |
| </span> | |
| </div> | |
| <!-- Removed Reset Button --> | |
| </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-blue-500 shadow-inner text-lg resize-none">Hello World! | |
| 123 & | |
| Multiline Test</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-blue-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> | |
| <!-- NEW BOLD COEF SLIDER --> | |
| <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-blue-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> | |
| <!-- NEW SKEW COEF SLIDER --> | |
| <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-blue-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> | |
| <!-- Removed RUN BUTTON --> | |
| </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</h3> | |
| <span class="text-xs text-gray-500">Vector Reconstruction</span> | |
| </div> | |
| <!-- Updated Background to bg-black for dark mode --> | |
| <div id="testerCanvasContainer" class="bg-black rounded border border-gray-600 overflow-hidden w-full flex justify-center min-h-[300px]"> | |
| <canvas id="testerCanvas" width="1200" height="600" class="bg-transparent"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </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 hoveredRawEdgeIndex = -1; | |
| let hoveredPrunedSegment = null; | |
| let hoveredRDPOnlySegment = null; | |
| let hoveredCapsuleIndex = -1; | |
| // Data Cache | |
| let cachedGrid = null; | |
| let cachedSDF = null; | |
| let cachedVoronoiData = null; | |
| let cachedRawEdges = []; | |
| let cachedPrunedData = null; | |
| let cachedRDPOnlyData = null; | |
| let cachedCapsules = []; | |
| let currentMetrics = { advance: 0, lineHeight: 0, baseline: 0, originX: 0 }; | |
| let loadedFontCharCodes = []; | |
| // Store custom font name if loaded | |
| 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'); | |
| const canvasReconstruct = document.getElementById('canvasReconstruct'); | |
| const ctxReconstruct = canvasReconstruct.getContext('2d'); | |
| 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'); | |
| // New Elements | |
| 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'); | |
| // NEW BOLD COEF ELEMENTS | |
| const boldCoef = document.getElementById('boldCoef'); | |
| const boldCoefVal = document.getElementById('boldCoefVal'); | |
| // NEW SKEW COEF ELEMENTS | |
| const skewCoef = document.getElementById('skewCoef'); | |
| const skewCoefVal = document.getElementById('skewCoefVal'); | |
| let testerData = null; // Global holder for tester data | |
| 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'), | |
| threshold: document.getElementById('thresholdInput'), | |
| rdp: document.getElementById('rdpInput'), | |
| prune: document.getElementById('pruneInput') | |
| }; | |
| const uiVals = { | |
| threshold: document.getElementById('valThreshold'), | |
| prune: document.getElementById('valPrune'), | |
| rdp: document.getElementById('valRDP') | |
| }; | |
| // --- Helper Functions (MATH) --- | |
| 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++; | |
| const dx = q - v[k]; | |
| d[q] = dx * dx + 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); | |
| } | |
| // --- Helper Functions (GRAPH ALGORITHMS) --- | |
| function getSDFRadiusBilinear(x, y) { | |
| const x0 = Math.floor(x); const 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; const 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]); | |
| const top = v00 * (1 - dx) + v10 * dx; | |
| const bottom = v01 * (1 - dx) + v11 * dx; | |
| return top * (1 - dy) + bottom * dy; | |
| } | |
| function buildAdjacencyFromVoronoi(delaunay, voronoi, isInsideGrid) { | |
| const adj = new Map(); | |
| const 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); const t2 = Math.floor(j / 3); | |
| const x1 = circumcenters[t1 * 2]; const y1 = circumcenters[t1 * 2 + 1]; | |
| const x2 = circumcenters[t2 * 2]; const 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; let 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) break; | |
| const key = getEdgeKey(curr, nextNode); | |
| if (processedEdges.has(key)) break; | |
| path.push(nextNode); | |
| processedEdges.add(key); | |
| 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; | |
| const pathIds = trace(startNode, nextNode); | |
| const points = pathIds.map(id => { | |
| const n = nodeData.get(id); return [n.x, n.y, getSDFRadiusBilinear(n.x, n.y)]; | |
| }); | |
| rawPolylines.push(points); | |
| } | |
| } | |
| 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))) { | |
| const pathIds = trace(startNode, nextNode); | |
| const points = pathIds.map(id => { | |
| const n = nodeData.get(id); return [n.x, n.y, getSDFRadiusBilinear(n.x, n.y)]; | |
| }); | |
| rawPolylines.push(points); | |
| } | |
| } | |
| } | |
| } | |
| return rawPolylines; | |
| } | |
| function pruneHairs(polylines, pruneCoef) { | |
| const graph = new Map(); | |
| const getKey = (p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`; | |
| function 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; | |
| } | |
| function addEdge(p1, p2) { | |
| const k1 = addNode(p1); const k2 = addNode(p2); | |
| if (k1 !== k2) { | |
| graph.get(k1).neighbors.add(k2); | |
| graph.get(k2).neighbors.add(k1); | |
| } | |
| } | |
| polylines.forEach(poly => { | |
| for (let i = 0; i < poly.length - 1; i++) addEdge(poly[i], poly[i+1]); | |
| }); | |
| 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; | |
| let path = [currentKey]; | |
| let length = 0; | |
| let 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); | |
| const dx = nextNode.x - currentNode.x; | |
| const dy = nextNode.y - currentNode.y; | |
| length += Math.sqrt(dx*dx + dy*dy); | |
| 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) { | |
| const isMicroHair = hair.length < 10.0; | |
| const isRelativeHair = (hair.length + hair.looseEndRadius) < (hair.junctionRadius * pruneCoef); | |
| if (isMicroHair || isRelativeHair) { | |
| 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 priorityNodes = allNodes.filter(k => graph.get(k).neighbors.size !== 2); | |
| const processingOrder = [...priorityNodes, ...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 = []; | |
| poly.push([startNode.x, startNode.y, startNode.r]); | |
| let currKey = neighborKey; | |
| let 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; | |
| } | |
| const nextEdgeKey = getEdgeKey(currKey, nextKey); | |
| visitedEdges.add(nextEdgeKey); | |
| prevKey = currKey; currKey = nextKey; | |
| } | |
| newPolylines.push(poly); | |
| } | |
| } | |
| return newPolylines; | |
| } | |
| function calculateJunctions(polylines) { | |
| const junctions = []; | |
| const 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]); | |
| const 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); const t2 = Math.floor(j / 3); | |
| const x1 = circumcenters[t1 * 2]; const y1 = circumcenters[t1 * 2 + 1]; | |
| const x2 = circumcenters[t2 * 2]; const y2 = circumcenters[t2 * 2 + 1]; | |
| if (isInside(x1, y1) && isInside(x2, y2)) { | |
| cachedRawEdges.push({ x1, y1, x2, y2, id: i }); | |
| } | |
| } | |
| } | |
| function buildCapsules(polylines) { | |
| cachedCapsules = []; | |
| for (const poly of polylines) { | |
| if (poly.length < 2) continue; | |
| for (let i = 1; i < poly.length; i++) { | |
| const p1 = poly[i-1]; const p2 = poly[i]; | |
| const r1 = p1[2] !== undefined ? p1[2] : getSDFRadiusBilinear(p1[0], p1[1]); | |
| const r2 = p2[2] !== undefined ? p2[2] : getSDFRadiusBilinear(p2[0], p2[1]); | |
| cachedCapsules.push({ x1: p1[0], y1: p1[1], r1: r1, x2: p2[0], y2: p2[1], r2: r2 }); | |
| } | |
| } | |
| } | |
| function ramerDouglasPeucker(points, epsilon) { | |
| if (points.length < 3) return points; | |
| let dmax = 0; let 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]]; } | |
| } | |
| function perpendicularDistance(p, lineStart, lineEnd) { | |
| let dx = lineEnd[0] - lineStart[0]; let dy = lineEnd[1] - lineStart[1]; let dz = lineEnd[2] - lineStart[2]; | |
| let magSq = dx*dx + dy*dy + dz*dz; | |
| if (magSq > 0) { | |
| const pax = p[0] - lineStart[0]; const pay = p[1] - lineStart[1]; const paz = p[2] - lineStart[2]; | |
| const t = (pax * dx + pay * dy + paz * dz) / magSq; | |
| const tClamped = Math.max(0, Math.min(1, t)); | |
| const projx = lineStart[0] + tClamped * dx; | |
| const projy = lineStart[1] + tClamped * dy; | |
| const projz = lineStart[2] + tClamped * dz; | |
| const ddx = p[0] - projx; const ddy = p[1] - projy; const ddz = p[2] - projz; | |
| return Math.sqrt(ddx*ddx + ddy*ddy + ddz*ddz); | |
| } else { | |
| const ddx = p[0] - lineStart[0]; const ddy = p[1] - lineStart[1]; const ddz = p[2] - lineStart[2]; | |
| return Math.sqrt(ddx*ddx + ddy*ddy + ddz*ddz); | |
| } | |
| } | |
| // --- Main Logic --- | |
| function updateUIValues() { | |
| uiVals.threshold.innerText = inputs.threshold.value; | |
| uiVals.prune.innerText = inputs.prune.value; | |
| uiVals.rdp.innerText = inputs.rdp.value; | |
| } | |
| function process() { | |
| // If user selected the special SCAN value, don't render | |
| if (inputs.font.value === '__SCAN__') return; | |
| updateUIValues(); | |
| // 1. Render Character | |
| const char = inputs.char.value || 'A'; | |
| renderCharacter(char); | |
| // 2. Get Binary Grid | |
| const imageData = ctxRender.getImageData(0, 0, WIDTH, HEIGHT); | |
| const grid = getBinaryGrid(imageData); | |
| // 3. Compute SDF | |
| const distOutside = computeEDT(grid, 1); | |
| const distInside = computeEDT(grid, 0); | |
| const sdf = new Float32Array(WIDTH * HEIGHT); | |
| let minVal = INF, maxVal = -INF; | |
| for(let i=0; i<sdf.length; i++) { | |
| const dOut = Math.sqrt(distOutside[i]); | |
| const dIn = Math.sqrt(distInside[i]); | |
| const val = dOut - dIn; | |
| sdf[i] = val; | |
| if(val < minVal) minVal = val; | |
| if(val > maxVal) maxVal = val; | |
| } | |
| cachedSDF = sdf; | |
| visualizeSDF(sdf, minVal, maxVal); | |
| // 4. Compute Vector Pipeline | |
| computeVectorPipeline(grid); | |
| } | |
| // --- BATCH PROCESSING --- | |
| btnGenerateASCII.addEventListener('click', () => generateAtlas('ascii')); | |
| btnExportAll.addEventListener('click', () => generateAtlas('all')); | |
| async function generateAtlas(mode) { | |
| const activeBtn = mode === 'ascii' ? btnGenerateASCII : btnExportAll; | |
| const otherBtn = mode === 'ascii' ? btnExportAll : btnGenerateASCII; | |
| activeBtn.disabled = true; | |
| activeBtn.classList.add('opacity-50'); | |
| otherBtn.disabled = true; | |
| otherBtn.classList.add('opacity-50'); | |
| batchStatus.classList.remove('hidden'); | |
| let charCodes = []; | |
| if (mode === 'ascii') { | |
| for (let i = 32; i <= 126; i++) charCodes.push(i); | |
| } else if (mode === 'all') { | |
| if (loadedFontCharCodes.length === 0 || !customFontName) { | |
| alert("No custom font loaded. Please upload a font file to use 'Export All'."); | |
| activeBtn.disabled = false; activeBtn.classList.remove('opacity-50'); | |
| otherBtn.disabled = false; otherBtn.classList.remove('opacity-50'); | |
| batchStatus.classList.add('hidden'); | |
| return; | |
| } | |
| charCodes = loadedFontCharCodes; | |
| } | |
| const total = charCodes.length; | |
| renderCharacter("H"); | |
| const globalMetrics = { | |
| lineHeight: currentMetrics.lineHeight, | |
| baseline: currentMetrics.baseline - TOP_MARGIN | |
| }; | |
| // CHANGE 1: Use Array instead of Object | |
| const charsData = []; | |
| const originalChar = inputs.char.value; | |
| for (let i = 0; i < total; i++) { | |
| const code = charCodes[i]; | |
| const char = String.fromCharCode(code); | |
| const percent = Math.round((i / total) * 100); | |
| batchStatus.innerText = `Processing '${char}' (${i+1}/${total})... ${percent}%`; | |
| await new Promise(resolve => setTimeout(resolve, 0)); | |
| // Must use the system font if not custom, otherwise load errors happen | |
| if (!customFontName) { | |
| renderCharacter(char); | |
| } else { | |
| renderCharacter(char); | |
| } | |
| const imageData = ctxRender.getImageData(0, 0, WIDTH, HEIGHT); | |
| const grid = getBinaryGrid(imageData); | |
| const distOutside = computeEDT(grid, 1); | |
| const distInside = computeEDT(grid, 0); | |
| const sdf = new Float32Array(WIDTH * HEIGHT); | |
| for(let k=0; k<sdf.length; k++) { | |
| const dOut = Math.sqrt(distOutside[k]); | |
| const dIn = Math.sqrt(distInside[k]); | |
| sdf[k] = dOut - dIn; | |
| } | |
| cachedSDF = sdf; | |
| // 3. Headless Pipeline | |
| const fullData = computeVectorPipelineHeadless(grid); | |
| // CHANGE 2: Push object with ID to array | |
| charsData.push({ | |
| charCode: code, | |
| advance: fullData.advance, | |
| lines: fullData.lines | |
| }); | |
| } | |
| // Restore | |
| inputs.char.value = originalChar; | |
| batchStatus.innerText = "Done!"; | |
| activeBtn.disabled = false; activeBtn.classList.remove('opacity-50'); | |
| otherBtn.disabled = false; otherBtn.classList.remove('opacity-50'); | |
| const jsonParts = []; | |
| jsonParts.push("{"); | |
| jsonParts.push(` "metrics": ${JSON.stringify(globalMetrics)},`); | |
| // CHANGE 3: Write Array format | |
| jsonParts.push(` "chars": [`); | |
| charsData.forEach((item, i) => { | |
| const comma = i < charsData.length - 1 ? "," : ""; | |
| // We stringify the item so it fits on one line per char for readability | |
| jsonParts.push(` ${JSON.stringify(item)}${comma}`); | |
| }); | |
| jsonParts.push(" ]"); | |
| jsonParts.push("}"); | |
| const blob = new Blob([jsonParts.join("\n")], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| const filename = mode === 'ascii' ? "font_atlas_ascii.json" : "font_atlas_complete.json"; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| process(); | |
| } | |
| function computeVectorPipelineHeadless(grid) { | |
| const points = []; | |
| const sliderVal = parseFloat(inputs.threshold.value); | |
| const step = Math.max(1, Math.floor(1.2 * sliderVal)); | |
| for(let y=1; y<HEIGHT-1; y+=1) { | |
| for(let x=1; x<WIDTH-1; x+=1) { | |
| if (grid[y*WIDTH+x] === 1) { | |
| const isTop = grid[(y-1)*WIDTH+x] === 0; | |
| const isBottom = grid[(y+1)*WIDTH+x] === 0; | |
| const isLeft = grid[y*WIDTH+(x-1)] === 0; | |
| const isRight = grid[y*WIDTH+(x+1)] === 0; | |
| if (isTop || isBottom || isLeft || isRight) { | |
| let sample = false; | |
| if ((isTop || isBottom) && (x % step === 0)) sample = true; | |
| if ((isLeft || isRight) && (y % step === 0)) sample = true; | |
| if ((isTop || isBottom) && (isLeft || isRight)) sample = true; | |
| if (sample) points.push([x + 0.5, y + 0.5]); | |
| } | |
| } | |
| } | |
| } | |
| if (points.length < 3) { | |
| return { | |
| advance: currentMetrics.advance, | |
| lineHeight: currentMetrics.lineHeight, | |
| baseline: currentMetrics.baseline - TOP_MARGIN, | |
| lines: [] | |
| }; | |
| } | |
| const delaunay = d3.Delaunay.from(points); | |
| const voronoi = delaunay.voronoi([0, 0, WIDTH, HEIGHT]); | |
| const isInside = (x, y) => { | |
| const ix = Math.floor(x); const iy = Math.floor(y); | |
| if (ix < 0 || ix >= WIDTH || iy < 0 || iy >= HEIGHT) return false; | |
| return grid[iy * WIDTH + ix] === 1; | |
| }; | |
| let { adj, nodeData } = buildAdjacencyFromVoronoi(delaunay, voronoi, isInside); | |
| let rawPolylines = tracePolylines(adj, nodeData); | |
| let prunedPolylines = rawPolylines; | |
| const pruneCoef = parseFloat(inputs.prune.value); | |
| if (pruneCoef > 0) { | |
| prunedPolylines = pruneHairs(rawPolylines, pruneCoef); | |
| } | |
| const rdpEpsilon = parseFloat(inputs.rdp.value); | |
| let simplifiedPolylines = prunedPolylines.map(p => ramerDouglasPeucker(p, rdpEpsilon)); | |
| const flatGeometry = []; | |
| for (const poly of simplifiedPolylines) { | |
| if (poly.length < 2) continue; | |
| for (let i = 0; i < poly.length - 1; i++) { | |
| const p1 = poly[i]; | |
| const p2 = poly[i+1]; | |
| flatGeometry.push(Math.round(p1[0] - currentMetrics.originX)); | |
| flatGeometry.push(Math.round(p1[1] - TOP_MARGIN)); | |
| flatGeometry.push(Math.round(p1[2])); | |
| flatGeometry.push(Math.round(p2[0] - currentMetrics.originX)); | |
| flatGeometry.push(Math.round(p2[1] - TOP_MARGIN)); | |
| flatGeometry.push(Math.round(p2[2])); | |
| } | |
| } | |
| return { | |
| advance: currentMetrics.advance, | |
| lineHeight: currentMetrics.lineHeight, | |
| baseline: currentMetrics.baseline - TOP_MARGIN, | |
| lines: flatGeometry | |
| }; | |
| } | |
| function computeVectorPipeline(grid) { | |
| cachedGrid = grid; | |
| updateGhostCanvas(grid); | |
| // --- Step A: Voronoi Generation --- | |
| const points = []; | |
| const sliderVal = parseFloat(inputs.threshold.value); | |
| const step = Math.max(1, Math.floor(1.2 * sliderVal)); | |
| for(let y=1; y<HEIGHT-1; y+=1) { | |
| for(let x=1; x<WIDTH-1; x+=1) { | |
| if (grid[y*WIDTH+x] === 1) { | |
| const isTop = grid[(y-1)*WIDTH+x] === 0; | |
| const isBottom = grid[(y+1)*WIDTH+x] === 0; | |
| const isLeft = grid[y*WIDTH+(x-1)] === 0; | |
| const isRight = grid[y*WIDTH+(x+1)] === 0; | |
| if (isTop || isBottom || isLeft || isRight) { | |
| let sample = false; | |
| if ((isTop || isBottom) && (x % step === 0)) sample = true; | |
| if ((isLeft || isRight) && (y % step === 0)) sample = true; | |
| if ((isTop || isBottom) && (isLeft || isRight)) sample = true; | |
| if (sample) 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); const iy = Math.floor(y); | |
| if (ix < 0 || ix >= WIDTH || iy < 0 || iy >= HEIGHT) return false; | |
| return grid[iy * WIDTH + ix] === 1; | |
| }; | |
| buildRawEdgesCache(delaunay, voronoi, isInside); | |
| // --- Step B: Raw Tracing --- | |
| let { adj, nodeData } = buildAdjacencyFromVoronoi(delaunay, voronoi, isInside); | |
| let rawPolylines = tracePolylines(adj, nodeData); | |
| // --- Step C: Pruning --- | |
| let prunedPolylines = rawPolylines; | |
| const pruneCoef = parseFloat(inputs.prune.value); | |
| if (pruneCoef > 0) { | |
| prunedPolylines = pruneHairs(rawPolylines, pruneCoef); | |
| } | |
| const prunedJunctions = calculateJunctions(prunedPolylines); | |
| cachedPrunedData = { polylines: prunedPolylines, junctions: prunedJunctions }; | |
| // --- Step D: RDP Simplification --- | |
| const rdpEpsilon = parseFloat(inputs.rdp.value); | |
| let simplifiedPolylines = prunedPolylines.map(p => ramerDouglasPeucker(p, rdpEpsilon)); | |
| const rdpOnlyJunctions = calculateJunctions(simplifiedPolylines); | |
| cachedRDPOnlyData = { polylines: simplifiedPolylines, junctions: rdpOnlyJunctions }; | |
| // --- Step F: Reconstruction Data --- | |
| buildCapsules(simplifiedPolylines); | |
| // --- Step G: Update JSON Output --- | |
| if (jsonOutput) { | |
| const flatGeometry = []; | |
| for (const poly of simplifiedPolylines) { | |
| if (poly.length < 2) continue; | |
| for (let i = 0; i < poly.length - 1; i++) { | |
| const p1 = poly[i]; | |
| const p2 = poly[i+1]; | |
| flatGeometry.push(Math.round(p1[0] - currentMetrics.originX)); | |
| flatGeometry.push(Math.round(p1[1] - TOP_MARGIN)); | |
| flatGeometry.push(Math.round(p1[2])); | |
| flatGeometry.push(Math.round(p2[0] - currentMetrics.originX)); | |
| flatGeometry.push(Math.round(p2[1] - TOP_MARGIN)); | |
| flatGeometry.push(Math.round(p2[2])); | |
| } | |
| } | |
| // Get current character info | |
| const charStr = inputs.char.value || ' '; | |
| const code = charStr.charCodeAt(0); | |
| // CHANGE 4: Include ID in the data object | |
| const charData = { | |
| charCode: code, | |
| advance: currentMetrics.advance, | |
| lines: flatGeometry | |
| }; | |
| const metrics = { | |
| lineHeight: currentMetrics.lineHeight, | |
| baseline: currentMetrics.baseline - TOP_MARGIN | |
| }; | |
| // Formatted JSON output to match "Export All" structure | |
| const metricsStr = JSON.stringify(metrics); | |
| const charVal = JSON.stringify(charData); | |
| // CHANGE 5: Format as array in textarea | |
| jsonOutput.value = `{\n "metrics": ${metricsStr},\n "chars": [\n ${charVal}\n ]\n}`; | |
| } | |
| drawVectorLayers(); | |
| } | |
| function renderCharacter(char) { | |
| ctxRender.clearRect(0, 0, WIDTH, HEIGHT); | |
| let fontStyle = ""; | |
| if (inputs.italic.checked) fontStyle += "italic "; | |
| if (inputs.bold.checked) fontStyle += "bold "; | |
| const fontSize = Math.floor(HEIGHT * 0.7); | |
| const fontFamily = inputs.font.value; | |
| const fontString = `${fontStyle}${fontSize}px "${fontFamily}"`; | |
| ctxRender.font = fontString; | |
| ctxRender.fillStyle = "black"; | |
| ctxRender.textAlign = "left"; | |
| ctxRender.textBaseline = "alphabetic"; | |
| const refMetrics = ctxRender.measureText("H"); | |
| const refAscent = refMetrics.fontBoundingBoxAscent || (refMetrics.actualBoundingBoxAscent + (fontSize * 0.2)); | |
| const baselineY = TOP_MARGIN + refAscent; | |
| 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 | |
| }; | |
| } | |
| // --- Font List Logic --- | |
| const standardFonts = [ | |
| "Arial", "Verdana", "Helvetica", "Tahoma", "Trebuchet MS", "Times New Roman", | |
| "Georgia", "Garamond", "Courier New", "Brush Script MT", "Segoe UI", "Roboto", | |
| "Lato", "Open Sans", "Montserrat", "Ubuntu", "Impact", "Comic Sans MS", | |
| "Lucida Console", "Monaco", "Consolas", "Courier", "Arial Black", "Arial Narrow", | |
| "Book Antiqua", "Calibri", "Cambria", "Candara", "Century Gothic", "Franklin Gothic Medium", | |
| "Geneva", "Helvetica Neue", "Lucida Sans Unicode", "Optima", "Palatino Linotype", | |
| "Perpetua", "Rockwell", "Segoe Print", "Symbol", "Webdings", "Wingdings" | |
| ]; | |
| function populateFonts(extras = []) { | |
| const current = inputs.font.value; | |
| inputs.font.innerHTML = ''; | |
| // 1. Add Scan Option if supported (Moved to top) | |
| 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); | |
| } | |
| // Combine Standard, Extras, and Custom Uploaded (if exists) | |
| let all = [...new Set([...standardFonts, ...extras])].sort(); | |
| // Always put custom font at top of the list (below Scan/Divider) | |
| if (customFontName) { | |
| // Remove from sort list to avoid duplication | |
| all = all.filter(f => f !== customFontName); | |
| const opt = document.createElement('option'); | |
| opt.value = customFontName; | |
| opt.text = "Custom: " + customFontName.replace("CustomUploadedFont", "File"); | |
| inputs.font.add(opt); | |
| } | |
| all.forEach(font => { | |
| const opt = document.createElement('option'); | |
| opt.value = font; | |
| opt.text = font; | |
| inputs.font.add(opt); | |
| }); | |
| // Try to restore selection | |
| if (current && current !== '__SCAN__' && (all.includes(current) || current === customFontName)) { | |
| inputs.font.value = current; | |
| } else { | |
| // Select first valid font. If Scan is present (indices 0 & 1), pick index 2. | |
| if ('queryLocalFonts' in window) { | |
| inputs.font.selectedIndex = 2; | |
| } else { | |
| inputs.font.selectedIndex = 0; | |
| } | |
| } | |
| } | |
| // Initialize Fonts | |
| populateFonts(); | |
| // --- Event Listeners --- | |
| // Handle Font Select Change (Including Scan Logic) | |
| inputs.font.addEventListener('change', async (e) => { | |
| if (e.target.value === '__SCAN__') { | |
| try { | |
| const localFonts = await window.queryLocalFonts(); | |
| const fontNames = localFonts.map(f => f.family); | |
| populateFonts(fontNames); | |
| // Alert user | |
| alert(`Successfully scanned ${fontNames.length} local fonts!`); | |
| // Reset to first real font (Index 2 to skip Scan + Divider) | |
| inputs.font.selectedIndex = 2; | |
| process(); | |
| } catch (err) { | |
| console.error(err); | |
| alert('Could not access local fonts. Browser may not support this feature or permission was denied.'); | |
| // Reset to first real font | |
| inputs.font.selectedIndex = 2; | |
| } | |
| } else { | |
| process(); | |
| } | |
| }); | |
| // --- Font File Upload --- | |
| inputs.uploadBtn.addEventListener('click', () => inputs.fileInput.click()); | |
| inputs.fileInput.addEventListener('change', async (e) => { | |
| if (e.target.files.length === 0) return; | |
| const file = e.target.files[0]; | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const fontName = "CustomUploadedFont"; | |
| const fontFace = new FontFace(fontName, arrayBuffer); | |
| try { | |
| await fontFace.load(); | |
| document.fonts.add(fontFace); | |
| // Update Global State | |
| customFontName = fontName; | |
| // Repopulate list to include custom font | |
| populateFonts(); | |
| inputs.font.value = fontName; | |
| try { | |
| const font = opentype.parse(arrayBuffer); | |
| populateGlyphSelect(font); | |
| } catch (parseErr) { | |
| console.warn("Opentype parsing failed", parseErr); | |
| inputs.glyphSelect.innerHTML = '<option value="">Parsing failed</option>'; | |
| } | |
| process(); | |
| } catch (err) { | |
| console.error("Font loading failed:", err); | |
| alert("Failed to load font file."); | |
| } | |
| }); | |
| 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); | |
| } | |
| } | |
| // 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); | |
| }); | |
| } | |
| inputs.glyphSelect.addEventListener('change', (e) => { | |
| if (e.target.value) { inputs.char.value = e.target.value; process(); } | |
| }); | |
| function updateGhostCanvas(grid) { | |
| const imgData = ghostCtx.createImageData(WIDTH, HEIGHT); | |
| const data = imgData.data; | |
| for(let i=0; i < grid.length; i++) { | |
| if(grid[i] === 1) { | |
| const idx = i * 4; | |
| data[idx] = 31; data[idx+1] = 41; data[idx+2] = 55; 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(); | |
| let count = 0; let highlightedEdge = null; | |
| cachedRawEdges.forEach(edge => { | |
| if (edge.id === hoveredRawEdgeIndex) highlightedEdge = edge; | |
| else { ctxVoronoi.moveTo(edge.x1, edge.y1); ctxVoronoi.lineTo(edge.x2, edge.y2); } | |
| count++; | |
| }); | |
| ctxVoronoi.stroke(); | |
| if (highlightedEdge) { | |
| ctxVoronoi.lineWidth = 3; ctxVoronoi.strokeStyle = "#facc15"; ctxVoronoi.beginPath(); | |
| ctxVoronoi.moveTo(highlightedEdge.x1, highlightedEdge.y1); ctxVoronoi.lineTo(highlightedEdge.x2, highlightedEdge.y2); ctxVoronoi.stroke(); | |
| } | |
| uiRawCount.innerText = `${count} 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); | |
| if (!cachedPrunedData) return; | |
| const { polylines, junctions } = cachedPrunedData; | |
| ctxPruned.lineJoin = "round"; ctxPruned.lineCap = "round"; | |
| ctxPruned.lineWidth = 1; ctxPruned.strokeStyle = "#f59e0b"; | |
| ctxPruned.beginPath(); | |
| let count = 0; let highlightedSeg = null; | |
| for(let pIdx = 0; pIdx < polylines.length; pIdx++) { | |
| const poly = polylines[pIdx]; | |
| if(poly.length < 2) continue; | |
| for(let i=1; i<poly.length; i++) { | |
| const isHovered = hoveredPrunedSegment && (hoveredPrunedSegment.polyIndex === pIdx && hoveredPrunedSegment.pointIndex === i-1); | |
| if (isHovered) highlightedSeg = { p1: poly[i-1], p2: poly[i] }; | |
| else { ctxPruned.moveTo(poly[i-1][0], poly[i-1][1]); ctxPruned.lineTo(poly[i][0], poly[i][1]); } | |
| count++; | |
| } | |
| } | |
| ctxPruned.stroke(); | |
| if (highlightedSeg) { | |
| ctxPruned.lineWidth = 3; ctxPruned.strokeStyle = "#facc15"; | |
| ctxPruned.beginPath(); ctxPruned.moveTo(highlightedSeg.p1[0], highlightedSeg.p1[1]); ctxPruned.lineTo(highlightedSeg.p2[0], highlightedSeg.p2[1]); ctxPruned.stroke(); | |
| } | |
| ctxPruned.fillStyle = "#ef4444"; | |
| for (const j of junctions) { ctxPruned.beginPath(); ctxPruned.arc(j[0], j[1], 2.5, 0, Math.PI * 2); ctxPruned.fill(); } | |
| uiPrunedCount.innerText = `${count} 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); | |
| if (!cachedRDPOnlyData) return; | |
| const { polylines, junctions } = cachedRDPOnlyData; | |
| ctxRDPOnly.lineJoin = "round"; ctxRDPOnly.lineCap = "round"; | |
| ctxRDPOnly.lineWidth = 2; ctxRDPOnly.strokeStyle = "#a855f7"; | |
| ctxRDPOnly.beginPath(); | |
| let count = 0; let highlightedSeg = null; | |
| for(let pIdx = 0; pIdx < polylines.length; pIdx++) { | |
| const poly = polylines[pIdx]; | |
| if(poly.length < 2) continue; | |
| for(let i=1; i<poly.length; i++) { | |
| const isHovered = hoveredRDPOnlySegment && (hoveredRDPOnlySegment.polyIndex === pIdx && hoveredRDPOnlySegment.pointIndex === i-1); | |
| if (isHovered) highlightedSeg = { p1: poly[i-1], p2: poly[i] }; | |
| else { ctxRDPOnly.moveTo(poly[i-1][0], poly[i-1][1]); ctxRDPOnly.lineTo(poly[i][0], poly[i][1]); } | |
| count++; | |
| } | |
| } | |
| ctxRDPOnly.stroke(); | |
| ctxRDPOnly.fillStyle = "#e9d5ff"; | |
| for (const poly of polylines) { | |
| if (poly.length < 1) continue; | |
| for (const p of poly) { ctxRDPOnly.beginPath(); ctxRDPOnly.arc(p[0], p[1], 1.5, 0, Math.PI * 2); ctxRDPOnly.fill(); } | |
| } | |
| if (highlightedSeg) { | |
| ctxRDPOnly.lineWidth = 4; ctxRDPOnly.strokeStyle = "#facc15"; | |
| ctxRDPOnly.beginPath(); ctxRDPOnly.moveTo(highlightedSeg.p1[0], highlightedSeg.p1[1]); ctxRDPOnly.lineTo(highlightedSeg.p2[0], highlightedSeg.p2[1]); ctxRDPOnly.stroke(); | |
| } | |
| ctxRDPOnly.fillStyle = "#ef4444"; | |
| for (const j of junctions) { ctxRDPOnly.beginPath(); ctxRDPOnly.arc(j[0], j[1], 4, 0, Math.PI * 2); ctxRDPOnly.fill(); } | |
| uiRdpOnlyCount.innerText = `${count} segs`; | |
| } | |
| function drawReconstruction() { | |
| ctxReconstruct.setTransform(1, 0, 0, 1, 0, 0); | |
| ctxReconstruct.fillStyle = "#111827"; ctxReconstruct.fillRect(0, 0, WIDTH, HEIGHT); | |
| ctxReconstruct.save(); | |
| ctxReconstruct.translate(viewTransform.x, viewTransform.y); | |
| ctxReconstruct.scale(viewTransform.k, viewTransform.k); | |
| ctxReconstruct.globalAlpha = 0.3; | |
| ctxReconstruct.drawImage(ghostCanvas, 0, 0); | |
| ctxReconstruct.restore(); | |
| ctxReconstruct.translate(viewTransform.x, viewTransform.y); | |
| ctxReconstruct.scale(viewTransform.k, viewTransform.k); | |
| cachedCapsules.forEach((cap, index) => { | |
| const isHovered = (index === hoveredCapsuleIndex); | |
| ctxReconstruct.fillStyle = isHovered ? "#facc15" : "white"; | |
| ctxReconstruct.strokeStyle = isHovered ? "#facc15" : "white"; | |
| drawUnevenCapsule(ctxReconstruct, cap.x1, cap.y1, cap.r1, cap.x2, cap.y2, cap.r2); | |
| }); | |
| } | |
| function drawUnevenCapsule(ctx, x1, y1, r1, x2, y2, r2) { | |
| ctx.beginPath(); ctx.arc(x1, y1, r1, 0, Math.PI*2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(x2, y2, r2, 0, Math.PI*2); ctx.fill(); | |
| const dist = Math.sqrt((x1-x2)**2 + (y1-y2)**2); | |
| if (dist <= Math.abs(r1 - r2)) return; | |
| const angle = Math.atan2(y2-y1, x2-x1); | |
| const offset = Math.acos((r1-r2)/dist); | |
| const t1 = angle + offset; const t2 = angle - offset; | |
| const cx1_1 = x1 + r1 * Math.cos(t1); const cy1_1 = y1 + r1 * Math.sin(t1); | |
| const cx2_1 = x2 + r2 * Math.cos(t1); const cy2_1 = y2 + r2 * Math.sin(t1); | |
| const cx1_2 = x1 + r1 * Math.cos(t2); const cy1_2 = y1 + r1 * Math.sin(t2); | |
| const cx2_2 = x2 + r2 * Math.cos(t2); const cy2_2 = y2 + r2 * Math.sin(t2); | |
| ctx.beginPath(); ctx.moveTo(cx1_1, cy1_1); ctx.lineTo(cx2_1, cy2_1); ctx.lineTo(cx2_2, cy2_2); ctx.lineTo(cx1_2, cy1_2); ctx.closePath(); ctx.fill(); | |
| } | |
| function setupInteractions() { | |
| const canvases = [canvasVoronoi, canvasPruned, canvasRDPOnly, canvasReconstruct]; | |
| const handleWheel = (e) => { | |
| e.preventDefault(); | |
| const scaleBy = 1.1; | |
| const rect = e.target.getBoundingClientRect(); | |
| const scaleX = e.target.width / rect.width; | |
| const scaleY = e.target.height / rect.height; | |
| const mouseX = (e.clientX - rect.left) * scaleX; | |
| const mouseY = (e.clientY - rect.top) * scaleY; | |
| const wx = (mouseX - viewTransform.x) / viewTransform.k; | |
| const wy = (mouseY - viewTransform.y) / viewTransform.k; | |
| if (e.deltaY < 0) viewTransform.k *= scaleBy; else viewTransform.k /= scaleBy; | |
| viewTransform.x = mouseX - wx * viewTransform.k; | |
| viewTransform.y = mouseY - wy * viewTransform.k; | |
| drawVectorLayers(); | |
| }; | |
| const handleMouseDown = (e) => { | |
| e.preventDefault(); | |
| isDragging = true; | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| canvases.forEach(c => { c.classList.remove('cursor-grab'); c.classList.add('cursor-grabbing'); }); | |
| }; | |
| const handleMouseMove = (e) => { | |
| if (isDragging) { | |
| const rect = canvasVoronoi.getBoundingClientRect(); | |
| const scaleX = canvasVoronoi.width / rect.width; | |
| const scaleY = canvasVoronoi.height / rect.height; | |
| const dx = (e.clientX - lastMouse.x) * scaleX; | |
| const dy = (e.clientY - lastMouse.y) * scaleY; | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| viewTransform.x += dx; viewTransform.y += dy; | |
| drawVectorLayers(); | |
| } else { | |
| 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 === canvasVoronoi) { | |
| let closestDist = 10 / viewTransform.k; let closestIndex = -1; | |
| cachedRawEdges.forEach(edge => { | |
| const d = distToSegmentSquared([wx, wy], [edge.x1, edge.y1], [edge.x2, edge.y2]); | |
| if (d < closestDist * closestDist) { closestDist = Math.sqrt(d); closestIndex = edge.id; } | |
| }); | |
| if (hoveredRawEdgeIndex !== closestIndex) { hoveredRawEdgeIndex = closestIndex; drawRawVoronoi(); } | |
| } | |
| else if (e.target === canvasPruned) { | |
| // GUARD ADDED | |
| if (!cachedPrunedData) return; | |
| let closestDist = 10 / viewTransform.k; let closestSeg = null; | |
| const { polylines } = cachedPrunedData; | |
| 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); closestSeg = { polyIndex: p, pointIndex: i-1 }; } | |
| } | |
| } | |
| if (JSON.stringify(hoveredPrunedSegment) !== JSON.stringify(closestSeg)) { hoveredPrunedSegment = closestSeg; drawPrunedGraph(); } | |
| } | |
| else if (e.target === canvasRDPOnly) { | |
| // GUARD ADDED | |
| if (!cachedRDPOnlyData) return; | |
| let closestDist = 10 / viewTransform.k; let closestSeg = null; | |
| 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); closestSeg = { polyIndex: p, pointIndex: i-1 }; } | |
| } | |
| } | |
| if (JSON.stringify(hoveredRDPOnlySegment) !== JSON.stringify(closestSeg)) { hoveredRDPOnlySegment = closestSeg; drawRDPOnlyGraph(); } | |
| } | |
| else if (e.target === canvasReconstruct) { | |
| let closestDist = 15 / viewTransform.k; let closestIndex = -1; | |
| cachedCapsules.forEach((cap, i) => { | |
| const d = distToSegmentSquared([wx, wy], [cap.x1, cap.y1], [cap.x2, cap.x2]); | |
| if (d < closestDist * closestDist) { closestDist = Math.sqrt(d); closestIndex = i; } | |
| }); | |
| if (hoveredCapsuleIndex !== closestIndex) { | |
| hoveredCapsuleIndex = closestIndex; | |
| if(closestIndex !== -1) { | |
| const cap = cachedCapsules[closestIndex]; uiReconstructInfo.innerText = `R: ${cap.r1.toFixed(1)} → ${cap.r2.toFixed(1)}`; | |
| } else { uiReconstructInfo.innerText = "-"; } | |
| drawReconstruction(); | |
| } | |
| } | |
| } | |
| }; | |
| const handleMouseUp = () => { | |
| isDragging = false; | |
| canvases.forEach(c => { c.classList.add('cursor-grab'); c.classList.remove('cursor-grabbing'); }); | |
| }; | |
| canvases.forEach(c => { | |
| c.addEventListener('wheel', handleWheel); c.addEventListener('mousedown', handleMouseDown); c.addEventListener('mousemove', handleMouseMove); | |
| }); | |
| window.addEventListener('mousemove', (e) => { if (isDragging) handleMouseMove(e); }); | |
| window.addEventListener('mouseup', handleMouseUp); | |
| } | |
| // --- Tabs Logic & Tester Logic --- | |
| function resizeTesterCanvas() { | |
| const rect = testerCanvasContainer.getBoundingClientRect(); | |
| // Set the actual drawing dimensions | |
| testerCanvas.width = rect.width; | |
| testerCanvas.height = rect.height; | |
| // The canvas needs to be re-rendered after resizing | |
| runTestRender(); | |
| } | |
| function switchTab(tab) { | |
| if (tab === 'gen') { | |
| viewGenerator.classList.remove('hidden'); | |
| viewGenerator.classList.add('block'); | |
| viewTester.classList.add('hidden'); | |
| tabGen.classList.add('text-blue-400', 'border-blue-400'); | |
| tabGen.classList.remove('text-gray-400', 'border-transparent'); | |
| tabTest.classList.remove('text-blue-400', 'border-blue-400'); | |
| tabTest.classList.add('text-gray-400', 'border-transparent'); | |
| } else { | |
| viewGenerator.classList.add('hidden'); | |
| viewGenerator.classList.remove('block'); | |
| viewTester.classList.remove('hidden'); | |
| tabTest.classList.add('text-blue-400', 'border-blue-400'); | |
| tabTest.classList.remove('text-gray-400', 'border-transparent'); | |
| tabGen.classList.remove('text-blue-400', 'border-blue-400'); | |
| tabGen.classList.add('text-gray-400', 'border-transparent'); | |
| // Ensure canvas size is correct and render is called | |
| resizeTesterCanvas(); | |
| } | |
| } | |
| // File Loading Logic | |
| btnLoadJson.addEventListener('click', () => testerFileInput.click()); | |
| // CHANGE 6: Updated Listener to handle Array format for Tester | |
| testerFileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (evt) => { | |
| try { | |
| testerData = JSON.parse(evt.target.result); | |
| // Compatibility fix: Convert Array format back to Map for renderer | |
| if (Array.isArray(testerData.chars)) { | |
| const charMap = {}; | |
| testerData.chars.forEach(c => { | |
| // Support 'charCode' (new) or 'id' (old) | |
| const key = c.charCode !== undefined ? c.charCode : c.id; | |
| charMap[String(key)] = c; | |
| }); | |
| testerData.chars = charMap; | |
| } | |
| testerSourceLabel.innerText = `Source: File`; | |
| testerStatusText.innerText = `Loaded: ${file.name}`; | |
| testerStatusText.classList.remove('text-red-400'); | |
| testerStatusText.classList.add('text-green-400'); | |
| runTestRender(); | |
| } catch (err) { | |
| console.error(err); | |
| testerSourceLabel.innerText = `Source: Error`; | |
| testerStatusText.innerText = "Error: Invalid JSON file."; | |
| testerStatusText.classList.add('text-red-400'); | |
| testerStatusText.classList.remove('text-green-400'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| // Removed Reset Logic | |
| function runTestRender() { | |
| const text = testerText.value; | |
| const fontSize = parseFloat(testerScale.value); | |
| const boldMultiplier = parseFloat(boldCoef.value); | |
| const skewAmount = parseFloat(skewCoef.value); | |
| const ctx = testerCanvas.getContext('2d'); | |
| // 1. Reset Transform to Clear Screen | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.clearRect(0, 0, testerCanvas.width, testerCanvas.height); | |
| if (!testerData) { | |
| // ONLY Clear the canvas | |
| return; | |
| } | |
| // 2. Apply Pan/Zoom View Transform | |
| ctx.setTransform(testerViewTransform.k, 0, 0, testerViewTransform.k, testerViewTransform.x, testerViewTransform.y); | |
| try { | |
| const fontData = testerData; | |
| const charsData = fontData.chars; | |
| const metrics = fontData.metrics || { lineHeight: 100, baseline: 80 }; | |
| // Setup Drawing State | |
| const startX = 50; | |
| let cursorX = startX; | |
| let cursorY = 100; | |
| // Use white for text fill | |
| ctx.fillStyle = "white"; | |
| for (const char of text) { | |
| if (char === '\n') { | |
| cursorX = startX; | |
| cursorY += (metrics.lineHeight * fontSize); | |
| continue; | |
| } | |
| const code = char.charCodeAt(0); | |
| const data = charsData[String(code)]; | |
| if (!data) { | |
| ctx.strokeStyle = "rgba(255, 255, 255, 0.5)"; | |
| ctx.lineWidth = 1 / testerViewTransform.k; | |
| // Draw a box for missing character | |
| ctx.strokeRect(cursorX, cursorY - (metrics.lineHeight * fontSize), 30 * fontSize, (metrics.lineHeight * fontSize)); | |
| cursorX += 35 * fontSize; | |
| continue; | |
| } | |
| // Save context before character-specific transform | |
| ctx.save(); | |
| // Translate to start of character (cursor position) | |
| ctx.translate(cursorX, cursorY); | |
| // Apply skew transformation | |
| // The drawing is currently relative to the cursorX/cursorY translation point. | |
| // We skew relative to the baseline (Y=0 in character space) | |
| ctx.transform(1, 0, skewAmount, 1, 0, 0); | |
| const lines = data.lines; | |
| if (lines && lines.length > 0) { | |
| for(let i=0; i<lines.length; i+=6) { | |
| // Coordinates are relative to the character's origin (0, 0 in its transform space) | |
| const x1 = (lines[i] * fontSize); | |
| const y1 = (lines[i+1] * fontSize); | |
| const r1 = lines[i+2] * fontSize * boldMultiplier; | |
| const x2 = (lines[i+3] * fontSize); | |
| const y2 = (lines[i+4] * fontSize); | |
| const r2 = lines[i+5] * fontSize * boldMultiplier; | |
| // We draw the capsule in the character's transform space | |
| drawUnevenCapsule(ctx, x1, y1, r1, x2, y2, r2); | |
| } | |
| } | |
| // Restore context to remove character-specific transform | |
| ctx.restore(); | |
| // Advance cursor based on character width | |
| cursorX += data.advance * fontSize; | |
| } | |
| } catch (e) { | |
| console.error("Render Error:", e); | |
| } | |
| } | |
| function setupTesterInteractions() { | |
| const canvas = testerCanvas; | |
| const handleWheel = (e) => { | |
| e.preventDefault(); | |
| const scaleBy = 1.1; | |
| const rect = canvas.getBoundingClientRect(); | |
| // Calculate mouse position relative to canvas | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const mouseX = (e.clientX - rect.left) * scaleX; | |
| const mouseY = (e.clientY - rect.top) * scaleY; | |
| // Transform mouse pointer to world coordinates | |
| const wx = (mouseX - testerViewTransform.x) / testerViewTransform.k; | |
| const wy = (mouseY - testerViewTransform.y) / testerViewTransform.k; | |
| // Apply zoom | |
| if (e.deltaY < 0) { | |
| testerViewTransform.k *= scaleBy; | |
| } else { | |
| testerViewTransform.k /= scaleBy; | |
| } | |
| // Adjust translation to zoom towards mouse | |
| testerViewTransform.x = mouseX - wx * testerViewTransform.k; | |
| testerViewTransform.y = mouseY - wy * testerViewTransform.k; | |
| runTestRender(); | |
| }; | |
| const handleMouseDown = (e) => { | |
| e.preventDefault(); | |
| isTesterDragging = true; | |
| lastTesterMouse = { x: e.clientX, y: e.clientY }; | |
| canvas.classList.remove('cursor-grab'); | |
| canvas.classList.add('cursor-grabbing'); | |
| }; | |
| const handleMouseMove = (e) => { | |
| if (!isTesterDragging) return; | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const dx = (e.clientX - lastTesterMouse.x) * scaleX; | |
| const dy = (e.clientY - lastTesterMouse.y) * scaleY; | |
| lastTesterMouse = { x: e.clientX, y: e.clientY }; | |
| testerViewTransform.x += dx; | |
| testerViewTransform.y += dy; | |
| runTestRender(); | |
| }; | |
| const handleMouseUp = () => { | |
| isTesterDragging = false; | |
| canvas.classList.add('cursor-grab'); | |
| canvas.classList.remove('cursor-grabbing'); | |
| }; | |
| canvas.addEventListener('wheel', handleWheel); | |
| canvas.addEventListener('mousedown', handleMouseDown); | |
| window.addEventListener('mousemove', (e) => { if (isTesterDragging) handleMouseMove(e); }); | |
| window.addEventListener('mouseup', handleMouseUp); | |
| // Initialize cursor class | |
| canvas.classList.add('cursor-grab'); | |
| } | |
| // Listeners for Tester | |
| tabGen.addEventListener('click', () => switchTab('gen')); | |
| tabTest.addEventListener('click', () => switchTab('test')); | |
| testerScale.addEventListener('input', (e) => { | |
| scaleVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| // NEW: Bold Coef Listener | |
| boldCoef.addEventListener('input', (e) => { | |
| boldCoefVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| // NEW: Skew Coef Listener | |
| skewCoef.addEventListener('input', (e) => { | |
| skewCoefVal.innerText = parseFloat(e.target.value).toFixed(2); | |
| runTestRender(); | |
| }); | |
| testerText.addEventListener('input', runTestRender); | |
| // Main Init | |
| inputs.char.addEventListener('input', process); | |
| inputs.bold.addEventListener('change', process); | |
| inputs.italic.addEventListener('change', process); | |
| inputs.threshold.addEventListener('input', process); | |
| inputs.threshold.addEventListener('change', process); | |
| inputs.prune.addEventListener('input', process); | |
| inputs.prune.addEventListener('change', process); | |
| inputs.rdp.addEventListener('input', process); | |
| inputs.rdp.addEventListener('change', process); | |
| window.onload = () => { | |
| setupInteractions(); | |
| setupTesterInteractions(); // Initialize Tester Canvas Interactions | |
| populateStandardASCII(); | |
| if (typeof d3 === 'undefined') setTimeout(process, 500); else process(); | |
| }; | |
| // Add resize listener for responsive canvas drawing size | |
| window.addEventListener('resize', () => { | |
| // Only run resize logic for the tester canvas if the tester tab is currently visible | |
| if (!viewTester.classList.contains('hidden')) { | |
| resizeTesterCanvas(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment