[object Object]

← back to Quadrille Showroom

WALL realism: derive subtle normal+roughness maps from wall-limewash.png (scripts/derive-wall-maps.py, $0 local PIL+numpy) — apply to MAT.wall + MAT.brick at normalScale 0.2 so limewash plaster has faint trowel relief catching grazing light, no longer a flat tiled photo. FPS 72, errorCount 0, specs intact

bb93cda1f9d4c38a2de04f933dce13d1f049f6de · 2026-06-27 12:24:50 -0700 · Steve

Files touched

Diff

commit bb93cda1f9d4c38a2de04f933dce13d1f049f6de
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 12:24:50 2026 -0700

    WALL realism: derive subtle normal+roughness maps from wall-limewash.png (scripts/derive-wall-maps.py, $0 local PIL+numpy) — apply to MAT.wall + MAT.brick at normalScale 0.2 so limewash plaster has faint trowel relief catching grazing light, no longer a flat tiled photo. FPS 72, errorCount 0, specs intact
---
 public/js/showroom.js                    |  23 +++++++--
 public/textures/wall-limewash-normal.png | Bin 0 -> 181475 bytes
 public/textures/wall-limewash-rough.png  | Bin 0 -> 59639 bytes
 scripts/derive-wall-maps.py              |  77 +++++++++++++++++++++++++++++++
 4 files changed, 97 insertions(+), 3 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 359635e..2120e1e 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -229,7 +229,18 @@ function initMaterials() {
   // bright warm off-white with a faint hand-troweled mottle. Replaces the flat
   // procedural wall so the room reads as a real PJ white-box gallery. color tint
   // keeps it bright; low repeat = large-scale mottle.
-  MAT.wall = new THREE.MeshStandardMaterial({ map: loadTex('wall-limewash', 2, 1.4), color: 0xfbf7ef, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.3 });
+  // SUBTLE NORMAL + ROUGHNESS maps (derived locally from wall-limewash.png via
+  // scripts/derive-wall-maps.py) give the plaster faint tactile trowel relief so it
+  // catches grazing picture-light instead of reading as one flat tiled photo.
+  // Plaster is smooth, so normalScale is kept ~0.2 — "honed limewash", not stucco.
+  // Maps tile in lock-step with the albedo (same repeat) → seam-free.
+  MAT.wall = new THREE.MeshStandardMaterial({
+    map: loadTex('wall-limewash', 2, 1.4),
+    normalMap: loadDataTex('wall-limewash-normal', 2, 1.4),
+    normalScale: new THREE.Vector2(0.2, 0.2),
+    roughnessMap: loadDataTex('wall-limewash-rough', 2, 1.4),
+    color: 0xfbf7ef, roughness: 1.0, metalness: 0.0, envMapIntensity: 0.3
+  });
   MAT.chrome = new THREE.MeshStandardMaterial({ color: 0xc9a96e, roughness: 0.25, metalness: 0.9, envMapIntensity: 1.0 }); // brushed brass
   MAT.wood = new THREE.MeshStandardMaterial({ color: 0x6b4a32, roughness: 0.5, metalness: 0.0, envMapIntensity: 0.5 }); // satin walnut
   MAT.chair = new THREE.MeshStandardMaterial({ color: 0x4a3528, roughness: 0.65, metalness: 0.05, envMapIntensity: 0.4 }); // cognac leather
@@ -244,8 +255,14 @@ function initMaterials() {
 
   // Left wall — same PHOTOREAL limewash plaster (NOT brick), offset repeat so it
   // doesn't read as a clone of the back wall. (MAT.brick name kept for the existing
-  // roomWalls reference; it is plaster now.)
-  MAT.brick = new THREE.MeshStandardMaterial({ map: loadTex('wall-limewash', 1.6, 1.6), color: 0xfbf7ef, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.3 });
+  // roomWalls reference; it is plaster now.) Same subtle trowel-relief maps.
+  MAT.brick = new THREE.MeshStandardMaterial({
+    map: loadTex('wall-limewash', 1.6, 1.6),
+    normalMap: loadDataTex('wall-limewash-normal', 1.6, 1.6),
+    normalScale: new THREE.Vector2(0.2, 0.2),
+    roughnessMap: loadDataTex('wall-limewash-rough', 1.6, 1.6),
+    color: 0xfbf7ef, roughness: 1.0, metalness: 0.0, envMapIntensity: 0.3
+  });
 }
 
 // ============================================================
diff --git a/public/textures/wall-limewash-normal.png b/public/textures/wall-limewash-normal.png
new file mode 100644
index 0000000..1d6e4db
Binary files /dev/null and b/public/textures/wall-limewash-normal.png differ
diff --git a/public/textures/wall-limewash-rough.png b/public/textures/wall-limewash-rough.png
new file mode 100644
index 0000000..8d2479a
Binary files /dev/null and b/public/textures/wall-limewash-rough.png differ
diff --git a/scripts/derive-wall-maps.py b/scripts/derive-wall-maps.py
new file mode 100644
index 0000000..3371aa4
--- /dev/null
+++ b/scripts/derive-wall-maps.py
@@ -0,0 +1,77 @@
+#!/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()

← a38c66c NOW VIEWING navigator HUD: museum media-player strip (swatch  ·  back to Quadrille Showroom  ·  CEILING: replace crude 64px procedural grid (read as a wiref 7dcf846 →