[object Object]

← back to Quadrille Showroom

Floor normal+roughness maps derived locally from floor-oak.png (scripts/derive-floor-maps.py): plank grain now catches grazing light, satin sheen breaks across boards. $0 local. FPS 72, errorCount 0

df96412189c72f43a88ab4d702b2bafc2264035d · 2026-06-27 11:59:43 -0700 · Steve

Files touched

Diff

commit df96412189c72f43a88ab4d702b2bafc2264035d
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 11:59:43 2026 -0700

    Floor normal+roughness maps derived locally from floor-oak.png (scripts/derive-floor-maps.py): plank grain now catches grazing light, satin sheen breaks across boards. $0 local. FPS 72, errorCount 0
---
 public/js/showroom.js                |  32 +++++++++++++-
 public/textures/floor-oak-normal.png | Bin 0 -> 898915 bytes
 public/textures/floor-oak-rough.png  | Bin 0 -> 313224 bytes
 scripts/derive-floor-maps.py         |  78 +++++++++++++++++++++++++++++++++++
 4 files changed, 108 insertions(+), 2 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 891fb6f..f104996 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -202,6 +202,23 @@ function loadTex(name, rx, ry) {
   return srgb(t);
 }
 
+// Data textures (normal / roughness maps) — MUST stay LINEAR (no sRGB) or the
+// surface relief + gloss read wrong. Same wrap/repeat/anisotropy as loadTex so the
+// derived oak normal+rough tile in lock-step with the albedo and stay seam-free.
+function loadDataTex(name, rx, ry) {
+  const t = new THREE.TextureLoader().load('/textures/' + name + '.png');
+  t.wrapS = t.wrapT = THREE.RepeatWrapping;
+  t.repeat.set(rx || 1, ry || 1);
+  t.minFilter = THREE.LinearMipmapLinearFilter;
+  t.magFilter = THREE.LinearFilter;
+  t.generateMipmaps = true;
+  if (THREE.LinearEncoding) t.encoding = THREE.LinearEncoding;
+  if (renderer && renderer.capabilities && renderer.capabilities.getMaxAnisotropy) {
+    t.anisotropy = renderer.capabilities.getMaxAnisotropy();
+  }
+  return t;
+}
+
 // ============================================================
 // MATERIALS — all shared
 // ============================================================
@@ -329,8 +346,19 @@ function buildRoom() {
   // floor-oak.png). Tiled ~2.2× across the room so the real plank grain reads at
   // walking scale; max anisotropy keeps it crisp at grazing angle. (Was a
   // procedural canvas that read as a cartoon — see REVIEW.md.)
-  const ft = loadTex('floor-oak', 2.4, 2.4);
-  const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshStandardMaterial({ map: ft, roughness: 0.5, metalness: 0.0, envMapIntensity: 0.35 }));
+  // NORMAL + ROUGHNESS maps (derived locally from floor-oak.png via
+  // scripts/derive-floor-maps.py) make the plank grain catch grazing light — the
+  // grooves self-shadow and the satin sheen breaks up across the boards instead of
+  // reading as one flat painted plane. normalScale kept modest so it's "real wood",
+  // not "embossed". Same 2.4× repeat as the albedo → tiles in lock-step, seam-free.
+  const ft  = loadTex('floor-oak', 2.4, 2.4);
+  const fn  = loadDataTex('floor-oak-normal', 2.4, 2.4);
+  const frg = loadDataTex('floor-oak-rough', 2.4, 2.4);
+  const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshStandardMaterial({
+    map: ft, normalMap: fn, roughnessMap: frg,
+    normalScale: new THREE.Vector2(0.55, 0.55),
+    roughness: 1.0, metalness: 0.0, envMapIntensity: 0.35
+  }));
   floorMesh.rotation.x = -Math.PI/2; floorMesh.receiveShadow = true; scene.add(floorMesh);
 
   // Ceiling
diff --git a/public/textures/floor-oak-normal.png b/public/textures/floor-oak-normal.png
new file mode 100644
index 0000000..bc3af05
Binary files /dev/null and b/public/textures/floor-oak-normal.png differ
diff --git a/public/textures/floor-oak-rough.png b/public/textures/floor-oak-rough.png
new file mode 100644
index 0000000..02f5217
Binary files /dev/null and b/public/textures/floor-oak-rough.png differ
diff --git a/scripts/derive-floor-maps.py b/scripts/derive-floor-maps.py
new file mode 100644
index 0000000..5eb8d86
--- /dev/null
+++ b/scripts/derive-floor-maps.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""
+derive-floor-maps.py — derive a tangent-space NORMAL map + a subtle ROUGHNESS map
+from public/textures/floor-oak.png so the oak plank grain catches grazing light.
+
+100% local (PIL + numpy) — $0, no API. Output is seamless-preserving: the source
+SDXL oak is tileable, and Sobel + wrap-mode padding keep the derived maps tileable
+too, so the floor's RepeatWrapping tiling stays seam-free.
+
+  floor-oak.png  ->  floor-oak-normal.png   (RGB tangent-space normal)
+                 ->  floor-oak-rough.png     (grayscale roughness variation)
+
+Run:  python3 scripts/derive-floor-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, 'floor-oak.png')
+
+# --- tunables -------------------------------------------------------------
+STRENGTH   = 2.4     # normal-map relief strength; higher = deeper grain
+BLUR       = 1.0     # pre-blur the height a touch to kill single-pixel noise
+ROUGH_BASE = 0.50    # matches MAT.floor roughness in showroom.js
+ROUGH_VAR  = 0.22    # +/- swing of roughness around base from grain darkness
+# -------------------------------------------------------------------------
+
+def wrap_sobel(h):
+    """Sobel gradients with WRAP padding so the result stays seamlessly tileable."""
+    # np.roll gives wrap-around neighbours for free (toroidal), preserving tiling.
+    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. Oak grain lines are darker -> read as recessed grooves.
+    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). Grooves point inward.
+    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
+    # Encode [-1,1] -> [0,255]. +Y up convention (OpenGL / three.js default).
+    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, 'floor-oak-normal.png'))
+
+    # Roughness: darker grain = slightly rougher (matte groove), bright plank face =
+    # smoother satin sheen. Centre on ROUGH_BASE so it matches the material.
+    inv_lum = 1.0 - lum                     # dark grain -> high
+    inv_lum = (inv_lum - inv_lum.mean())    # zero-centre the variation
+    sd = inv_lum.std() + 1e-6
+    rough = ROUGH_BASE + (inv_lum / (3.0*sd)) * ROUGH_VAR
+    rough = np.clip(rough, 0.30, 0.80)
+    rmap = np.clip(rough*255.0, 0, 255).astype(np.uint8)
+    Image.fromarray(rmap, 'L').save(os.path.join(TEX, 'floor-oak-rough.png'))
+
+    print('OK  floor-oak-normal.png  (%dx%d, strength %.1f)' % (W, Hh, STRENGTH))
+    print('OK  floor-oak-rough.png   (base %.2f +/- %.2f)' % (ROUGH_BASE, ROUGH_VAR))
+    print('$0 (local PIL+numpy)')
+
+if __name__ == '__main__':
+    main()

← b1e3c75 PJ hero framing: focused board presents FLAT/dead-on (near-f  ·  back to Quadrille Showroom  ·  PJ hero framing: narrow FOV (55->30) on focus + back off pro 1e55735 →