Browse Source

chore(skills): Sync svg-brand-tint-ops assets from svg-studio

Sync only — no local edits. trace-core.mjs and index.html are vendored
verbatim from X:/Forge/svg-studio/assets (source commit cfc6ffe);
sha256 hash-compared identical after copy.

The bundled copies were stale (pre-v3 engine, ~1,925 lines); this sync
carries the v3 Potrace geometry / v4 field-palette fixes / v5 gradient
engine catch-up plus the new agentic-quality doctrine headers (ARCHITECTURE
guard comments, Sections maps, '=== NAME ===' markers) added in svg-studio.

tests/run.sh: 22 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 3 weeks ago
parent
commit
f7a0753105
2 changed files with 1399 additions and 23 deletions
  1. 106 10
      skills/svg-brand-tint-ops/assets/index.html
  2. 1293 13
      skills/svg-brand-tint-ops/assets/trace-core.mjs

+ 106 - 10
skills/svg-brand-tint-ops/assets/index.html

@@ -318,13 +318,45 @@ option{background:var(--panel); color:var(--ink)}
 /* ═══════════════════════════════════════════════════════════════════════
    SVG STUDIO — zero-dependency designer tool.
    Load an SVG (or trace a PNG into one), then recolour / restyle / export.
+
+   ARCHITECTURE: classic script (not ES modules), ONE file — "open
+   index.html" straight from disk must keep working, and ES module loading
+   fails on file:// (CORS). Do not split this file, convert the script to
+   type="module", or add a bundler. The canonical engine (trace-core.mjs)
+   is a progressive enhancement via dynamic import() under http(s); the
+   inline trace mirror keeps file:// fully functional (see TRACE).
+
+   Sections (grep "=== NAME ==="): HELPERS · DATA · STATE · RAIL · APPLY ·
+   VIEW · INSPECT · SOURCE · TRACE · EXPORT · UTIL · BOOT
    ═══════════════════════════════════════════════════════════════════════ */
+// === HELPERS ===
 const $ = s => document.querySelector(s);
 const el = (t,a={},...k)=>{const n=document.createElement(t);for(const x in a){if(x==='class')n.className=a[x];else if(x==='html')n.innerHTML=a[x];else n.setAttribute(x,a[x]);}k.flat().forEach(c=>n.append(c));return n;};
 const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
 const hex2rgb=h=>{h=h.replace('#','');if(h.length===3)h=h.split('').map(c=>c+c).join('');const n=parseInt(h,16);return[(n>>16)&255,(n>>8)&255,n&255];};
 const rgb2hex=(r,g,b)=>'#'+[r,g,b].map(v=>clamp(Math.round(v),0,255).toString(16).padStart(2,'0')).join('');
 
