const exportShaderBtn = document.getElementById('exportShader'); const exportJSONBtn = document.getElementById('exportJSON'); const exportDialog = document.getElementById('exportDialog'); const exportText = document.getElementById('exportText'); exportText.addEventListener('wheel', (e) => { const atTop = exportText.scrollTop === 0; const atBottom = exportText.scrollTop + exportText.clientHeight >= exportText.scrollHeight; if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { e.preventDefault(); } }, { passive: false }); const gradientCanvas = document.getElementById('gradientCanvas'); const gctx = gradientCanvas.getContext('2d', { willReadFrequently: true }); const pickerCanvas = document.getElementById('picker'); const pctx = pickerCanvas.getContext('2d', { willReadFrequently: true }); const addBtn = document.getElementById('addPoint'); const removeBtn = document.getElementById('removePoint'); const tbody = document.querySelector('#pointsTable tbody'); // Helpers const clamp = (v,min,max)=>Math.min(max,Math.max(min,v)); const lerp = (a,b,t)=>a+(b-a)*t; const toHex2 = (n)=>n.toString(16).padStart(2,'0'); function rgbToHex(r,g,b){ return '#' + toHex2(r) + toHex2(g) + toHex2(b); } function hexToRgb(hex){ if(!hex) return null; hex = hex.trim(); const m3 = /^#?([0-9a-fA-F]{3})$/; const m6 = /^#?([0-9a-fA-F]{6})$/; let m; if(m = hex.match(m3)){ const s=m[1]; const r=parseInt(s[0]+s[0],16), g=parseInt(s[1]+s[1],16), b=parseInt(s[2]+s[2],16); return {r,g,b}; } if(m = hex.match(m6)){ const s=m[1]; const r=parseInt(s.slice(0,2),16), g=parseInt(s.slice(2,4),16), b=parseInt(s.slice(4,6),16); return {r,g,b}; } return null; } const pointsTable = document.getElementById('pointsTable'); pointsTable.addEventListener('wheel', (e) => { const { target } = e; if (target.type === 'number') { e.preventDefault(); const dir = Math.sign(e.deltaY); if (dir === 0) return; const step = e.shiftKey ? 0.10 : 0.01; const weight = parseFloat(target.value); target.value = Math.max(0.01, +(weight - dir * step).toFixed(4)); target.dispatchEvent(new Event('input', { bubbles: true })); } else { return; } }, { passive: false }); function drawPicker(){ const w=pickerCanvas.width, h=pickerCanvas.height; const hue = pctx.createLinearGradient(0,0,w,0); hue.addColorStop(0/6,'#f00'); hue.addColorStop(1/6,'#ff0'); hue.addColorStop(2/6,'#0f0'); hue.addColorStop(3/6,'#0ff'); hue.addColorStop(4/6,'#00f'); hue.addColorStop(5/6,'#f0f'); hue.addColorStop(1,'#f00'); pctx.fillStyle=hue; pctx.fillRect(0,0,w,h); const whiteGrad=pctx.createLinearGradient(0,0,0,h); whiteGrad.addColorStop(0,'rgba(255,255,255,1)'); whiteGrad.addColorStop(0.5,'rgba(255,255,255,0)'); pctx.fillStyle=whiteGrad; pctx.fillRect(0,0,w,h); const blackGrad=pctx.createLinearGradient(0,0,0,h); blackGrad.addColorStop(0.5,'rgba(0,0,0,0)'); blackGrad.addColorStop(1,'rgba(0,0,0,1)'); pctx.fillStyle=blackGrad; pctx.fillRect(0,0,w,h); } // Model: list of control points, each { hex, weight } let points = [ { hex:'#008', weight:0.75 }, // was #00F, now #008 { hex:'#8000ff', weight:0.75 }, { hex:'#ff0080', weight:1.00 }, { hex:'#ff8000', weight:0.75 }, { hex:'#ffffff', weight:1.00 }, ]; // Selection state let selected = 0; function segmentWeights(pts){ const n=pts.length; const seg=[]; for(let i=0;ia+b,0) || 1; const len = seg.map(s=>s/sum); const t=[0]; for(let i=0;ihexToRgb(p.hex) || {r:0,g:0,b:0}); let si = 0; // segment index for(let x=0;x t[si+1]) si++; const a = t[si], b = t[si+1]; const v = b>a ? (u - a)/(b - a) : 0; const r = Math.round(lerp(rgb[si].r, rgb[si+1].r, v)); const g = Math.round(lerp(rgb[si].g, rgb[si+1].g, v)); const bch = Math.round(lerp(rgb[si].b, rgb[si+1].b, v)); const k = x*4; data[k+0]=r; data[k+1]=g; data[k+2]=bch; data[k+3]=255; } // scale to full height gctx.putImageData(img,0,0); gctx.imageSmoothingEnabled=false; gctx.drawImage(gradientCanvas,0,0,w,h); } function setSelected(i){ if (selected === i) return; const rows = [...tbody.querySelectorAll('tr')]; if (rows[selected]) rows[selected].classList.remove('sel'); selected = i; if (rows[selected]) rows[selected].classList.add('sel'); } function refreshPositions(){ const t = positionsFromWeights(points); const rows = tbody.querySelectorAll('tr'); for (let j = 0; j < rows.length; j++) { const posCell = rows[j].lastElementChild; // position column if (posCell) posCell.textContent = t[j].toFixed(6); } drawGradient(); } // Update swatches (and non-focused hex inputs) to match current point colors, // then redraw the gradient. Does not rebuild the table or steal focus. function refreshColors(){ const rows = tbody.querySelectorAll('tr'); for (let j = 0; j < points.length; j++) { const row = rows[j]; if (!row) continue; const swatch = row.querySelector('.swatch'); if (swatch) swatch.style.background = points[j].hex; const hexInput = row.querySelector('input[type="text"]'); if (hexInput && document.activeElement !== hexInput) { hexInput.value = points[j].hex; // keep focused input untouched } } drawGradient(); } function updateTable(){ tbody.innerHTML=''; const t = positionsFromWeights(points); points.forEach((p,i)=>{ const tr=document.createElement('tr'); if(i===selected) tr.classList.add('sel'); const tdIdx=document.createElement('td'); tdIdx.textContent=i+1; tr.appendChild(tdIdx); const tdSw=document.createElement('td'); const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=p.hex; tdSw.appendChild(sw); tr.appendChild(tdSw); const tdHex=document.createElement('td'); const inpHex=document.createElement('input'); inpHex.type='text'; inpHex.value=p.hex; inpHex.placeholder='#rrggbb'; /* Possible refactor: inpHex.addEventListener('change', () => { const rgb = hexToRgb(inpHex.value); if (rgb) { p.hex = normalizeHex(inpHex.value); drawGradient(); // remove updateTable() here } else { inpHex.value = p.hex; } }); */ tdHex.appendChild(inpHex); tr.appendChild(tdHex); const tdW=document.createElement('td'); const inpW=document.createElement('input'); inpW.type='number'; inpW.min='0.01'; inpW.step='0.01'; inpW.value=p.weight.toString(); tdW.appendChild(inpW); tr.appendChild(tdW); const tdPos=document.createElement('td'); tdPos.textContent = t[i].toFixed(6); tr.appendChild(tdPos); tr.addEventListener('click', (e) => { setSelected(i); }); inpHex.addEventListener('focus', () => setSelected(i)); inpHex.addEventListener('change',()=>{ const rgb=hexToRgb(inpHex.value); if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); } else { inpHex.value=p.hex; } }); inpHex.addEventListener('input',()=>{ const rgb=hexToRgb(inpHex.value); if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); } }); inpW.addEventListener('focus', () => setSelected(i)); inpW.addEventListener('input',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); }); inpW.addEventListener('change',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); }); tbody.appendChild(tr); }); removeBtn.disabled = points.length<=2 || selected==null; } function normalizeHex(h){ h=h.trim(); if(h[0]!=='#') h='#'+h; // allow missing '#' const rgb=hexToRgb(h); if(!rgb) return '#000000'; return rgbToHex(rgb.r,rgb.g,rgb.b); } function updateSelection(){ updateTable(); drawGradient(); } function addPointAfter(idx){ const t = positionsFromWeights(points); const left = Math.max(0, Math.min(idx, points.length-1)); const right = Math.min(left+1, points.length-1); const u = (t[left] + t[right]) / 2; const col = sampleGradient(u); points.splice(left+1,0,{ hex: col, weight: 1.0 }); selected = left+1; updateSelection(); } function sampleGradient(u){ const t = positionsFromWeights(points); const rgb = points.map(p => hexToRgb(p.hex)); let i = 0; while (i < t.length - 2 && u > t[i+1]) i++; const a = t[i], b = t[i+1]; const v = b > a ? (u - a) / (b - a) : 0; const rr = Math.round(lerp(rgb[i].r, rgb[i+1].r, v)); const gg = Math.round(lerp(rgb[i].g, rgb[i+1].g, v)); const bb = Math.round(lerp(rgb[i].b, rgb[i+1].b, v)); return rgbToHex(rr, gg, bb); } addBtn.addEventListener('click',()=>{ const idx = selected ?? (points.length-2); addPointAfter(idx); }); removeBtn.addEventListener('click',()=>{ if(points.length<=2 || selected==null) return; points.splice(selected,1); selected = Math.max(0, Math.min(selected, points.length-1)); updateSelection(); }); pickerCanvas.addEventListener('click', (e)=>{ if(selected==null) return; const rect = pickerCanvas.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) * (pickerCanvas.width/rect.width)); const y = Math.floor((e.clientY - rect.top) * (pickerCanvas.height/rect.height)); const px = pctx.getImageData(x,y,1,1).data; const hex = rgbToHex(px[0],px[1],px[2]); points[selected].hex = hex; updateSelection(); }); // Initial render drawPicker(); drawGradient(); updateTable(); function buildShaderFunction() { const t = positionsFromWeights(points); const rgb = points.map(p => hexToRgb(p.hex)); let out = []; out.push("float3 gradient(float t)"); out.push("{"); out.push("\tt = saturate(t);"); for (let i = 0; i < points.length - 1; i++) { const a = t[i].toFixed(6); const b = t[i+1].toFixed(6); const c0 = rgb[i]; const c1 = rgb[i+1]; // FIX: last segment must be unconditional out.push( i === 0 ? `\tif (t < ${b})` : (i === points.length - 2 ? `\telse` : `\telse if (t < ${b})`) ); out.push("\t\treturn lerp("); out.push(`\t\t\tfloat3(${(c0.r/255).toFixed(6)}, ${(c0.g/255).toFixed(6)}, ${(c0.b/255).toFixed(6)}),`); out.push(`\t\t\tfloat3(${(c1.r/255).toFixed(6)}, ${(c1.g/255).toFixed(6)}, ${(c1.b/255).toFixed(6)}),`); out.push(`\t\t\t(t - ${a}) / (${b} - ${a})`); out.push("\t\t);"); } out.push("}"); return out.join("\n"); } function showExportDialog({ title, text, filename, mime }) { const dlg = document.getElementById('exportDialog'); const ta = document.getElementById('exportText'); const h = document.getElementById('exportTitle'); const copy = document.getElementById('copyExport'); const dl = document.getElementById('downloadExport'); h.textContent = title; ta.value = text; copy.onclick = async () => { await navigator.clipboard.writeText(text); }; dl.onclick = () => { downloadText(filename, text, mime); }; dlg.showModal(); } function downloadText(filename, text, mime = "text/plain") { const blob = new Blob([text], { type: mime }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } function buildJSON() { return JSON.stringify( { points: points.map(p => ({ hex: p.hex, weight: p.weight })) }, null, 2 ); } exportShaderBtn.addEventListener('click', () => { showExportDialog({ title: "OBS shader: gradient()", text: buildShaderFunction(), filename: "gradient.hlsl", mime: "text/plain" }); }); exportJSONBtn.addEventListener('click', () => { showExportDialog({ title: "Gradient JSON", text: buildJSON(), filename: "gradient.json", mime: "application/json" }); });