diff --git a/docs/404.html b/docs/404.html index 8b7dcdd..a32a961 100644 --- a/docs/404.html +++ b/docs/404.html @@ -8,7 +8,7 @@ name="description" content="Turn a photo into single-thread string art — plotter/print-ready, made in your browser. No backend, nothing leaves your device." /> - + diff --git a/docs/assets/index-B21gUkMX.js b/docs/assets/index-B21gUkMX.js new file mode 100644 index 0000000..2efecf2 --- /dev/null +++ b/docs/assets/index-B21gUkMX.js @@ -0,0 +1,3 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))r(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const s of i.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function o(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(n){if(n.ep)return;n.ep=!0;const i=o(n);fetch(n.href,i)}})();function J(e,t,o,r){const n=[];if(e<=0)return n;for(let i=0;i=l&&(y+=l,n+=f),M<=d&&(y+=d,i+=u)}return v===x.length?x:x.subarray(0,v)}function H(e,t,o){const{width:r}=o,n=o.height;let i=0,s=0;for(let c=0;c=r||l>=n||(i+=e[l*r+d],s++)}return s===0?0:i/s}function X(e,t,o,r){const{width:n}=r,i=r.height;for(let s=0;s=n||d>=i)continue;const l=d*n+c,f=e[l]-o;e[l]=f<0?0:f}}function _(e,t,o,r){const n=t.length;if(n<2)return-1;const i=Math.max(1,r.minGap??1),s=r.used,c=r.count??n,d=t[e];let l=-1,f=0;for(let u=0;uf&&(f=L,l=u)}return l}function Q(e,t,o){return ed){n[u]=0;continue}const x=e[u]/255;n[u]=r?x:1-x}return n}function Z(e,t,o){const r=new Uint8ClampedArray(t*o);for(let n=0;n255?255:f}return t}function E(e){return Number.isInteger(e)?String(e):e.toFixed(2)}function tt(e,t,o){const{width:r,height:n,lineWidth:i,opacity:s}=o,c=o.stroke??"#000000",d=o.background??"#ffffff",l=[];for(const y of e){const m=t[y];m&&l.push(`${E(m.x)},${E(m.y)}`)}let f="";o.showNails&&(f=`${t.map(m=>``).join("")}`);const u=l.length>=2?``:"";return``+u+f+""}function et(e,t){return["# string-portrait nail sequence",`# nails: ${t}`,`# threads: ${Math.max(0,e.length-1)}`,"# Wind one continuous thread, visiting these nail indices in order:",...e.map(r=>String(r))].join(` +`)+` +`}function h(e){const t=document.getElementById(e);if(!t)throw new Error(`missing #${e}`);return t}const k=h("canvas"),g=k.getContext("2d"),a=500,p={nails:240,maxLines:2500,strength:.15,opacity:.5,invert:!1};let $=null,P=null,R=[],w=[],G=null,C=0,I=0,b=!1;function F(){k.width=a,k.height=a;const t=k.parentElement.getBoundingClientRect(),o=Math.max(280,Math.min(t.width,t.height)-24);k.style.width=`${o}px`,k.style.height=`${o}px`}function U(){g.setTransform(1,0,0,1,0,0),g.fillStyle="#ffffff",g.fillRect(0,0,a,a)}function D(e,t,o){const r=document.createElement("canvas");r.width=a,r.height=a;const n=r.getContext("2d");n.fillStyle="#ffffff",n.fillRect(0,0,a,a);const i=Math.max(a/t,a/o),s=t*i,c=o*i;n.drawImage(e,(a-s)/2,(a-c)/2,s,c);const d=n.getImageData(0,0,a,a).data;return Z(d,a,a)}function N(){$=z(a),T(),S("Sample loaded — hit ▶ Generate.")}async function nt(e){try{const t=await createImageBitmap(e);$=D(t,t.width,t.height),t.close(),T(),S("Photo loaded — hit ▶ Generate.")}catch{const t=URL.createObjectURL(e),o=new Image;o.onload=()=>{$=D(o,o.naturalWidth,o.naturalHeight),URL.revokeObjectURL(t),T(),S("Photo loaded — hit ▶ Generate.")},o.onerror=()=>{URL.revokeObjectURL(t),O("Could not read that image.")},o.src=t}}function T(){if(j(),U(),!$)return;const e=g.createImageData(a,a),t=a/2,o=a/2,r=a/2-1,n=r*r;for(let i=0;i{if(!b||!P||!G)return;let n=!1;for(let s=0;s=p.maxLines);s++){const c=_(C,R,P,{width:a,height:a,used:G,count:t});if(c<0){W(!0);return}const d=R[C],l=R[c],f=q(d.x,d.y,l.x,l.y);X(P,f,p.strength,{width:a,height:a}),G.add(Q(C,c,t)),g.beginPath(),g.moveTo(d.x,d.y),g.lineTo(l.x,l.y),g.stroke(),w.push(c),C=c,n=!0}const i=w.length-1;if(S(`Winding… ${i} / ${p.maxLines} threads`),i>=p.maxLines||!n){W(!1);return}I=requestAnimationFrame(r)};I=requestAnimationFrame(r)}function W(e){b=!1,cancelAnimationFrame(I),B();const t=w.length-1;S(e?`Done — ${t} threads (no darkness left to chase).`:`Done — ${t} threads through ${p.nails} nails.`)}function j(){b=!1,cancelAnimationFrame(I),B()}function B(){h("generate").disabled=b,h("stop").disabled=!b}function S(e){h("progress").textContent=e}function O(e){const t=h("toast");t.textContent=e,t.classList.add("show"),window.setTimeout(()=>t.classList.remove("show"),1800)}function A(e,t){const o=URL.createObjectURL(t),r=document.createElement("a");r.href=o,r.download=e,r.click(),URL.revokeObjectURL(o)}function K(e,t){return e==="strength"||e==="opacity"?t.toFixed(2):String(t)}const Y=["nails","maxLines","strength","opacity"];function it(){for(const e of Y)h(`${e}-out`).textContent=K(e,p[e])}function rt(){it(),h("file").addEventListener("change",t=>{const o=t.target.files?.[0];o&&nt(o)}),h("sample").addEventListener("click",N);for(const t of Y)h(t).addEventListener("input",o=>{const r=Number(o.target.value);p[t]=r,h(`${t}-out`).textContent=K(t,r)});h("invert").addEventListener("change",t=>{p.invert=t.target.checked,$&&!b&&T()}),h("generate").addEventListener("click",ot),h("stop").addEventListener("click",()=>{j(),S(`Stopped at ${w.length-1} threads.`)}),h("png").addEventListener("click",()=>{k.toBlob(t=>{t&&A("string-portrait.png",t)},"image/png")}),h("svg").addEventListener("click",()=>{if(w.length<2){O("Generate a portrait first.");return}const t=tt(w,R,{width:a,height:a,lineWidth:.6,opacity:p.opacity,stroke:"#14141c",background:"#ffffff"});A("string-portrait.svg",new Blob([t],{type:"image/svg+xml"}))}),h("txt").addEventListener("click",()=>{if(w.length<2){O("Generate a portrait first.");return}const t=et(w,p.nails);A("string-portrait-nails.txt",new Blob([t],{type:"text/plain"}))}),h("toggle-panel").addEventListener("click",()=>h("panel").classList.add("hidden")),h("show-panel").addEventListener("click",()=>h("panel").classList.remove("hidden")),h("version").textContent="v0.1.0 · f25e0a2";let e=0;window.addEventListener("resize",()=>{window.clearTimeout(e),e=window.setTimeout(()=>{b||(F(),w.length>=2?st():T())},200)})}function st(){U(),g.strokeStyle=`rgba(20, 20, 28, ${p.opacity})`,g.lineWidth=.6,g.lineCap="round",g.lineJoin="round",g.beginPath();for(let e=0;e{for(const i of n)if(i.type==="childList")for(const r of i.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&s(r)}).observe(document,{childList:!0,subtree:!0});function o(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(n){if(n.ep)return;n.ep=!0;const i=o(n);fetch(n.href,i)}})();function J(e,t,o,s){const n=[];if(e<=0)return n;for(let i=0;i=l&&(p+=l,n+=f),M<=d&&(p+=d,i+=u)}return v===x.length?x:x.subarray(0,v)}function H(e,t,o){const{width:s}=o,n=o.height;let i=0,r=0;for(let c=0;c=s||l>=n||(i+=e[l*s+d],r++)}return r===0?0:i/r}function X(e,t,o,s){const{width:n}=s,i=s.height;for(let r=0;r=n||d>=i)continue;const l=d*n+c,f=e[l]-o;e[l]=f<0?0:f}}function _(e,t,o,s){const n=t.length;if(n<2)return-1;const i=Math.max(1,s.minGap??1),r=s.used,c=s.count??n,d=t[e];let l=-1,f=0;for(let u=0;uf&&(f=L,l=u)}return l}function Q(e,t,o){return ed){n[u]=0;continue}const x=e[u]/255;n[u]=s?x:1-x}return n}function Z(e,t,o){const s=new Uint8ClampedArray(t*o);for(let n=0;n255?255:f}return t}function E(e){return Number.isInteger(e)?String(e):e.toFixed(2)}function tt(e,t,o){const{width:s,height:n,lineWidth:i,opacity:r}=o,c=o.stroke??"#000000",d=o.background??"#ffffff",l=[];for(const p of e){const y=t[p];y&&l.push(`${E(y.x)},${E(y.y)}`)}let f="";o.showNails&&(f=`${t.map(y=>``).join("")}`);const u=l.length>=2?``:"";return``+u+f+""}function et(e,t){return["# string-portrait nail sequence",`# nails: ${t}`,`# threads: ${Math.max(0,e.length-1)}`,"# Wind one continuous thread, visiting these nail indices in order:",...e.map(s=>String(s))].join(` -`)+` -`}function h(e){const t=document.getElementById(e);if(!t)throw new Error(`missing #${e}`);return t}const k=h("canvas"),g=k.getContext("2d"),a=500,m={nails:240,maxLines:2500,strength:.15,opacity:.5,invert:!1};let $=null,P=null,R=[],w=[],G=null,C=0,I=0,b=!1;function F(){k.width=a,k.height=a;const t=k.parentElement.getBoundingClientRect(),o=Math.max(280,Math.min(t.width,t.height)-24);k.style.width=`${o}px`,k.style.height=`${o}px`}function U(){g.setTransform(1,0,0,1,0,0),g.fillStyle="#ffffff",g.fillRect(0,0,a,a)}function D(e,t,o){const s=document.createElement("canvas");s.width=a,s.height=a;const n=s.getContext("2d");n.fillStyle="#ffffff",n.fillRect(0,0,a,a);const i=Math.max(a/t,a/o),r=t*i,c=o*i;n.drawImage(e,(a-r)/2,(a-c)/2,r,c);const d=n.getImageData(0,0,a,a).data;return Z(d,a,a)}function N(){$=z(a),T(),S("Sample loaded — hit ▶ Generate.")}async function nt(e){try{const t=await createImageBitmap(e);$=D(t,t.width,t.height),t.close(),T(),S("Photo loaded — hit ▶ Generate.")}catch{const t=URL.createObjectURL(e),o=new Image;o.onload=()=>{$=D(o,o.naturalWidth,o.naturalHeight),URL.revokeObjectURL(t),T(),S("Photo loaded — hit ▶ Generate.")},o.onerror=()=>{URL.revokeObjectURL(t),O("Could not read that image.")},o.src=t}}function T(){if(j(),U(),!$)return;const e=g.createImageData(a,a),t=a/2,o=a/2,s=a/2-1,n=s*s;for(let i=0;i{if(!b||!P||!G)return;let s=!1;for(let i=0;i=m.maxLines);i++){const r=_(C,R,P,{width:a,height:a,used:G,count:m.nails});if(r<0){W(!0);return}const c=R[C],d=R[r],l=q(c.x,c.y,d.x,d.y);X(P,l,m.strength,{width:a,height:a}),G.add(Q(C,r,m.nails)),g.beginPath(),g.moveTo(c.x,c.y),g.lineTo(d.x,d.y),g.stroke(),w.push(r),C=r,s=!0}const n=w.length-1;if(S(`Winding… ${n} / ${m.maxLines} threads`),n>=m.maxLines||!s){W(!1);return}I=requestAnimationFrame(o)};I=requestAnimationFrame(o)}function W(e){b=!1,cancelAnimationFrame(I),B();const t=w.length-1;S(e?`Done — ${t} threads (no darkness left to chase).`:`Done — ${t} threads through ${m.nails} nails.`)}function j(){b=!1,cancelAnimationFrame(I),B()}function B(){h("generate").disabled=b,h("stop").disabled=!b}function S(e){h("progress").textContent=e}function O(e){const t=h("toast");t.textContent=e,t.classList.add("show"),window.setTimeout(()=>t.classList.remove("show"),1800)}function A(e,t){const o=URL.createObjectURL(t),s=document.createElement("a");s.href=o,s.download=e,s.click(),URL.revokeObjectURL(o)}function K(e,t){return e==="strength"||e==="opacity"?t.toFixed(2):String(t)}const Y=["nails","maxLines","strength","opacity"];function it(){for(const e of Y)h(`${e}-out`).textContent=K(e,m[e])}function st(){it(),h("file").addEventListener("change",t=>{const o=t.target.files?.[0];o&&nt(o)}),h("sample").addEventListener("click",N);for(const t of Y)h(t).addEventListener("input",o=>{const s=Number(o.target.value);m[t]=s,h(`${t}-out`).textContent=K(t,s)});h("invert").addEventListener("change",t=>{m.invert=t.target.checked,$&&!b&&T()}),h("generate").addEventListener("click",ot),h("stop").addEventListener("click",()=>{j(),S(`Stopped at ${w.length-1} threads.`)}),h("png").addEventListener("click",()=>{k.toBlob(t=>{t&&A("string-portrait.png",t)},"image/png")}),h("svg").addEventListener("click",()=>{if(w.length<2){O("Generate a portrait first.");return}const t=tt(w,R,{width:a,height:a,lineWidth:.6,opacity:m.opacity,stroke:"#14141c",background:"#ffffff"});A("string-portrait.svg",new Blob([t],{type:"image/svg+xml"}))}),h("txt").addEventListener("click",()=>{if(w.length<2){O("Generate a portrait first.");return}const t=et(w,m.nails);A("string-portrait-nails.txt",new Blob([t],{type:"text/plain"}))}),h("toggle-panel").addEventListener("click",()=>h("panel").classList.add("hidden")),h("show-panel").addEventListener("click",()=>h("panel").classList.remove("hidden")),h("version").textContent="v0.1.0 · local";let e=0;window.addEventListener("resize",()=>{window.clearTimeout(e),e=window.setTimeout(()=>{b||(F(),w.length>=2?rt():T())},200)})}function rt(){U(),g.strokeStyle=`rgba(20, 20, 28, ${m.opacity})`,g.lineWidth=.6,g.lineCap="round",g.lineJoin="round",g.beginPath();for(let e=0;e - + diff --git a/src/main.ts b/src/main.ts index 491edff..8c8701e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -160,7 +160,15 @@ function startGeneration(): void { residual = grayToResidual(sourceGray, RES, RES, settings.invert); const radius = RES / 2 - 1; - nails = nailPositions(settings.nails, radius, RES / 2, RES / 2); + // Snapshot the nail count for the lifetime of this run. The "Nails" slider + // stays live (the user can drag it mid-run to preview a value for next + // time), but `nails`/`used`'s edge-key encoding must stay pinned to + // whatever count the nail positions were actually built with — reading + // `settings.nails` live here would silently corrupt `edgeKey` hashing + // partway through a run (bug: mid-run edge-key drift), letting the + // "never redraw the same chord" guarantee break. + const nailCount = settings.nails; + nails = nailPositions(nailCount, radius, RES / 2, RES / 2); sequence = [0]; used = new Set(); current = 0; @@ -185,7 +193,7 @@ function startGeneration(): void { width: RES, height: RES, used, - count: settings.nails, + count: nailCount, }); if (next < 0) { // Nothing worth drawing remains — finish early. @@ -196,7 +204,7 @@ function startGeneration(): void { const b = nails[next]!; const px = linePixels(a.x, a.y, b.x, b.y); subtractLine(residual, px, settings.strength, { width: RES, height: RES }); - used.add(edgeKey(current, next, settings.nails)); + used.add(edgeKey(current, next, nailCount)); ctx.beginPath(); ctx.moveTo(a.x, a.y);