<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BUKE STUDIO]]></title><description><![CDATA[BUKE STUDIO]]></description><link>https://bukestudio.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 16:29:36 GMT</lastBuildDate><atom:link href="https://bukestudio.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One image, 66 shoe panels: the geometry (and four silent bugs) behind a canvas cutting pipeline]]></title><description><![CDATA[How a browser-side canvas pipeline maps one uploaded image onto 66 shoe panels, and the four bugs that failed silently doing it.]]></description><link>https://bukestudio.hashnode.dev/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline</link><guid isPermaLink="true">https://bukestudio.hashnode.dev/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[canvas]]></category><category><![CDATA[webdev]]></category><category><![CDATA[graphics]]></category><dc:creator><![CDATA[BUKE STUDIO]]></dc:creator><pubDate>Thu, 22 Oct 2026 14:29:27 GMT</pubDate><content:encoded><![CDATA[<p>I make custom printed sneakers. People upload a picture, we print it onto the leather panels of a shoe, and somewhere between those two steps the picture stops being a picture and becomes a set of manufacturing instructions.</p>
<p>That middle step is a browser-side canvas pipeline, and it is the least glamorous and most bug-prone code I have written. This post is about the geometry it has to solve and the four bugs that took me longest to find — all of which failed silently, which is the worst kind.</p>
<h2>The problem in one sentence</h2>
<p>A sneaker upper is not one surface. It is a set of flat panels cut from a hide, printed, then sewn together — 2 panels on our slipper, 4 on the blade pattern, 10 on the everyday sneaker, 14 and 18 on the two low-tops. Sixty-six panels across eight styles.</p>
<p>So "print this image on a shoe" decomposes into: for each panel, take a rectangular region of the user's artwork, scale it to that panel's production resolution, mask it to the panel's actual outline, and flip it if the pattern piece is mirrored.</p>
<h2>The data model</h2>
<p>Every panel is four numbers and two files:</p>
<pre><code class="language-js">{
  id: 1,
  sampleBox: { x: 112.24, y: 29.89, w: 440.06, h: 455.83 }, // where to read from the artwork canvas
  width: 1342, height: 1106,   // production raster for this panel
  mask: 'piece-01-mask.png',   // the panel outline, as an alpha mask
  mirrored: false
}
</code></pre>
<p>The artwork itself sits on a logical canvas — 611 × 800 for the low-top, roughly 3:4. Coordinates in <code>sampleBox</code> are in that space, so the same layout can be reprinted at any physical scale without touching the data.</p>
<p>The cutting step is then genuinely short:</p>
<pre><code class="language-js">const cut = new OffscreenCanvas(p.width, p.height);
const ctx = cut.getContext('2d');
if (p.mirrored) { ctx.translate(p.width, 0); ctx.scale(-1, 1); }
ctx.drawImage(artwork, sb.x * s, sb.y * s, sb.w * s, sb.h * s, 0, 0, p.width, p.height);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(mask, 0, 0);
</code></pre>
<p>Eight lines. It took a week to make those eight lines trust each other.</p>
<h2>Bug one: the masks looked like masks but were not</h2>
<p>The panel outlines came from the pattern department as PNGs. Some of them were real alpha masks — opaque inside the panel, transparent outside. Others were exported from a design file: white shape, grey backdrop, red construction lines.</p>
<p>The second kind looks correct in any image viewer. It is not correct for canvas.</p>
<p>Canvas compositing is <a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas">premultiplied</a>. An RGBA pixel of <code>(255, 255, 255, 0)</code> is stored as <code>(0, 0, 0, 0)</code> — the colour is discarded the moment alpha hits zero. So a "white shape on transparent" export, where the white actually carries <code>alpha = 0</code>, arrives as pure transparent black. <code>drawImage</code> with <code>destination-in</code> then erases everything. No exception, no warning, just an empty canvas.</p>
<p>I tried to patch it at runtime: read the pixels, find the bright ones, rebuild alpha from luminance. It never worked, because by then the RGB values were already gone. The information was destroyed at export time, not at draw time.</p>
<p>The fix was to stop patching at runtime and rebuild the masks offline, once:</p>
<pre><code class="language-js">// per panel, decide which encoding it actually uses
const hasBlackCover = blackPixels / total &gt; 0.01;  // a grey overlay layer, alpha ~153, luminance &lt; 70
let on;
if (hasBlackCover) on = !isBlackCover(r, g, b, a);  // the cover marks what is *not* the panel
else               on = a &gt; 120;                    // honest alpha mask
</code></pre>
<p>66 masks rebuilt as binary alpha, 658 KB total — smaller than the originals, and now unambiguous. The runtime code went from ~40 lines of guessing to a validity check.</p>
<p><strong>Lesson:</strong> if a shape has to survive a compositing step, the shape has to be in alpha before it reaches the canvas. Luminance is not a mask.</p>
<h2>Bug two: garbage coordinates fail silently</h2>
<p>One panel's <code>sampleBox</code> came out of the geometry export as:</p>
<pre><code class="language-js">{ x: -2.18e+22, y: -2.64e+22, w: 4.81e+22 }
</code></pre>
<p><code>drawImage</code> with those numbers throws nothing. It draws nothing. There is no error to catch, no <code>onerror</code>, no rejected promise — the panel just renders blank and you assume your mask is wrong.</p>
<p>I spent most of an afternoon on the mask before printing the source numbers.</p>
<p>The guard is now a magnitude check rather than a sign check, because negative coordinates are legitimate — a panel can sample from outside the artwork canvas and let canvas clamp it:</p>
<pre><code class="language-js">const sane = (sb, mw, mh) =&gt;
  Math.abs(sb.x) &lt;= mw * 4 &amp;&amp; Math.abs(sb.y) &lt;= mh * 4 &amp;&amp;
  sb.w &gt; 0 &amp;&amp; sb.h &gt; 0 &amp;&amp; sb.w &lt;= mw * 4 &amp;&amp; sb.h &lt;= mh * 4;