+/* ── working-resolution recommendation (mirrors trace-core.mjs pickSuperF /
+   traceResInfo; index.html is a self-contained mirror of the engine) ──────────
+   The trace quality is governed by the WORKING resolution (source long edge ×
+   resample), tuned around a [640,1400]px band: supersample a low-res source UP
+   to the floor, trace an in-band source at native, downsample a >1400px source
+   DOWN to the ceiling. S.traceRes is that working-long-edge target. */
+const WORK_MIN=640, WORK_MAX=1400, TRACE_SUPER_MIN=0.2, TRACE_SUPER_MAX=4;
+function recoWorkingRes(L){ return Math.round(clamp(L,WORK_MIN,WORK_MAX)); }
+function traceReco(w,h){
+  const L=Math.max(1,w,h), target=recoWorkingRes(L);
+  const superF=clamp(target/L,TRACE_SUPER_MIN,TRACE_SUPER_MAX), workLong=Math.round(L*superF);
+  const warn=L<500;
+  let regime,msg;
+  if(L<500)        { regime='low';   msg=`Low-res (${L}px) — traced at ~${workLong}px, but detail is source-limited. For best results, use 1000px+.`; }
+  else if(L<1000)  { regime='fair';  msg=`${L}px source. Good — 1000px+ gives the crispest edges.`; }
+  else if(L<=2000) { regime='ideal'; msg=`${L}px source — ideal resolution for tracing.`; }
+  else             { regime='high';  msg=`High-res (${L}px) — traced at a capped ~${workLong}px working res for speed and clean edges.`; }
+  return {target,superF,workLong,regime,warn,msg};
+}
+
+// === DATA ===
 /* ── curated Google Fonts for SVG text ──────────────────────────────── */
 const FONTS = {
   'Sans':    [['Manrope','400;600;700'],['Work Sans','400;600'],['Sora','400;700'],['Outfit','400;600'],['Figtree','400;600'],['Hanken Grotesk','400;600'],['Schibsted Grotesk','400;700'],['Onest','400;700']],
@@ -346,6 +378,7 @@ const PRESETS = {
   'Fallout CRT':    {stops:['#001a08','#28e24a','#c9ffcf'], sat:1.9,  bgMode:'crt', glow:'#39ff7a', glowBlur:18},
 };
 
+// === STATE ===
 /* ── state ──────────────────────────────────────────────────────────── */
 const DEFAULT = {
   // tone map
@@ -382,6 +415,7 @@ function save(){ try{LS.set('svgstudio', JSON.stringify(S));}catch{} }
 let SRC = { type:null, svg:null, raster:null, natW:0, natH:0 };  // svg = <svg> element (live), raster = Image
 let baseSize = new Map();  // text node -> base font-size px (for size scale)
 
+// === RAIL ===
 /* ═══ SECTIONS (accordion) ══════════════════════════════════════════ */
 let openSet; try{ openSet=new Set(JSON.parse(LS.get('svgstudio-open')||'["source","trace","tone"]')); }catch{ openSet=new Set(['source','trace','tone']); }
 
@@ -478,11 +512,13 @@ function buildTrace(p){
   ctlRange(p,num('smooth','Smoothing',0,1,0.05,v=>Math.round(v*100)+'%'));
   ctlRange(p,num('despeckle','Despeckle  (min area)',0,120,2,v=>v+'px²'));
   ctlRange(p,num('preblur','Pre-blur  (denoise)',0,4,0.5,v=>v.toFixed(1)));
-  ctlRange(p,num('traceRes','Resolution',300,1400,50,v=>v+'px'));
+  const rsInp=ctlRange(p,num('traceRes','Working res  (target)',300,1400,50,v=>v+'px'));
+  rsInp.addEventListener('input',updateTraceReco);
+  const reco=el('div',{class:'hint',id:'traceReco'}); reco.style.display='none'; reco.style.marginTop='2px'; p.append(reco);
   ctlTog(p,tog('traceInvert','Invert luminance'));
   const btn=el('button',{class:'btn primary wide',id:'traceGo'},'✦  Trace to SVG'); btn.style.marginTop='10px';
   btn.onclick=runTrace; p.append(btn);
-  p.append(el('div',{class:'hint'},'Marching-squares contours · Douglas–Peucker simplify · optional bézier smoothing. All in-browser, no upload.'));
+  p.append(el('div',{class:'hint',id:'traceHint'},traceHintText()));
   // Optional benchmark gallery — appears only when a local ./gallery/ has been
   // populated (before/after vectorise experiments). Shipped hidden; a HEAD probe
   // reveals it where present. Silently stays hidden in sandboxed previews (fetch
@@ -652,6 +688,7 @@ function refreshMeta(){
   set('canvas', S.bg);
 }
 
+// === APPLY ===
 /* ═══ APPLY — the render pipeline ════════════════════════════════════ */
 let raf=0;
 function apply(){ cancelAnimationFrame(raf); raf=requestAnimationFrame(applyNow); }
@@ -749,6 +786,7 @@ function applySizeScale(svg){
   });
 }
 
+// === VIEW ===
 /* ═══ VIEW — zoom / pan / split ══════════════════════════════════════ */
 function updateView(){
   const t=`translate(${S.panx}px,${S.pany}px) scale(${S.zoom})`;
@@ -834,6 +872,7 @@ function setupViewport(){
   });
 }
 
+// === INSPECT ===
 /* ═══ INSPECT ════════════════════════════════════════════════════════ */
 let inspectOn=false;
 function toggleInspect(){ inspectOn=!inspectOn; $('#inspectBtn').classList.toggle('on',inspectOn); $('#viewport').classList.toggle('inspect',inspectOn); if(!inspectOn){$('#hoverbox').style.display='none';$('#tip').style.display='none';} }
@@ -874,6 +913,7 @@ function buildElementList(){
 }
 function flashEl(n){ const o=n.style.outline; n.style.transition='opacity .1s'; let i=0; const iv=setInterval(()=>{ n.style.opacity=(i%2?1:0.2); if(++i>5){clearInterval(iv);n.style.opacity=1;} },110); highlightEl(n); }
 
