Heron's homepage opens on an architect's elevation of a concrete house. First it is only lines, drawn in one by one on paper, then a red scan line comes down and the finished, shaded drawing fills in behind it. I wanted that, but for more than one building, so I made a full-screen carousel of six: it is live at avivashishta.com/lab/blueprint.
Every slide plays the same way. Dash-dot construction lines run across the screen, the building's outline draws itself from the ground up, the red bar comes down and the drawing soaks in behind it in blots, like ink into paper, and then the lines step back. Move your cursor over a drawing and you get a crosshair with the pixel you are on.
The short version: Codex draws each building as a flat front elevation. A Python script with OpenCV finds the long straight lines in that image and saves them as numbers, in the image's own pixels, so the lines and the drawing always land on each other. The page draws those lines with SVG, then paints the image onto a canvas through a noise mask that grows from top to bottom, which is what makes it look like ink soaking in.
How Heron does it
I opened their source before building anything. Three things, stacked in one box:
- An SVG of about 66 straight paths, traced by hand. The comments name the parts of the house ("left block", "centre block"). Each path draws in with
stroke-dashoffset. - The finished image, one WebP, hidden by a
clip-path. - The red scan line. As it moves down, the clip opens, so the image appears behind it.
The only hard part is that the lines and the image have to agree, and they traced theirs by hand. I wanted six buildings and the option to add more, so the lines had to come out of the image automatically.
Step 1: the drawings (Codex)
The style has to be the same every time, and the drawing has to be a true elevation: no perspective, so every edge is perfectly vertical or horizontal. That is what makes the tracing in step 2 work. Here is the prompt; only the last line changes per building.
A strict FRONT ELEVATION architectural drawing, perfectly orthographic, no perspective at all, every vertical line exactly vertical and every horizontal exactly horizontal, the building centred. Style: black ink technical line drawing with crisp straight outlines plus hand stipple and grain shading, soft diagonal cast shadows, like a premium architecture monograph illustration. A single straight black ground line runs across the full drawing at about 82% height. A few bare winter trees in thin ink on both sides. Flat warm off-white paper background, no grid, no border, no text, no labels, no people, no colour (only black, greys and paper). Landscape 3:2, 1536x1024.
Building: a two-storey brutalist board-formed concrete villa: two tall slender concrete fins framing a central glazed double-height living box with thin steel mullions, solid concrete side wings, a cantilevered upper volume, a straight run of entrance steps on the left, a low concrete planter wall on the right.
My six were a concrete villa, an office tower with a stepped crown, a Chandigarh-style civic block, an Indian stepwell, a Bauhaus school and a Japanese timber pavilion. Buildings with mostly straight lines trace best.
One thing that cost me time: with the Codex CLI, a long prompt passed as an argument made the job die instantly, with an empty log. Put the prompt in a file and pass it on stdin, one job at a time:
codex exec --skip-git-repo-check -C ./raw - < villa.prompt
Step 2: find the lines (OpenCV)
The obvious tool is edge detection, but these drawings are full of stipple, texture and trees, and edge detection finds all of it. What a building has that nothing else in the picture has is long, perfectly straight runs of ink. So the script keeps only those:
ink = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 31, 25)
# "Opening" with a 40 x 1 rectangle keeps only runs of ink at least 40 px long
long_h = cv2.morphologyEx(ink, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1)))
Do that once with a wide rectangle for horizontals and once with a tall one for verticals, take the centre line of each run, and join runs that sit on the same line. Then a few rules clean it up:
- The building's width is where its long horizontals are (roofs, slabs, sills). Anything outside it is a tree trunk.
- A short line that touches nothing is a texture mark, like a joint in board-formed concrete. A real edge meets another edge at each end.
- The ground is the highest of the long lines near the bottom, and anything under it (a pool's reflection) goes.
Slanted and curved things, like the ramp on the civic block or the stairs in the stepwell, are not traced. They arrive with the ink, which is how Heron handles their trees too. The script writes one JSON per building, with the lines in the image's own pixels:
{ "w": 1536, "h": 1024, "v": "abae1141", "ground": [0, 886, 1536, 886], "lines": [[203, 233, 203, 838], ...] }
Step 3: the drawing has no paper
My first version laid the image over the page and set mix-blend-mode: multiply on it, so its paper would blend into the page's paper. It showed up as a white box anyway, because the element it sat in had a translate, which gives it its own stacking context, and blend modes do not reach past that.
The fix is better than a blend mode: the script saves the drawing as ink on a transparent sheet. Every pixel becomes the ink colour, and its darkness becomes its opacity:
level = np.clip(gray * (255 / (paper - 4)), 0, 255) # paper becomes pure white
alpha = (255 - level) * (255 / (255 - 17)) # darkness becomes opacity
ink = np.dstack([np.full_like(level, 17)] * 3 + [alpha]) # all #111, varying alpha
Ink at that opacity over any paper gives exactly what multiply would, so the page's own paper is the only paper there is, with no box and no change of tone between slides.
Step 4: lines that draw themselves (SVG)
Each line is an SVG <line> in a viewBox the size of the image, sitting in the same box as the drawing. The trick that saves measuring anything: pathLength="1". Every line now has a length of 1, so one dash of 1 and an offset that runs from 1 to 0 draws it, whatever its real length.
line { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw var(--t) var(--d) forwards; }
@keyframes draw { to { stroke-dashoffset: 0; } }
The delay --d comes from the line's height (the lower, the sooner) plus a little randomness, and vertical lines start at their foot, so the building goes up the way buildings do.
Step 5: the ink soaks in (canvas)
A clip-path reveal is a straight edge. To make the drawing soak in, every pixel needs its own turn. So each building gets a field of numbers, one per pixel of a small 384 x 256 grid, that says when that pixel's turn comes: mostly top to bottom, broken up by blotchy noise at three sizes and a little grain.
const blot = noise(x / 40, y / 40) * 0.55 + noise(x / 16, y / 16) * 0.3 + noise(x / 6, y / 6) * 0.15;
field[i] = (y / mh) * 0.62 + blot * 0.32 + grain * 0.06;
Every frame, the pixels whose turn has come become opaque in a small mask, and pixels just behind the front fade in. The mask is stretched to full size (stretching blurs it, which softens the blots) and the drawing is painted through it:
ctx.drawImage(mask, 0, 0, cv.width, cv.height); // where the ink has reached
ctx.globalCompositeOperation = "source-in";
ctx.drawImage(img, 0, 0, cv.width, cv.height); // the drawing, only there
The red bar sits at the front of the top-to-bottom part of the field, so it always leads and the ink pools in behind it. Leaving a slide runs the same thing backwards, and the ink drains up the way it came.
What I'd tell you before you build one
- Trace from the image, never by hand. Lines in the image's own pixels, in the image's own box, cannot drift. It is the reason all six line up.
- Put a hash of the image in its URL. While the last drawings were generating, I put copies of the first one in their place. When the real ones arrived, the browser kept the old images under the same names and the lines no longer matched. Now the script writes a short hash of each image into its JSON and the page asks for
stepwell.webp?v=abae1141, so a new drawing always gets a new address. - Let the progress bar be the clock. Each tab has a bar that fills over the slide. When its CSS animation ends, the page turns, and hovering the drawing pauses it. No timers to keep in sync.
To add a building: generate it with the prompt above, run python3 scripts/trace-blueprint.py villa.png villa, and add one line to the list in the component.