</code></pre>
<p><strong>Lesson:</strong> validate geometry against the canvas bounds it is supposed to live in, not against "looks reasonable".</p>
<h2>Bug three: <code>revokeObjectURL</code> kills the image you are still drawing</h2>
<p>The first version loaded uploads the obvious way:</p>
<pre><code class="language-js">const url = URL.createObjectURL(file);
const img = new Image();
img.src = url;
URL.revokeObjectURL(url);   // "free the memory"
</code></pre>
<p>This works exactly once. Redraw the panel grid — switch style, change the layout — and every <code>drawImage</code> silently produces an empty canvas, because the blob URL backing the image is gone. The <code>&lt;img&gt;</code> element still reports <code>naturalWidth &gt; 0</code>, which is what made it confusing.</p>
<p><code>createImageBitmap</code> is the right tool here: it decodes once into an immutable bitmap that stays valid until you close it, and it is also faster to draw repeatedly.</p>
<pre><code class="language-js">const bmp = await createImageBitmap(file);
userImage?.close?.();
userImage = bmp;
</code></pre>
<p><strong>Lesson:</strong> if a decoded image is going to be drawn more than once, own the bitmap, not a URL that points at it.</p>
<h2>Bug four: fast clicks race the render loop</h2>
<p>Switching styles fires an async render. Switch twice quickly and the first render's results land after the second one's, painting the old style's panels into the new style's grid.</p>
<p>The fix is a monotonic token checked at every await point:</p>
<pre><code class="language-js">let renderSeq = 0;
async function render() {
  const mySeq = ++renderSeq;
  for (const cell of cells) {
    const mask = await getMask(cell.panel.mask);
    if (mySeq !== renderSeq) return;   // a newer render owns the canvas now
    // ...
  }
}
</code></pre>
<p>Unglamorous, and the only version that survives a user with a fast mouse.</p>
<h2>What the numbers look like</h2>
<p>The pipeline is not just slicing; it is upscaling, and that decides whether a user's file is good enough.</p>
<p>To fill a 1342-pixel panel from a 440-unit window on a 611-unit canvas, the artwork needs to be about <strong>1,860 px wide</strong> to arrive at full resolution with no interpolation. Across the library, the median enlargement is <strong>2.9×</strong> and the worst case is <strong>4.8×</strong> (the 360 × 566 window feeding a 1598 × 1958 panel).</p>
<p>That is a real product constraint disguised as a geometry detail. Telling someone "your image needs to be 2,000 px on the short edge" is only credible if you can derive it from your own pipeline — which is exactly what the sampling windows give you.</p>
<h2>Why I shipped the tool instead of a diagram</h2>
<p>The pipeline runs entirely in the browser, so I exposed it: upload an image, pick a style, and see every panel of that style cut from your artwork at production size. No upload, no account, no server round trip — the same <code>destination-in</code> compositing you just read about.</p>
<p>It turned out to be a better explanation than any diagram I could draw, and it cost nothing extra to run: <a href="https://bukefootwear.com/panel-preview">bukefootwear.com/panel-preview</a>.</p>
<p>The written version, with all 66 panels and the per-style numbers, is at <a href="https://bukefootwear.com/guides/artwork-across-shoe-panels">bukefootwear.com/guides/artwork-across-shoe-panels</a>.</p>
<p>If you are doing anything with panel-based printing, garment mapping or texture atlases, I would like to hear how you handle seam continuity — that is the part I still consider an open problem rather than a solved one.</p>
]]></content:encoded></item></channel></rss>