← back to Quadrille Showroom

scripts/derive-wall-maps.py

78 lines

#!/usr/bin/env python3
"""
derive-wall-maps.py — derive a SUBTLE tangent-space NORMAL map + a faint ROUGHNESS
map from public/textures/wall-limewash.png so the limewash plaster reads as a real
hand-troweled surface with faint tactile relief, not a flat tiled photo.

Same local method as scripts/derive-floor-maps.py (PIL + numpy, wrap-padded Sobel,
$0, no API). Plaster is SMOOTH, so the relief is deliberately gentle — we pre-blur
the height harder and use a low strength so we capture the large-scale trowel mottle
rather than pixel grain. The showroom applies it at normalScale ~0.2.

  wall-limewash.png  ->  wall-limewash-normal.png   (RGB tangent-space normal)
                     ->  wall-limewash-rough.png     (grayscale roughness variation)

Run:  python3 scripts/derive-wall-maps.py
"""
import os, sys
import numpy as np
from PIL import Image, ImageFilter

HERE = os.path.dirname(os.path.abspath(__file__))
TEX  = os.path.join(HERE, '..', 'public', 'textures')
SRC  = os.path.join(TEX, 'wall-limewash.png')

# --- tunables (plaster = smooth, so gentle) -------------------------------
STRENGTH   = 0.9     # LOW relief — limewash is troweled-smooth, only faint mottle
BLUR       = 2.5     # blur harder than floor: capture trowel-scale mottle, not grain
ROUGH_BASE = 0.95    # matches MAT.wall roughness in showroom.js (matte plaster)
ROUGH_VAR  = 0.05    # tiny swing — plaster stays uniformly matte
# -------------------------------------------------------------------------

def wrap_sobel(h):
    """Sobel gradients with WRAP padding so the result stays seamlessly tileable."""
    gx = ( np.roll(h, -1, axis=1) - np.roll(h, 1, axis=1) ) \
       + 0.5*( np.roll(np.roll(h,-1,0),-1,1) - np.roll(np.roll(h,-1,0),1,1) ) \
       + 0.5*( np.roll(np.roll(h, 1,0),-1,1) - np.roll(np.roll(h, 1,0),1,1) )
    gy = ( np.roll(h, -1, axis=0) - np.roll(h, 1, axis=0) ) \
       + 0.5*( np.roll(np.roll(h,-1,1),-1,0) - np.roll(np.roll(h,-1,1),1,0) ) \
       + 0.5*( np.roll(np.roll(h, 1,1),-1,0) - np.roll(np.roll(h, 1,1),1,0) )
    return gx, gy

def main():
    if not os.path.exists(SRC):
        print('MISSING source:', SRC); sys.exit(1)
    img = Image.open(SRC).convert('RGB')
    W, Hh = img.size

    # Luminance heightmap, heavily blurred so we read the large trowel mottle.
    lum = np.asarray(img.convert('L').filter(ImageFilter.GaussianBlur(BLUR)), dtype=np.float32) / 255.0

    gx, gy = wrap_sobel(lum)
    gx *= STRENGTH; gy *= STRENGTH

    # Tangent-space normal: N = normalize(-gx, -gy, 1).
    nz = np.ones_like(gx)
    nx, ny = -gx, -gy
    inv = 1.0 / np.sqrt(nx*nx + ny*ny + nz*nz)
    nx *= inv; ny *= inv; nz *= inv
    nrm = np.stack([ (nx*0.5+0.5), (ny*0.5+0.5), (nz*0.5+0.5) ], axis=-1)
    nrm = np.clip(nrm*255.0, 0, 255).astype(np.uint8)
    Image.fromarray(nrm, 'RGB').save(os.path.join(TEX, 'wall-limewash-normal.png'))

    # Roughness: faint variation around the matte base — troweled wet spots read a
    # touch glossier, dried high spots a touch chalkier. Kept very tight.
    inv_lum = lum - lum.mean()
    sd = inv_lum.std() + 1e-6
    rough = ROUGH_BASE + (inv_lum / (3.0*sd)) * ROUGH_VAR
    rough = np.clip(rough, 0.88, 0.99)
    rmap = np.clip(rough*255.0, 0, 255).astype(np.uint8)
    Image.fromarray(rmap, 'L').save(os.path.join(TEX, 'wall-limewash-rough.png'))

    print('OK  wall-limewash-normal.png  (%dx%d, strength %.1f, blur %.1f)' % (W, Hh, STRENGTH, BLUR))
    print('OK  wall-limewash-rough.png   (base %.2f +/- %.2f)' % (ROUGH_BASE, ROUGH_VAR))
    print('$0 (local PIL+numpy)')

if __name__ == '__main__':
    main()