+// === SOURCE ===
 /* ═══ SOURCE LOADING ═════════════════════════════════════════════════ */
 function loadFile(f){
   const isSvg=/svg/i.test(f.type)||/\.svg$/i.test(f.name);
@@ -940,18 +980,33 @@ function setRaster(dataURL,opts){
   img.onload=()=>{
     if(!opts||opts.push!==false) pushHistory();   // capture the outgoing source before replacing
     SRC={type:'raster', svg:null, raster:img, natW:img.naturalWidth, natH:img.naturalHeight, rasterURL:dataURL};
+    // auto-target the recommended working resolution for this source size
+    S.traceRes=traceReco(img.naturalWidth,img.naturalHeight).target;
+    const rs=$('#sl-traceRes'); if(rs){ rs.value=S.traceRes; const v=rs.parentElement&&rs.parentElement.querySelector('.v'); if(v)v.textContent=S.traceRes+'px'; }
     // show the raster as an <img> wrapped in svg-less preview until traced
     const art=$('#art'); art.innerHTML=''; const wrap=el('div',{}); const im=el('img',{src:dataURL}); im.style.maxWidth='min(74vw,'+(img.naturalWidth)+'px)'; im.style.display='block'; wrap.append(im); art.append(wrap);
     $('#artBase').innerHTML='';
     $('#empty').style.display='none';
     srcChip(); refreshMeta(); openSection('trace'); fitView();
     $('#art').querySelector('img').style.filter='none';
-    toast('Raster loaded — open Image Trace to vectorise'); traceNote();
+    toast('Raster loaded — open Image Trace to vectorise'); traceNote(); updateTraceReco();
     updateReadout();
   };
   img.src=dataURL;
 }
-function traceNote(){ const n=$('#traceNote'); if(n){ n.textContent = SRC.type==='raster' ? 'Ready. Tune the mode + threshold, then Trace to SVG.' : 'Load a PNG/JPG to enable.'; } const go=$('#traceGo'); if(go)go.disabled=SRC.type!=='raster'; }
+function traceNote(){ const n=$('#traceNote'); if(n){ n.textContent = SRC.type==='raster' ? 'Ready. Tune the mode + threshold, then Trace to SVG.' : 'Load a PNG/JPG to enable.'; } const go=$('#traceGo'); if(go)go.disabled=SRC.type!=='raster'; updateTraceReco(); }
+/* Resolution hint + low-res warning, driven by the loaded raster's real size and
+   the current working-res target. Green = ideal, red = low-res warning, muted otherwise. */
+function updateTraceReco(){
+  const box=$('#traceReco'); if(!box) return;
+  if(SRC.type!=='raster'||!SRC.natW){ box.style.display='none'; return; }
+  const L=Math.max(SRC.natW,SRC.natH), reco=traceReco(SRC.natW,SRC.natH);
+  const cur=S.traceRes||reco.target, workNow=Math.round(clamp(cur/L,TRACE_SUPER_MIN,TRACE_SUPER_MAX)*L);
+  const col=reco.warn?'var(--bad)':reco.regime==='ideal'?'var(--good)':'var(--muted)';
+  const icon=reco.warn?'⚠':reco.regime==='ideal'?'✓':'◈';
+  box.style.display='block';
+  box.innerHTML=`<b style="color:${col}">${icon}</b> ${reco.msg} <span style="color:var(--faint)">· working ≈ ${workNow}px (rec ${reco.target}px)</span>`;
+}
 function srcChip(){ const c=$('#srcchip'); c.innerHTML = SRC.type? `<b>${SRC.type.toUpperCase()}</b> ${SRC.natW}×${SRC.natH}` : 'no source'; }
 function openSection(id){ openSet.add(id); const acc=[...document.querySelectorAll('.acc')].find(a=>a.dataset.id===id); if(acc)acc.classList.add('open'); LS.set('svgstudio-open',JSON.stringify([...openSet])); }
 
@@ -975,27 +1030,50 @@ function makeSampleRaster(){
 // looking right without the server.
 const FALLBACK_SVG=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400" font-family="sans-serif"><rect width="640" height="400" fill="#f4f4f4"/><g stroke="#9aa0a6" stroke-width="2" fill="none"><path d="M160 120 H240"/><path d="M400 120 H480"/><path d="M120 160 V240"/><path d="M520 160 V240"/><path d="M200 280 H440" stroke-dasharray="6 6"/></g><g stroke="#5f6368" stroke-width="1.5"><rect x="60" y="90" width="100" height="60" rx="10" fill="#d9dce0"/><rect x="240" y="90" width="160" height="60" rx="10" fill="#b7bcc2"/><rect x="480" y="90" width="100" height="60" rx="10" fill="#d9dce0"/><rect x="70" y="240" width="120" height="56" rx="10" fill="#8b9198"/><rect x="450" y="240" width="120" height="56" rx="10" fill="#8b9198"/></g><circle cx="320" cy="270" r="34" fill="#6b7178" stroke="#3c4043" stroke-width="2"/><path d="M320 250 l16 28 h-32 z" fill="#f4f4f4"/><g fill="#2b2f33" font-size="14" text-anchor="middle"><text x="110" y="125">Client</text><text x="320" y="125">Gateway</text><text x="530" y="125">CDN</text><text x="130" y="273">Service A</text><text x="510" y="273">Service B</text><text x="320" y="345" font-size="12" fill="#5f6368">shared queue</text></g></svg>`;
 
+// === TRACE ===
 /* ═══ IMAGE TRACE (raster → vector) ══════════════════════════════════ */
+/* Canonical engine (trace-core.mjs) — loaded as an ES module at boot. When the
+   import resolves (any http(s) serve), interactive traces run the shared v5
+   engine: soft-field palette + matte machinery, Potrace-style global geometry,
+   native <linearGradient>/<radialGradient> fills. Under file:// a sibling-module
+   import is blocked (CORS) → ENGINE stays null and the inline mirror below keeps
+   the tool fully self-contained. */
+let ENGINE=null;
+import('./trace-core.mjs').then(m=>{ ENGINE=m; const hn=$('#traceHint'); if(hn)hn.textContent=traceHintText(); }).catch(()=>{});
+function traceHintText(){
+  return ENGINE
+    ? 'v5 engine · soft-field palette · Potrace-style geometry · native gradient fills. All in-browser, no upload.'
+    : 'Marching-squares contours · Douglas–Peucker simplify · optional bézier smoothing. All in-browser, no upload.';
+}
 function runTrace(){
   if(SRC.type!=='raster'){ toast('Load a raster first'); return; }
   busy(true,'tracing…');
-  setTimeout(()=>{ try{ const svg=traceRaster(SRC.raster); setSVG(svg); toast('Traced → '+(svg.match(/<path/g)||[]).length+' paths'); }catch(err){ console.error(err); toast('Trace failed'); } finally{ busy(false); } }, 30);
+  setTimeout(()=>{ try{ const svg=traceRaster(SRC.raster); setSVG(svg); toast('Traced → '+(svg.match(/<path/g)||[]).length+' paths · '+(ENGINE?'v5 engine':'inline')); }catch(err){ console.error(err); toast('Trace failed'); } finally{ busy(false); } }, 30);
 }
 function busy(on,msg){ const b=$('#busy'); b.classList.toggle('on',on); if(msg)$('#busymsg').textContent=msg; }
 
-/* Raster → vector. Mirrors trace-core.mjs (the canonical/headless engine) inline
-   so the tool stays a single self-contained file: SUPERSAMPLE for sub-pixel edges
+/* Raster → vector. When ENGINE resolved, defers to trace-core.mjs (the canonical
+   v5 engine). The inline mirror below is the file:// fallback so the tool stays a
+   single self-contained file: SUPERSAMPLE for sub-pixel edges
    → alpha-aware nearest-palette layers (k-means-refined) → interpolated iso-contours
    → corner-preserving + faired curve fit. See references/tri-tone-and-trace.md. */
 function traceRaster(img){
   let w=img.naturalWidth||img.width, h=img.naturalHeight||img.height;
-  const sc=Math.min(2, Math.max(1, (S.traceRes||900)/Math.max(w,h)));   // supersample (2x = quality sweet spot)
+  // S.traceRes is the TARGET WORKING long edge — resample toward it either way:
+  // supersample a low-res source up, downsample a hi-res source down (sc<1). The
+  // old min-1 clamp traced a 2000px source at full native → path explosion + noise.
+  const sc=clamp((S.traceRes||900)/Math.max(w,h), TRACE_SUPER_MIN, TRACE_SUPER_MAX);
   w=Math.max(2,Math.round(w*sc)); h=Math.max(2,Math.round(h*sc));
   const cv=el('canvas'); cv.width=w; cv.height=h; const ctx=cv.getContext('2d',{willReadFrequently:true});
   ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality='high';
   ctx.drawImage(img,0,0,w,h);
   let idata=ctx.getImageData(0,0,w,h);
-  if(S.preblur>0) idata=boxBlur(idata,w,h,Math.round(S.preblur));
+  if(ENGINE) return ENGINE.traceImage(idata, engineOpts(sc));   // canonical path (preblur handled in-engine)
+  // band-limit before quantization when downsampling (sc<1): the interpolated
+  // downscale leaves thin edge fringes a noisy background would seed as spurious
+  // near-duplicate layers. Radius-1 min dissolves them; no-op when up/native-sampling.
+  const pre=sc<1 ? Math.max(S.preblur||0, 1) : (S.preblur||0);
+  if(pre>0) idata=boxBlur(idata,w,h,Math.round(pre));
   const px=idata.data, N=w*h;
   const lum=new Float32Array(N), opaque=new Uint8Array(N);
   for(let i=0,j=0;i<N;i++,j+=4){ lum[i]=0.299*px[j]+0.587*px[j+1]+0.114*px[j+2]; opaque[i]=px[j+3]>=128?1:0; }
@@ -1017,6 +1095,19 @@ function traceRaster(img){
   }
   return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`;
 }
+/* Options for ENGINE.traceImage — delegates to the engine's own buildTraceOpts
+   (single source of truth; see assets/trace-core.mjs) with the studio's resample
+   factor `sc` as superF. Raw S.despeckle/S.detail are passed through UNSCALED —
+   buildTraceOpts scales them internally (×sc², ×sc). Gradient fitting (gradFit…)
+   stays on engine DEFAULTS: ON. */
+function engineOpts(sc){
+  return ENGINE.buildTraceOpts(sc, {
+    mode:S.traceMode, colors:S.colors, smooth:S.smooth, mergeDist:48,
+    despeckle:S.despeckle, detail:S.detail,
+    threshold:S.threshold, levels:S.levels, invert:S.traceInvert,
+    preblur:S.preblur,
+  });
+}
 function traceLayers(px,lum,opaque,w,h){
   const N=w*h, layers=[];
   if(S.traceMode==='bw'){
@@ -1222,12 +1313,15 @@ function paletteFromArt(){
 function seedFromImage(img){
   const w=120,h=Math.max(1,Math.round(120*(img.naturalHeight||img.height)/(img.naturalWidth||img.width)));
   const c=el('canvas'); c.width=w;c.height=h; const x=c.getContext('2d',{willReadFrequently:true}); x.drawImage(img,0,0,w,h);
-  const px=x.getImageData(0,0,w,h).data; const pal=medianCut(px,w,h,Math.max(3,S.stops.length));
+  const px=x.getImageData(0,0,w,h).data;
+  const opq=new Uint8Array(w*h); for(let i=0;i<w*h;i++) opq[i]=px[i*4+3]>=128?1:0;
+  const pal=medianCut(px,opq,w,h,Math.max(3,S.stops.length));
   pal.sort((a,b)=>(a[0]+a[1]+a[2])-(b[0]+b[1]+b[2])); // dark→light
   S.stops=pal.slice(0,Math.max(3,S.stops.length)).map((c,i,arr)=>({c:rgb2hex(...c),p:i/(arr.length-1)}));
   renderRail(); apply(); scheduleSave(); toast('Palette extracted');
 }
 
+// === EXPORT ===
 /* ═══ EXPORT ═════════════════════════════════════════════════════════ */
 function exportSVGString(bake){
   const svg=SRC.svg; if(!svg)return '';
@@ -1308,6 +1402,7 @@ function useBrandTint(ref){
 }`;
 }
 
+// === UTIL ===
 /* ═══ helpers: clipboard / download / toast ═════════════════════════ */
 function copy(t,msg){ navigator.clipboard?.writeText(t).then(()=>toast(msg||'Copied')).catch(()=>toast('Copy failed')); }
 function download(text,name,type){ downloadBlob(new Blob([text],{type}),name); }
@@ -1324,6 +1419,7 @@ function syncCanvasBg(){ const bg=$('#canvasbg'); bg.className='canvasbg'; bg.st
 }
 let saveT=0; function scheduleSave(){ clearTimeout(saveT); saveT=setTimeout(save,400); }
 
+// === BOOT ===
 /* ═══ boot ═══════════════════════════════════════════════════════════ */
 function resetAll(){ if(!confirm('Reset all controls to defaults?'))return; S=structuredClone(DEFAULT); baseSize=new Map(); renderRail(); syncCanvasBg(); if(SRC.svg)applySvgSizing(); apply(); save(); toast('Reset'); }
 $('#resetBtn').onclick=resetAll;

File diff suppressed because it is too large
+ 1293 - 13
skills/svg-brand-tint-ops/assets/trace-core.mjs


Some files were not shown because too many files changed in this diff