import * as THREE from 'three'; /** Hi-res procedural textures for photo-realistic gallery surfaces. */ export const TEXTURE_SIZE = 1024; export type SurfaceTextureKind = | 'marble-white' | 'marble-veined-carrara' | 'marble-veined-emerald' | 'marble-checker' | 'marble-opus-sectile' | 'marble-revetment' | 'coffered-wood' | 'limestone' | 'sandstone' | 'rough-stone' | 'gothic-ashlar' | 'gothic-vault' | 'gothic-stone-floor' | 'basalt' | 'stucco-cream' | 'stucco-terracotta' | 'plaster-white' | 'plaster-warm' | 'velvet-crimson' | 'velvet-navy' | 'velvet-emerald' | 'silk-gold' | 'silk-pale' | 'oak-panel' | 'walnut-panel' | 'dark-wood-panel' | 'parquet-herringbone' | 'parquet-chevrons' | 'terracotta-tiles' | 'flagstone' | 'mosaic-byzantine' | 'mosaic-roman' | 'terrazzo' | 'concrete-polished' | 'concrete-raw' | 'industrial-floor' | 'gilded-stucco' | 'fresco-worn' | 'brick' | 'concrete-block' | 'dark-plaster' | 'pastel-plaster' | 'white-plaster' | 'studio-white' | 'painted-lime' | 'painted-oil-matte' | 'painted-oil-satin' | 'painted-emulsion' | 'painted-flat'; export interface SurfaceTextureSet { map: THREE.CanvasTexture; normalMap: THREE.CanvasTexture; roughness: number; metalness: number; metersPerRepeat: number; normalScale: number; } const cache = new Map(); function seeded(seed: number) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } function hexToRgb(hex: string): [number, number, number] { const n = parseInt(hex.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function rgb(r: number, g: number, b: number) { return `rgb(${r | 0},${g | 0},${b | 0})`; } function shadeHex(hex: string, amount: number): string { const [r, g, b] = hexToRgb(hex); const clamp = (v: number) => Math.min(255, Math.max(0, v + amount)); return rgb(clamp(r), clamp(g), clamp(b)); } function fill(ctx: CanvasRenderingContext2D, size: number, color: string) { ctx.fillStyle = color; ctx.fillRect(0, 0, size, size); } function noiseOverlay(ctx: CanvasRenderingContext2D, size: number, alpha: number, seed: number) { const rand = seeded(seed); const img = ctx.getImageData(0, 0, size, size); const d = img.data; for (let i = 0; i < d.length; i += 4) { const n = (rand() - 0.5) * alpha * 255; d[i] += n; d[i + 1] += n; d[i + 2] += n; } ctx.putImageData(img, 0, 0); } /** Fractal value noise for organic surface variation. */ function fbm(x: number, y: number, seed: number, octaves = 5): number { let value = 0; let amp = 0.5; let freq = 1; for (let i = 0; i < octaves; i++) { value += amp * (Math.sin(x * freq * 12.9898 + seed * 0.137 + i * 1.7) * Math.cos(y * freq * 78.233 + seed * 0.311 + i * 2.3) * 0.5 + 0.5); amp *= 0.52; freq *= 2.05; } return value; } function paintNeutralBase(ctx: CanvasRenderingContext2D, size: number) { fill(ctx, size, '#f8f8f6'); } type PaintFinish = 'lime' | 'oil-matte' | 'oil-satin' | 'emulsion' | 'flat'; /** Single-colour painted wall — neutral base so movement tint drives the hue. */ function paintedWallSurface( ctx: CanvasRenderingContext2D, size: number, finish: PaintFinish, seed: number ) { paintNeutralBase(ctx, size); const img = ctx.getImageData(0, 0, size, size); const d = img.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const u = x / size; const v = y / size; let lum = 0; if (finish === 'lime') { lum += (fbm(u * 3.2, v * 3.2, seed) - 0.5) * 0.14; lum += (fbm(u * 8, v * 8, seed + 17) - 0.5) * 0.06; lum += Math.sin(v * 40 + fbm(u, 0, seed + 3) * 8) * 0.012; } else if (finish === 'oil-matte') { lum += Math.sin(v * 55 + fbm(u * 2, 0, seed) * 4) * 0.018; lum += (fbm(u * 18, v * 18, seed + 5) - 0.5) * 0.04; lum += (fbm(u * 60, v * 60, seed + 9) - 0.5) * 0.025; } else if (finish === 'oil-satin') { lum += Math.sin(v * 48 + fbm(u, 0, seed) * 3) * 0.014; const sheen = Math.pow(Math.max(0, Math.sin(u * 28 + v * 22 + seed)), 3) * 0.06; lum += sheen; } else if (finish === 'emulsion') { lum += Math.sin(v * (Math.PI * 2 * size) / 105) * 0.022; lum += (fbm(u * 22, v * 22, seed + 11) - 0.5) * 0.035; lum += (fbm(u * 70, v * 70, seed + 13) - 0.5) * 0.018; } else { lum += (fbm(u * 35, v * 35, seed + 19) - 0.5) * 0.028; lum += (fbm(u * 90, v * 90, seed + 21) - 0.5) * 0.012; } const delta = lum * 255; d[i] = Math.min(255, Math.max(0, d[i] + delta)); d[i + 1] = Math.min(255, Math.max(0, d[i + 1] + delta)); d[i + 2] = Math.min(255, Math.max(0, d[i + 2] + delta)); } } ctx.putImageData(img, 0, 0); noiseOverlay(ctx, size, finish === 'flat' ? 0.012 : 0.022, seed + 99); } function plasterSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) { fill(ctx, size, base); const rand = seeded(seed); for (let pass = 0; pass < 55; pass++) { const angle = rand() * Math.PI; const len = size * (0.08 + rand() * 0.22); const x0 = rand() * size; const y0 = rand() * size; ctx.strokeStyle = `rgba(0,0,0,${0.015 + rand() * 0.025})`; ctx.lineWidth = 6 + rand() * 18; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + Math.cos(angle) * len, y0 + Math.sin(angle) * len); ctx.stroke(); } noiseOverlay(ctx, size, 0.045, seed + 1); } function stuccoSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) { fill(ctx, size, base); const rand = seeded(seed); for (let i = 0; i < 80; i++) { const cx = rand() * size; const cy = rand() * size; const r = 20 + rand() * 60; ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.04})`; ctx.lineWidth = 2 + rand() * 4; ctx.beginPath(); ctx.arc(cx, cy, r, rand() * Math.PI, rand() * Math.PI + Math.PI * 0.8); ctx.stroke(); } noiseOverlay(ctx, size, 0.07, seed + 2); } function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string, vein: string, seed: number) { fill(ctx, size, base); const rand = seeded(seed); for (let i = 0; i < 42; i++) { const [r, g, b] = hexToRgb(vein.startsWith('#') ? vein : '#a8a098'); ctx.strokeStyle = `rgba(${r},${g},${b},${0.04 + rand() * 0.16})`; ctx.lineWidth = 0.8 + rand() * 6; ctx.beginPath(); let x = rand() * size; let y = rand() * size; ctx.moveTo(x, y); for (let s = 0; s < 14; s++) { x += (rand() - 0.5) * size * 0.14; y += (rand() - 0.5) * size * 0.1; const cx = (x + rand() * size * 0.05) / 2; const cy = (y + rand() * size * 0.05) / 2; ctx.quadraticCurveTo(cx, cy, x, y); } ctx.stroke(); } { const img = ctx.getImageData(0, 0, size, size); const d = img.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const n = (fbm(x / 180, y / 180, seed + 50) - 0.5) * 12; d[i] += n; d[i + 1] += n; d[i + 2] += n; } } ctx.putImageData(img, 0, 0); } noiseOverlay(ctx, size, 0.035, seed + 1); } /** * Two-centred (pointed) arch path from the springing line up to the apex. * `springY` is the bottom of the curve, `apexY` the crown — canvas y grows down. */ function pointedArchPath( ctx: CanvasRenderingContext2D, cx: number, halfW: number, springY: number, apexY: number ) { const shoulder = apexY + (springY - apexY) * 0.28; ctx.moveTo(cx - halfW, springY); ctx.quadraticCurveTo(cx - halfW, shoulder, cx, apexY); ctx.quadraticCurveTo(cx + halfW, shoulder, cx + halfW, springY); } /** Coursed ashlar masonry — staggered blocks with cut joints and per-block tone. */ function ashlarCourses( ctx: CanvasRenderingContext2D, size: number, base: string, seed: number, courses = 12 ) { fill(ctx, size, base); const [br, bg, bb] = hexToRgb(base); const ch = size / courses; const bw = size / 6; for (let row = 0; row < courses; row++) { const offset = row % 2 ? bw / 2 : 0; for (let col = -1; col < 7; col++) { const rand = seeded(seed + row * 11 + col * 3); const t = (rand() - 0.5) * 22; ctx.fillStyle = rgb(br + t, bg + t, bb + t * 0.9); ctx.fillRect(col * bw + offset, row * ch, bw - 1.5, ch - 1.5); } // Recessed bed joint with a lit lower lip ctx.fillStyle = 'rgba(60,52,40,0.35)'; ctx.fillRect(0, row * ch + ch - 1.5, size, 1.5); ctx.fillStyle = 'rgba(255,250,235,0.16)'; ctx.fillRect(0, row * ch, size, 1); } noiseOverlay(ctx, size, 0.05, seed + 1); } /** Veined marble confined to one slab rect — for revetment panels and inlay. */ function marbleSlab( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, base: string, vein: string, seed: number ) { const rand = seeded(seed); ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); ctx.fillStyle = base; ctx.fillRect(x, y, w, h); const [vr, vg, vb] = hexToRgb(vein); const strands = 10 + Math.floor(w / 24); for (let i = 0; i < strands; i++) { ctx.strokeStyle = `rgba(${vr},${vg},${vb},${0.1 + rand() * 0.3})`; ctx.lineWidth = 0.6 + rand() * 3.4; ctx.beginPath(); let px = x + rand() * w; let py = y + rand() * h; ctx.moveTo(px, py); for (let s = 0; s < 9; s++) { px += (rand() - 0.35) * w * 0.28; py += (rand() - 0.5) * h * 0.22; ctx.quadraticCurveTo(px - w * 0.05, py + h * 0.04, px, py); } ctx.stroke(); } ctx.restore(); } function woodPanelSurface( ctx: CanvasRenderingContext2D, size: number, base: string, seed: number, plankCount = 14 ) { const plankH = size / plankCount; for (let i = 0; i < plankCount; i++) { const grad = ctx.createLinearGradient(0, i * plankH, size, i * plankH + plankH); grad.addColorStop(0, shadeHex(base, -18)); grad.addColorStop(0.35, base); grad.addColorStop(0.65, shadeHex(base, 8)); grad.addColorStop(1, shadeHex(base, -12)); ctx.fillStyle = grad; ctx.fillRect(0, i * plankH, size, plankH - 2); for (let g = 0; g < 16; g++) { const gx = g * (size / 16); ctx.strokeStyle = 'rgba(0,0,0,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(gx, i * plankH); const wave = Math.sin(g * 0.8 + i + seed * 0.01) * size * 0.04; ctx.bezierCurveTo(gx + size * 0.03, i * plankH + plankH * 0.3, gx - size * 0.02 + wave, i * plankH + plankH * 0.7, gx + wave, i * plankH + plankH); ctx.stroke(); } ctx.strokeStyle = 'rgba(0,0,0,0.28)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, i * plankH); ctx.lineTo(size, i * plankH); ctx.stroke(); } noiseOverlay(ctx, size, 0.035, seed); } function velvetSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) { fill(ctx, size, base); const img = ctx.getImageData(0, 0, size, size); const d = img.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const weave = Math.sin(x * 0.35) * Math.sin(y * 0.35) * 0.04 + Math.sin(x * 0.12 + y * 0.08 + seed) * 0.025; const pile = fbm(x / 40, y / 40, seed) * 0.08; const delta = (weave + pile) * 255; d[i] += delta; d[i + 1] += delta; d[i + 2] += delta; } } ctx.putImageData(img, 0, 0); noiseOverlay(ctx, size, 0.12, seed + 1); } function paintSurface(kind: SurfaceTextureKind, size: number): ImageData { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d')!; switch (kind) { case 'painted-lime': paintedWallSurface(ctx, size, 'lime', 501); break; case 'painted-oil-matte': paintedWallSurface(ctx, size, 'oil-matte', 502); break; case 'painted-oil-satin': paintedWallSurface(ctx, size, 'oil-satin', 503); break; case 'painted-emulsion': paintedWallSurface(ctx, size, 'emulsion', 504); break; case 'painted-flat': paintedWallSurface(ctx, size, 'flat', 505); break; case 'marble-white': case 'marble-veined-carrara': marbleVeined(ctx, size, '#ece8e0', '#a8a098', 101); break; case 'marble-veined-emerald': marbleVeined(ctx, size, '#dce8e0', '#4a7868', 102); break; case 'marble-checker': { const tile = size / 10; for (let row = 0; row < 10; row++) { for (let col = 0; col < 10; col++) { const light = (row + col) % 2 === 0; ctx.fillStyle = light ? '#f2eee6' : '#4a6858'; ctx.fillRect(col * tile, row * tile, tile, tile); ctx.strokeStyle = 'rgba(0,0,0,0.06)'; ctx.strokeRect(col * tile + 0.5, row * tile + 0.5, tile - 1, tile - 1); } } noiseOverlay(ctx, size, 0.03, 103); break; } case 'marble-opus-sectile': { // Cosmatesque paving: cut-stone squares with diagonally-set inlays and // corner triangles in porphyry, verd-antique, and giallo over travertine. const stones = [ ['#6a3c42', '#4c2830'], ['#47554b', '#333f38'], ['#8a7752', '#66573c'], ['#4e4a45', '#363330'], ]; fill(ctx, size, '#7d6e58'); const grid = 2; const cell = size / grid; for (let row = 0; row < grid; row++) { for (let col = 0; col < grid; col++) { const x = col * cell; const y = row * cell; const rand = seeded(610 + row * grid + col); // Travertine ground slab for this bay marbleSlab(ctx, x, y, cell, cell, '#8f7f66', '#6d5f4c', 620 + row * grid + col); const pick = stones[(row * 3 + col * 5 + Math.floor(rand() * 2)) % stones.length]; const inset = cell * 0.045; // Corner triangles ctx.fillStyle = pick[1]; const corners: [number, number, number, number, number, number][] = [ [x + inset, y + inset, x + cell * 0.5, y + inset, x + inset, y + cell * 0.5], [x + cell - inset, y + inset, x + cell * 0.5, y + inset, x + cell - inset, y + cell * 0.5], [x + inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + inset, y + cell * 0.5], [x + cell - inset, y + cell - inset, x + cell * 0.5, y + cell - inset, x + cell - inset, y + cell * 0.5], ]; for (const [ax, ay, bx, by, cx2, cy2] of corners) { ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx2, cy2); ctx.closePath(); ctx.fill(); } // Diamond inlay set on the diagonal const cx = x + cell / 2; const cy = y + cell / 2; const r = cell * 0.34; ctx.save(); ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy); ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r, cy); ctx.closePath(); ctx.clip(); marbleSlab(ctx, cx - r, cy - r, r * 2, r * 2, pick[0], pick[1], 640 + row * grid + col); ctx.restore(); ctx.strokeStyle = 'rgba(40,30,22,0.45)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy); ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r, cy); ctx.closePath(); ctx.stroke(); // Cut joint around the bay ctx.strokeStyle = 'rgba(40,30,22,0.5)'; ctx.lineWidth = 2; ctx.strokeRect(x + 1, y + 1, cell - 2, cell - 2); } } noiseOverlay(ctx, size, 0.03, 601); break; } case 'marble-revetment': { // Basilica wall cladding: book-matched marble panels in carved stone // surrounds — Santa Sabina / San Vitale, no mosaic. fill(ctx, size, '#a8977c'); const cols = 2; const rows = 2; const pw = size / cols; const ph = size / rows; const fields = [ ['#5e6d64', '#33423a'], ['#8a7a5e', '#5a4c36'], ['#5e6d64', '#33423a'], ['#6f5f56', '#453a34'], ]; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = col * pw; const y = row * ph; // Travertine surround marbleSlab(ctx, x, y, pw, ph, '#b0a084', '#8a7658', 660 + row * cols + col); const m = pw * 0.09; const iw = pw - m * 2; const ih = ph - m * 2; const field = fields[(row * cols + col) % fields.length]; // Book-matched pair: the same slab mirrored about the panel centre const half = iw / 2; marbleSlab(ctx, x + m, y + m, half, ih, field[0], field[1], 670 + row * cols + col); ctx.save(); ctx.translate(x + m + iw, y + m); ctx.scale(-1, 1); ctx.drawImage(ctx.canvas, x + m, y + m, half, ih, 0, 0, half, ih); ctx.restore(); // Moulded frame around the panel ctx.strokeStyle = 'rgba(60,44,30,0.55)'; ctx.lineWidth = 3; ctx.strokeRect(x + m, y + m, iw, ih); ctx.strokeStyle = 'rgba(255,244,222,0.35)'; ctx.lineWidth = 2; ctx.strokeRect(x + m - 4, y + m - 4, iw + 8, ih + 8); } } // Horizontal string course between panel registers ctx.fillStyle = 'rgba(90,68,44,0.35)'; ctx.fillRect(0, ph - 3, size, 6); noiseOverlay(ctx, size, 0.04, 680); break; } case 'coffered-wood': { // Open-timber coffered basilica ceiling: recessed dark walnut boxes with // lit chamfers so the grid reads as depth, not a flat pattern. fill(ctx, size, '#2e1e12'); const n = 6; const cell = size / n; for (let row = 0; row < n; row++) { for (let col = 0; col < n; col++) { const x = col * cell; const y = row * cell; const beam = cell * 0.14; // Beam faces catch light from below ctx.fillStyle = '#6a4a2c'; ctx.fillRect(x, y, cell, beam); ctx.fillRect(x, y, beam, cell); ctx.fillStyle = '#4a3018'; ctx.fillRect(x + cell - beam, y, beam, cell); ctx.fillRect(x, y + cell - beam, cell, beam); // Recessed coffer pan const px = x + beam; const py = y + beam; const pwid = cell - beam * 2; const phei = cell - beam * 2; const grad = ctx.createLinearGradient(px, py, px, py + phei); grad.addColorStop(0, '#22150c'); grad.addColorStop(1, '#3c2616'); ctx.fillStyle = grad; ctx.fillRect(px, py, pwid, phei); // Central gilt rosette boss ctx.fillStyle = '#8a6a2a'; ctx.beginPath(); ctx.arc(px + pwid / 2, py + phei / 2, Math.min(pwid, phei) * 0.13, 0, Math.PI * 2); ctx.fill(); } } noiseOverlay(ctx, size, 0.05, 690); break; } case 'limestone': fill(ctx, size, '#ddd4c4'); for (let y = 0; y < size; y += 4) { for (let x = 0; x < size; x += 4) { const n = (fbm(x / 120, y / 120, 104) - 0.5) * 18; ctx.fillStyle = rgb(221 + n, 212 + n, 196 + n); ctx.fillRect(x, y, 4, 4); } } noiseOverlay(ctx, size, 0.06, 104); break; case 'sandstone': fill(ctx, size, '#c8b090'); noiseOverlay(ctx, size, 0.1, 105); break; case 'rough-stone': fill(ctx, size, '#7a7468'); for (let row = 0; row < 6; row++) { for (let col = 0; col < 6; col++) { const tones = ['#6a6458', '#8a8478', '#5a5448']; ctx.fillStyle = tones[(row + col) % 3]; const w = size / 6 + (row % 2 ? 6 : -4); ctx.fillRect(col * (size / 6), row * (size / 6), w, size / 6); } } noiseOverlay(ctx, size, 0.12, 106); break; case 'basalt': fill(ctx, size, '#3a3834'); noiseOverlay(ctx, size, 0.15, 107); break; case 'gothic-ashlar': { // One tile spans a full wall height: plinth, tall blind arcade, string // course, triforium gallery, cornice — so the wall reads as architecture // rather than a flat stone panel. Relief comes through the normal map. ashlarCourses(ctx, size, '#c9bda6', 210, 13); const stringCourse = (y: number, h: number) => { ctx.fillStyle = 'rgba(250,244,228,0.5)'; ctx.fillRect(0, y, size, h * 0.45); ctx.fillStyle = 'rgba(74,64,50,0.45)'; ctx.fillRect(0, y + h * 0.45, size, h * 0.55); }; // Plinth and cornice ctx.fillStyle = 'rgba(60,52,40,0.22)'; ctx.fillRect(0, size * 0.9, size, size * 0.1); stringCourse(size * 0.885, size * 0.022); stringCourse(size * 0.055, size * 0.028); stringCourse(size * 0.375, size * 0.024); // Tall blind arcade — 3 bays across the tile const bays = 3; const bayW = size / bays; for (let i = 0; i < bays; i++) { const cx = bayW * (i + 0.5); const halfW = bayW * 0.36; const springY = size * 0.66; const apexY = size * 0.43; // Recessed panel behind the arch ctx.save(); ctx.beginPath(); pointedArchPath(ctx, cx, halfW, springY, apexY); ctx.lineTo(cx + halfW, size * 0.885); ctx.lineTo(cx - halfW, size * 0.885); ctx.closePath(); ctx.clip(); ctx.fillStyle = 'rgba(52,44,34,0.34)'; ctx.fillRect(cx - halfW, apexY, halfW * 2, size); const shade = ctx.createLinearGradient(cx - halfW, 0, cx + halfW, 0); shade.addColorStop(0, 'rgba(20,16,12,0.4)'); shade.addColorStop(0.45, 'rgba(20,16,12,0)'); shade.addColorStop(1, 'rgba(20,16,12,0.28)'); ctx.fillStyle = shade; ctx.fillRect(cx - halfW, apexY, halfW * 2, size); ctx.restore(); // Arch moulding: lit outer roll, dark soffit ctx.beginPath(); pointedArchPath(ctx, cx, halfW + size * 0.016, springY, apexY - size * 0.02); ctx.strokeStyle = 'rgba(252,246,230,0.62)'; ctx.lineWidth = size * 0.016; ctx.stroke(); ctx.beginPath(); pointedArchPath(ctx, cx, halfW, springY, apexY); ctx.strokeStyle = 'rgba(58,48,36,0.5)'; ctx.lineWidth = size * 0.008; ctx.stroke(); // Colonnette shafts carrying the arch for (const sx of [cx - halfW, cx + halfW]) { ctx.fillStyle = 'rgba(250,244,228,0.5)'; ctx.fillRect(sx - size * 0.011, springY, size * 0.014, size * 0.225); ctx.fillStyle = 'rgba(58,48,36,0.42)'; ctx.fillRect(sx + size * 0.003, springY, size * 0.005, size * 0.225); // Moulded capital and base ctx.fillStyle = 'rgba(252,246,230,0.62)'; ctx.fillRect(sx - size * 0.019, springY - size * 0.016, size * 0.038, size * 0.016); ctx.fillRect(sx - size * 0.017, size * 0.868, size * 0.034, size * 0.017); } } // Triforium gallery — paired small arches per bay for (let i = 0; i < bays; i++) { const bayCx = bayW * (i + 0.5); for (const sub of [-1, 1]) { const cx = bayCx + sub * bayW * 0.19; const halfW = bayW * 0.13; const springY = size * 0.29; const apexY = size * 0.135; ctx.save(); ctx.beginPath(); pointedArchPath(ctx, cx, halfW, springY, apexY); ctx.lineTo(cx + halfW, size * 0.345); ctx.lineTo(cx - halfW, size * 0.345); ctx.closePath(); ctx.clip(); ctx.fillStyle = 'rgba(28,24,18,0.55)'; ctx.fillRect(cx - halfW, apexY, halfW * 2, size * 0.3); ctx.restore(); ctx.beginPath(); pointedArchPath(ctx, cx, halfW + size * 0.009, springY, apexY - size * 0.012); ctx.strokeStyle = 'rgba(252,246,230,0.58)'; ctx.lineWidth = size * 0.009; ctx.stroke(); for (const sx of [cx - halfW, cx + halfW]) { ctx.fillStyle = 'rgba(250,244,228,0.5)'; ctx.fillRect(sx - size * 0.006, springY, size * 0.008, size * 0.055); } } } noiseOverlay(ctx, size, 0.035, 211); break; } case 'gothic-vault': { // Quadripartite rib vault with tiercerons, seen from below: webbing // panels lift toward a gilded central boss. fill(ctx, size, '#cec4ac'); const c = size / 2; const glow = ctx.createRadialGradient(c, c, size * 0.05, c, c, size * 0.62); glow.addColorStop(0, 'rgba(255,250,236,0.55)'); glow.addColorStop(1, 'rgba(38,32,25,0.72)'); ctx.fillStyle = glow; ctx.fillRect(0, 0, size, size); for (let y = 0; y < size; y += 4) { for (let x = 0; x < size; x += 4) { const n = (fbm(x / 90, y / 90, 221) - 0.5) * 14; ctx.fillStyle = `rgba(${210 + n | 0},${200 + n | 0},${176 + n | 0},0.35)`; ctx.fillRect(x, y, 4, 4); } } const rib = (x0: number, y0: number, x1: number, y1: number, w: number) => { ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(48,40,30,0.5)'; ctx.lineWidth = w * 1.55; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.strokeStyle = 'rgba(236,228,206,0.95)'; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.strokeStyle = 'rgba(255,252,242,0.75)'; ctx.lineWidth = w * 0.32; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); }; const major = size * 0.044; const minor = size * 0.026; // Transverse and wall ribs around the bay rib(0, 0, size, 0, major); rib(0, size, size, size, major); rib(0, 0, 0, size, major); rib(size, 0, size, size, major); // Diagonal cross ribs rib(0, 0, size, size, major); rib(size, 0, 0, size, major); // Tiercerons springing to the edge midpoints rib(0, 0, c, 0, minor); rib(0, 0, 0, c, minor); rib(size, 0, c, 0, minor); rib(size, 0, size, c, minor); rib(0, size, c, size, minor); rib(0, size, 0, c, minor); rib(size, size, c, size, minor); rib(size, size, size, c, minor); // Ridge ribs rib(c, 0, c, size, minor); rib(0, c, size, c, minor); // Carved boss at the crown ctx.fillStyle = 'rgba(46,38,28,0.55)'; ctx.beginPath(); ctx.arc(c, c, size * 0.052, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c9a94e'; ctx.beginPath(); ctx.arc(c, c, size * 0.042, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,246,216,0.75)'; ctx.beginPath(); ctx.arc(c - size * 0.008, c - size * 0.008, size * 0.02, 0, Math.PI * 2); ctx.fill(); noiseOverlay(ctx, size, 0.03, 222); break; } case 'gothic-stone-floor': { // Worn limestone flags with dark diamond cabochons at the slab corners. fill(ctx, size, '#a89c86'); const n = 4; const cell = size / n; for (let row = 0; row < n; row++) { for (let col = 0; col < n; col++) { const x = col * cell; const y = row * cell; const rand = seeded(230 + row * n + col); const t = (rand() - 0.5) * 26; ctx.fillStyle = rgb(178 + t, 168 + t, 146 + t); ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4); // Footfall polish toward the slab centre const wear = ctx.createRadialGradient( x + cell / 2, y + cell / 2, cell * 0.05, x + cell / 2, y + cell / 2, cell * 0.55 ); wear.addColorStop(0, 'rgba(226,218,198,0.35)'); wear.addColorStop(1, 'rgba(226,218,198,0)'); ctx.fillStyle = wear; ctx.fillRect(x + 2, y + 2, cell - 4, cell - 4); ctx.strokeStyle = 'rgba(58,50,40,0.45)'; ctx.lineWidth = 3; ctx.strokeRect(x + 2, y + 2, cell - 4, cell - 4); } } // Dark cabochons where four slabs meet for (let row = 1; row < n; row++) { for (let col = 1; col < n; col++) { const cx = col * cell; const cy = row * cell; const r = cell * 0.085; ctx.fillStyle = '#3c3a3e'; ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy); ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r, cy); ctx.closePath(); ctx.fill(); } } noiseOverlay(ctx, size, 0.05, 231); break; } case 'stucco-cream': stuccoSurface(ctx, size, '#f0e8d8', 108); break; case 'stucco-terracotta': stuccoSurface(ctx, size, '#c89070', 109); break; case 'plaster-white': case 'white-plaster': case 'studio-white': plasterSurface(ctx, size, '#f8f8f6', 110); break; case 'plaster-warm': case 'pastel-plaster': plasterSurface(ctx, size, kind === 'pastel-plaster' ? '#ece4ec' : '#f2ebe0', 111); break; case 'dark-plaster': plasterSurface(ctx, size, '#3a3230', 112); break; case 'velvet-crimson': velvetSurface(ctx, size, '#5c1828', 114); break; case 'velvet-navy': velvetSurface(ctx, size, '#1a2848', 115); break; case 'velvet-emerald': velvetSurface(ctx, size, '#1a4030', 116); break; case 'silk-gold': fill(ctx, size, '#d8c890'); noiseOverlay(ctx, size, 0.06, 117); break; case 'silk-pale': fill(ctx, size, '#f0ece4'); noiseOverlay(ctx, size, 0.05, 118); break; case 'oak-panel': woodPanelSurface(ctx, size, '#8a6848', 119); break; case 'walnut-panel': woodPanelSurface(ctx, size, '#5a4030', 120); break; case 'dark-wood-panel': woodPanelSurface(ctx, size, '#3a2818', 121); break; case 'parquet-herringbone': case 'parquet-chevrons': { const tones = ['#6b4a2e', '#735234', '#624428', '#7a5638']; const plankW = size / 24; const plankL = size / 8; for (let row = 0; row < 16; row++) { for (let col = 0; col < 16; col++) { ctx.save(); ctx.translate(col * plankW * 1.4, row * plankW * 1.4); ctx.rotate(kind === 'parquet-chevrons' ? Math.PI / 4 : (col + row) % 2 ? Math.PI / 4 : -Math.PI / 4); ctx.fillStyle = tones[(row + col) % tones.length]; ctx.fillRect(-plankL / 2, -plankW / 2, plankL, plankW); ctx.restore(); } } noiseOverlay(ctx, size, 0.04, 119); break; } case 'terracotta-tiles': { const tile = size / 8; for (let row = 0; row < 8; row++) { for (let col = 0; col < 8; col++) { ctx.fillStyle = ['#b87050', '#c88058', '#a86048'][(row + col) % 3]; ctx.fillRect(col * tile + 2, row * tile + 2, tile - 4, tile - 4); } } break; } case 'flagstone': fill(ctx, size, '#5a5448'); for (let i = 0; i < 30; i++) { const rand = seeded(120 + i); const w = size * (0.12 + rand() * 0.15); const h = size * (0.1 + rand() * 0.12); ctx.fillStyle = ['#6a6458', '#7a7468', '#625c50'][i % 3]; ctx.fillRect(rand() * (size - w), rand() * (size - h), w, h); } break; case 'mosaic-byzantine': { // Gold-ground tessera field (gold-leaf glass, individually jittered) with // sparse inlaid accent tesserae and a meander border course — Hagia Sophia / // Ravenna style, not a flat multicolor checker. fill(ctx, size, '#1a1408'); const grid = 32; const cell = size / grid; const rand = seeded(777); const accents: [number, number, number][] = [ [32, 52, 98], [126, 30, 44], [28, 78, 58], [18, 16, 22], ]; for (let gy = 0; gy < grid; gy++) { for (let gx = 0; gx < grid; gx++) { const x = gx * cell; const y = gy * cell; const roll = rand(); let r: number, g: number, b: number; if (roll < 0.05) { [r, g, b] = accents[Math.floor(rand() * accents.length)]; } else { const j = (rand() - 0.5) * 50; r = 214 + j; g = 170 + j * 0.72; b = 58 + j * 0.35; } ctx.fillStyle = rgb(r, g, b); const pad = 1 + rand() * 1.4; ctx.fillRect(x + pad, y + pad, cell - pad * 2, cell - pad * 2); } } // Dark grout between tesserae reads as individual glass/gold tiles. ctx.strokeStyle = 'rgba(16,10,4,0.4)'; ctx.lineWidth = 1; for (let i = 0; i <= grid; i++) { ctx.beginPath(); ctx.moveTo(i * cell, 0); ctx.lineTo(i * cell, size); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * cell); ctx.lineTo(size, i * cell); ctx.stroke(); } // Meander (key-pattern) register band dividing the panel, in navy and gold. const bandRow = Math.floor(grid * 0.47); for (let gx = 0; gx < grid; gx++) { const on = Math.floor(gx / 2) % 2 === 0; ctx.fillStyle = on ? '#0f2a52' : '#e0b840'; ctx.fillRect(gx * cell, bandRow * cell, cell, cell * 0.5); } noiseOverlay(ctx, size, 0.045, 778); break; } case 'mosaic-roman': { fill(ctx, size, '#c8b898'); const cell = 20; const colors = ['#8a7060', '#a88870', '#706050', '#d0c0a0']; for (let y = 0; y < size; y += cell) { for (let x = 0; x < size; x += cell) { ctx.fillStyle = colors[(x / cell + y / cell) % colors.length]; ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2); } } break; } case 'terrazzo': fill(ctx, size, '#d8d0c4'); for (let i = 0; i < 3000; i++) { const rand = seeded(121 + i); ctx.fillStyle = ['#a89888', '#c8b8a8', '#888078'][i % 3]; ctx.beginPath(); ctx.arc(rand() * size, rand() * size, 2 + rand() * 6, 0, Math.PI * 2); ctx.fill(); } break; case 'concrete-polished': fill(ctx, size, '#c8c8c8'); noiseOverlay(ctx, size, 0.06, 122); break; case 'concrete-raw': case 'concrete-block': fill(ctx, size, '#a8a8a8'); noiseOverlay(ctx, size, 0.1, 123); if (kind === 'concrete-block') { ctx.strokeStyle = 'rgba(0,0,0,0.2)'; ctx.lineWidth = 3; for (let y = 0; y < size; y += size / 6) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(size, y); ctx.stroke(); } for (let x = 0; x < size; x += size / 4) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, size); ctx.stroke(); } } break; case 'industrial-floor': fill(ctx, size, '#888880'); noiseOverlay(ctx, size, 0.12, 124); break; case 'gilded-stucco': stuccoSurface(ctx, size, '#d8c070', 125); break; case 'fresco-worn': fill(ctx, size, '#d0c8b8'); plasterSurface(ctx, size, '#d0c8b8', 126); break; case 'brick': { const bh = size / 16; const bw = size / 8; fill(ctx, size, '#5a4030'); for (let row = 0; row < 16; row++) { const offset = row % 2 ? bw / 2 : 0; for (let col = -1; col < 9; col++) { const rand = seeded(130 + row * 9 + col); ctx.fillStyle = ['#8a5040', '#7a4838', '#9a5848'][(row + col) % 3]; const inset = 2 + rand() * 2; ctx.fillRect(col * bw + offset + inset, row * bh + inset, bw - inset * 2, bh - inset * 2); } } break; } default: fill(ctx, size, '#e8e4dc'); noiseOverlay(ctx, size, 0.05, 999); } return ctx.getImageData(0, 0, size, size); } function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): ImageData { const out = new ImageData(size, size); const src = data.data; const dst = out.data; const idx = (x: number, y: number) => (y * size + x) * 4; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const xl = src[idx(Math.max(0, x - 1), y)]; const xr = src[idx(Math.min(size - 1, x + 1), y)]; const yt = src[idx(x, Math.max(0, y - 1))]; const yb = src[idx(x, Math.min(size - 1, y + 1))]; const dx = (xr - xl) / 255; const dy = (yb - yt) / 255; let nx = -dx * strength; let ny = -dy * strength; let nz = 1; const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; nx /= len; ny /= len; nz /= len; const i = idx(x, y); dst[i] = ((nx + 1) * 0.5 * 255) | 0; dst[i + 1] = ((ny + 1) * 0.5 * 255) | 0; dst[i + 2] = ((nz + 1) * 0.5 * 255) | 0; dst[i + 3] = 255; } } return out; } function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture { const canvas = document.createElement('canvas'); canvas.width = data.width; canvas.height = data.height; canvas.getContext('2d')!.putImageData(data, 0, 0); const tex = new THREE.CanvasTexture(canvas); tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping; tex.colorSpace = colorSpace; tex.anisotropy = 8; return tex; } function normalStrengthFor(kind: SurfaceTextureKind): number { if (kind.startsWith('painted-')) return 2.2; if (kind.includes('velvet')) return 1.6; if (kind.includes('marble')) return 3.4; if (kind.includes('stucco') || kind.includes('plaster')) return 2.6; if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8; if (kind === 'brick' || kind === 'rough-stone') return 3.2; // Arcading and rib mouldings are drawn as light/dark bands — lean on the // derived normals so they read as carved relief, not painted-on lines. if (kind === 'gothic-ashlar') return 4.2; if (kind === 'gothic-vault') return 3.6; if (kind === 'gothic-stone-floor') return 2.4; return 2.5; } const ROUGHNESS: Partial> = { 'marble-white': 0.18, 'marble-veined-carrara': 0.16, 'marble-veined-emerald': 0.18, 'marble-checker': 0.14, 'marble-opus-sectile': 0.2, 'marble-revetment': 0.34, 'coffered-wood': 0.72, 'gothic-ashlar': 0.84, 'gothic-vault': 0.9, 'gothic-stone-floor': 0.56, 'gilded-stucco': 0.22, 'velvet-crimson': 0.92, 'velvet-navy': 0.92, 'velvet-emerald': 0.92, 'concrete-polished': 0.28, 'concrete-raw': 0.72, 'oak-panel': 0.55, 'walnut-panel': 0.58, 'dark-wood-panel': 0.62, 'parquet-herringbone': 0.42, 'painted-lime': 0.88, 'painted-oil-matte': 0.82, 'painted-oil-satin': 0.68, 'painted-emulsion': 0.86, 'painted-flat': 0.94, 'limestone': 0.78, 'stucco-cream': 0.8, 'stucco-terracotta': 0.78, 'plaster-white': 0.9, 'plaster-warm': 0.88, 'white-plaster': 0.92, 'studio-white': 0.94, }; const METALNESS: Partial> = { 'marble-white': 0.08, 'marble-veined-carrara': 0.1, 'marble-opus-sectile': 0.14, 'marble-revetment': 0.1, 'gilded-stucco': 0.65, 'concrete-polished': 0.12, 'terrazzo': 0.15, 'painted-oil-satin': 0.06, 'painted-flat': 0.02, }; const METERS: Partial> = { 'marble-checker': 2.4, 'marble-opus-sectile': 5.0, 'marble-revetment': 4.4, 'coffered-wood': 4.2, // Matches WALL_HEIGHT so one tile = one full storey of arcading. 'gothic-ashlar': 4.2, 'gothic-vault': 5.5, 'gothic-stone-floor': 3.2, flagstone: 3.0, 'mosaic-byzantine': 1.8, 'mosaic-roman': 2.0, 'parquet-herringbone': 1.8, 'parquet-chevrons': 1.8, 'concrete-raw': 4.0, brick: 2.5, 'painted-lime': 3.2, 'painted-oil-matte': 3.0, 'painted-oil-satin': 3.0, 'painted-emulsion': 3.5, 'painted-flat': 4.0, }; const NORMAL_SCALE: Partial> = { 'painted-flat': 0.32, 'painted-emulsion': 0.48, 'painted-oil-satin': 0.42, 'painted-oil-matte': 0.55, 'painted-lime': 0.62, 'marble-white': 0.75, 'marble-veined-carrara': 0.8, 'marble-opus-sectile': 0.55, 'marble-revetment': 0.6, 'coffered-wood': 1.0, 'gothic-ashlar': 1.0, 'gothic-vault': 0.85, 'gothic-stone-floor': 0.45, 'velvet-crimson': 0.5, 'velvet-navy': 0.5, 'velvet-emerald': 0.5, 'oak-panel': 0.7, 'brick': 0.85, 'stucco-cream': 0.65, 'plaster-warm': 0.58, }; export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet { const cached = cache.get(kind); if (cached) return cached; const colorData = paintSurface(kind, TEXTURE_SIZE); const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind)); const set: SurfaceTextureSet = { map: imageDataToTexture(colorData, THREE.SRGBColorSpace), // Normal maps must stay linear — sRGB encoding breaks lighting on walls. normalMap: imageDataToTexture(normalData, THREE.NoColorSpace), roughness: ROUGHNESS[kind] ?? 0.75, metalness: METALNESS[kind] ?? 0.04, metersPerRepeat: METERS[kind] ?? 2.8, normalScale: NORMAL_SCALE[kind] ?? 0.6, }; cache.set(kind, set); return set; } export function cloneSurfaceTexture(kind: SurfaceTextureKind, repeatW: number, repeatH: number): SurfaceTextureSet { const base = getSurfaceTexture(kind); const map = base.map.clone(); const normalMap = base.normalMap.clone(); map.repeat.set(repeatW, repeatH); normalMap.repeat.set(repeatW, repeatH); map.needsUpdate = true; normalMap.needsUpdate = true; return { ...base, map, normalMap }; }