Add gothic ashlar/vault/floor textures, compound piers with transverse ribs, proper lancet arches, textured door-flanking panels, and lightScale for shaft-lit naves. Docs updated in basics and data-and-images. Co-authored-by: Cursor <cursoragent@cursor.com>
1254 lines
41 KiB
TypeScript
1254 lines
41 KiB
TypeScript
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<string, SurfaceTextureSet>();
|
|
|
|
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<Record<SurfaceTextureKind, number>> = {
|
|
'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<Record<SurfaceTextureKind, number>> = {
|
|
'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<Record<SurfaceTextureKind, number>> = {
|
|
'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<Record<SurfaceTextureKind, number>> = {
|
|
'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 };
|
|
}
|