---
title: "Paint Job: A Sofa You Re-Upholster by Dropping a Swatch on It"
description: "How I built a furniture store where you drag a fabric swatch onto the sofa and the new fabric soaks in from where it lands. Seven Codex photos of one room, a mask worked out from where they differ, and one WebGL shader. Prompts and code inside."
date: "2026-11-13"
type: "build"
tags: ["webgl", "shader", "codex", "image generation", "ecommerce", "creative coding", "nextjs"]
output: "/lab/paint"
---

Choosing a sofa fabric online means clicking a tiny square and watching the whole photo blink into a different photo. In a showroom you hold the swatch against the arm. So I made the swatch the thing you pick up: drag it onto the sofa and the fabric soaks in from where it lands, like dye into cloth. [Try it at avivashishta.com/lab/paint](/lab/paint).

<div class="highlight"><p><strong>The short version:</strong> Codex makes one photo of a room with a cream sofa in it, then six edits of that photo with only the sofa re-upholstered. A script works out the sofa's mask from where the edits differ, and cuts each fabric's swatch from its own photo. One WebGL shader soaks the new fabric's photo in from the drop, with a sheen that the mask keeps on the sofa.</p></div>

## Step 1: the room, then the fabrics (Codex)

The room is shot straight on at sofa height, so the sofa is big and nothing overlaps it:

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>A bright minimalist living room, photographed straight on at sofa height. A mid-century modern three-seat sofa with slim tapered oak legs stands centred against a plain warm white wall, upholstered in cream boucle. A pale oak floor, a soft wool rug under the sofa, a small round side table on the right with a ceramic vase, a large window just out of frame on the left casting soft morning daylight. Uncluttered, calm, interior design magazine photography, photoreal. No text, no logos, no people. Landscape 3:2, 1536x1024.</p>
  </div>
</div>

Each fabric is that photo, attached, with one instruction. "Its real texture and how it catches the light" is what gets you corduroy ridges and the sheen on velvet instead of the same sofa tinted:

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>The attached image is a living room with a sofa. Re-upholster ONLY the sofa in mustard yellow wide-wale corduroy: the cushions, back, arms and seat all in the new fabric, with its real texture and how it catches the light. Keep EVERYTHING else exactly identical: the sofa's shape, size and position, the oak legs, the wall, the floor, the rug, the side table and vase, the light, the framing and the camera. Photoreal. No text. Landscape 3:2, 1536x1024.</p>
  </div>
</div>

Codex kept the room still to the pixel on all six. It mostly left the throw cushions alone, but in the linen edit it covered them to match, which is what an upholsterer would offer anyway.

## Step 2: the sofa is where the photos differ

I never drew a mask. The prep script compares each fabric's photo with the first, at a quarter size with a little blur, and counts a pixel as sofa if most of the fabrics changed it. Then it closes the holes (grow, then shrink) and softens the edge:

```js
diff[i] += d > 60 ? 1 : 0;                          // this fabric changed this pixel
m[i] = diff[i] >= files.length * 0.6 ? 255 : 0;     // most of them did: it's sofa
m = morph(morph(m, true), false);                    // close the holes
```

Because the cushions stay the same in most of the photos, they mostly fall out of the mask by themselves. The middle of the mask is also where each fabric's swatch is cut from, so the swatches are real crops of the real fabric. And the page uses it to tell whether a swatch was dropped on the sofa or on the wall.

```bash
bun scripts/prepare-paint.mjs ./raw
```

## Step 3: the soak

The shader has the fabric that is on, the fabric that is going on, and the mask. A circle grows from the drop, roughed up by noise and stretched along the sofa so it reads as dye spreading through cloth rather than a wipe, with a soft sheen riding the front:

```glsl
float e = dist * mix(1., .8, abs(d.x) / max(dist, 1e-4)) + (fbm(uv * vec2(9., 7.) + uSeed) - .5) * .12 - uR;
float band = exp(-e * e / .0016) * uLive * m;   // the sheen, only where the mask says sofa
vec3 col = mix(a, b, 1. - smoothstep(-.006, .006, e));
col += band * .16 * vec3(1., .97, .9);
```

My first version used the mask to keep everything outside the sofa from the first photo. It cut the throw cushions in half: Codex nudged them a few pixels in some edits, so the bottom of a cushion came from one photo and the top from another. The whole photo changing behind the front is better, because the room really is the same in every photo, so the only thing you see change is the sofa (and a cushion settling, which looks right).

It is the same shader idea as [Try On](/blog/try-on-one-model-every-bag), with the mask doing what the cut-out's alpha did there.

## Step 4: drag and drop

The swatches are buttons in a tray. Press one and drag, and a copy follows the pointer; over the sofa it straightens up and gets a blue ring. To know whether it is over the sofa, the page keeps the mask's pixels from when it loaded and reads one under the pointer. Let go there and the soak starts at that exact point on the photo. A plain click soaks from the middle of the cushions instead.

## The small things

- **Three kinds of light.** Morning, golden hour and evening grade the whole room in the shader, easing from one to the next, so you can see the fabric in your evenings, not just the photographer's morning.
- **The price follows the fabric.** Velvet costs more than boucle, as it does.
- **Keyboard:** tab to a swatch and press Enter.

## Build your own

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>Build a full-screen product recolour page (React, WebGL, no libraries). Assets: a room photo and several edits of it where only the sofa changes, plus a mask image of the sofa (worked out offline from where the edits differ from the original). Draw the room on a canvas covering the screen (cover-fit in the shader). The fragment shader takes the current fabric's photo, the next one's, the mask, a drop point and a radius. Show the next photo within a circle round the drop point, roughened with fbm noise and stretched horizontally, the current one outside it, with a soft light band along the edge only where the mask is set. Grow the radius over 1.7 s.</p>
<p>Show swatches (crops of each fabric) in a tray. Dragging one shows a ghost under the pointer; read the mask's pixel under the pointer to know if it is over the sofa. Dropping on the sofa starts the soak there; a click starts it from the sofa's middle. Add a time-of-day switch that grades the colours in the shader, and the fabric's name and price with Add to bag.</p>
  </div>
</div>
