Rework Byzantine hall as a late-antique basilica interior.
Replace the flat gold-mosaic chapel with marble revetment walls, a Cosmatesque stone floor, and a coffered timber ceiling, matching Santa Sabina / San Vitale reference interiors. New procedural surfaces in galleryProceduralTextures.ts: - marble-revetment: book-matched veined panels in moulded travertine surrounds (each panel mirrored about its centre) - marble-opus-sectile: cut-stone bays with diagonal diamond inlays and corner triangles in porphyry, verd-antique, giallo, and grey - coffered-wood: recessed walnut coffers with lit beam chamfers Add a byzantine detail set to MovementHallDetails.tsx: engaged porphyry colonnettes with white-marble basket capitals and impost blocks carrying the ceiling, a marble revetment dado, and hanging brass polycandela. Colonnettes stay flush in the corner pockets so they do not clip the ~0.7m frame hang margin. Basilicas are lit by shafts from high windows rather than evenly flooded, but the shared hall lights carry a brightness floor that prevented dimming. Add an opt-in lightScale to the interior style that scales the scene, hall, track, and HDR environment lights. It defaults to 1, so all other halls are unchanged; only Byzantine opts down. Window fill lights are left unscaled so the daylight shafts still read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1853222e01
commit
1cb9eef935
@@ -335,12 +335,12 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
|
||||
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
|
||||
| Wall order | Same U-shaped hang as artist halls (left → end → right) |
|
||||
| Frame captions | **Year · artist** label below each frame |
|
||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
|
||||
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
|
||||
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
|
||||
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) |
|
||||
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
|
||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames) |
|
||||
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela |
|
||||
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
|
||||
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
|
||||
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
|
||||
@@ -376,6 +376,8 @@ Full guide: [tours.md](tours.md).
|
||||
|
||||
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
|
||||
|
||||
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
|
||||
|
||||
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
|
||||
|
||||
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
|
||||
|
||||
@@ -139,6 +139,106 @@ function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
|
||||
);
|
||||
}
|
||||
|
||||
function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||
// Engaged porphyry colonnettes with Byzantine basket/impost capitals at the
|
||||
// corners (flush — never proud of the ~0.7m frame hang margin), a marble
|
||||
// revetment dado at spring-line height, and hanging brass polycandela in
|
||||
// place of wall torches (a Byzantine basilica hangs its light, not sconces it).
|
||||
const colonnettes = useMemo(
|
||||
() =>
|
||||
[
|
||||
[-halfW + 0.08, -halfD + 0.08],
|
||||
[halfW - 0.08, -halfD + 0.08],
|
||||
[-halfW + 0.08, halfD - 0.08],
|
||||
[halfW - 0.08, halfD - 0.08],
|
||||
] as [number, number][],
|
||||
[halfW, halfD]
|
||||
);
|
||||
|
||||
const lampPositions = useMemo(
|
||||
() =>
|
||||
[
|
||||
[0, -halfD * 0.32],
|
||||
[0, halfD * 0.18],
|
||||
] as [number, number][],
|
||||
[halfD]
|
||||
);
|
||||
|
||||
const dadoW = Math.max(0, halfW * 2 - 0.3);
|
||||
const dadoD = Math.max(0, halfD * 2 - 0.3);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Marble revetment dado — imperial banding course at chair-rail height */}
|
||||
{[
|
||||
[0, -halfD + 0.07, dadoW, 0.1] as const,
|
||||
[0, halfD - 0.07, dadoW, 0.1] as const,
|
||||
[-halfW + 0.07, 0, 0.1, dadoD] as const,
|
||||
[halfW - 0.07, 0, 0.1, dadoD] as const,
|
||||
].map(([x, z, w, d], i) => (
|
||||
<group key={i}>
|
||||
<mesh position={[x, 0.62, z]}>
|
||||
<boxGeometry args={[w, 1.24, d]} />
|
||||
<meshStandardMaterial color="#6d5644" roughness={0.34} metalness={0.12} />
|
||||
</mesh>
|
||||
<mesh position={[x, 1.28, z]}>
|
||||
<boxGeometry args={[w * 1.004, 0.09, d * 1.004]} />
|
||||
<meshStandardMaterial color="#d8c8a8" roughness={0.42} metalness={0.1} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Engaged colonnettes, basket capital, and impost block */}
|
||||
{colonnettes.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
{/* Polished porphyry shaft — the imperial stone of the reference basilicas */}
|
||||
<mesh position={[0, 1.95, 0]}>
|
||||
<cylinderGeometry args={[0.14, 0.16, 3.4, 14]} />
|
||||
<meshStandardMaterial color="#43222a" roughness={0.32} metalness={0.16} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.12, 0]}>
|
||||
<cylinderGeometry args={[0.17, 0.19, 0.24, 14]} />
|
||||
<meshStandardMaterial color="#d8cdb4" roughness={0.5} metalness={0.08} />
|
||||
</mesh>
|
||||
{/* Basket capital in white marble, then an impost block carrying the ceiling */}
|
||||
<mesh position={[0, 3.8, 0]}>
|
||||
<cylinderGeometry args={[0.22, 0.11, 0.3, 8]} />
|
||||
<meshStandardMaterial color="#e8e0cc" roughness={0.44} metalness={0.06} />
|
||||
</mesh>
|
||||
<mesh position={[0, 4.05, 0]}>
|
||||
<boxGeometry args={[0.28, 0.22, 0.28]} />
|
||||
<meshStandardMaterial color="#ded4bc" roughness={0.5} metalness={0.05} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Hanging polycandela — brass ring lamps suspended from the vault */}
|
||||
{lampPositions.map(([x, z], i) => (
|
||||
<group key={i} position={[x, 0, z]}>
|
||||
<mesh position={[0, 4.1, 0]}>
|
||||
<cylinderGeometry args={[0.012, 0.012, 0.5, 6]} />
|
||||
<meshStandardMaterial color="#5a4a20" roughness={0.4} metalness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0, 3.84, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.32, 0.025, 8, 20]} />
|
||||
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.8} emissive="#3a2808" emissiveIntensity={0.1} />
|
||||
</mesh>
|
||||
{Array.from({ length: 6 }, (_, j) => {
|
||||
const a = (j / 6) * Math.PI * 2;
|
||||
return (
|
||||
<mesh key={j} position={[Math.cos(a) * 0.32, 3.76, Math.sin(a) * 0.32]}>
|
||||
<sphereGeometry args={[0.045, 8, 8]} />
|
||||
<meshStandardMaterial color="#ffcf80" emissive="#ff9830" emissiveIntensity={0.9} toneMapped={false} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
<pointLight position={[0, 3.76, 0]} intensity={0.85} distance={6} color="#ffb060" />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
|
||||
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
||||
@@ -226,6 +326,8 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
|
||||
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'medieval':
|
||||
return <MedievalDetails halfW={halfW} halfD={halfD} />;
|
||||
case 'byzantine':
|
||||
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'neoclassical':
|
||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||
case 'classical':
|
||||
|
||||
@@ -1484,6 +1484,7 @@ function ArtistHall({
|
||||
);
|
||||
const titleColor = interiorStyle?.titleColor ?? '#4a3020';
|
||||
const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
|
||||
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
||||
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
||||
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
||||
|
||||
@@ -1656,7 +1657,7 @@ function ArtistHall({
|
||||
<GalleryTrackLights
|
||||
width={width}
|
||||
depth={depth}
|
||||
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI}
|
||||
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI * interiorStyle.lightScale}
|
||||
color={interiorStyle.warmLight}
|
||||
/>
|
||||
</>
|
||||
@@ -1736,14 +1737,14 @@ function ArtistHall({
|
||||
|
||||
<pointLight
|
||||
position={[0, WALL_HEIGHT - 0.4, 0]}
|
||||
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI}
|
||||
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI * hallLightScale}
|
||||
distance={width + depth}
|
||||
decay={0}
|
||||
color={warmLight}
|
||||
/>
|
||||
<pointLight
|
||||
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]}
|
||||
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI}
|
||||
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI * hallLightScale}
|
||||
distance={Math.max(12, depth * 0.75)}
|
||||
decay={0}
|
||||
color={warmLight}
|
||||
@@ -1751,7 +1752,7 @@ function ArtistHall({
|
||||
{interiorStyle && (
|
||||
<directionalLight
|
||||
position={[0, 6, -halfD - 2]}
|
||||
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI}
|
||||
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI * hallLightScale}
|
||||
color={interiorStyle.sunLight}
|
||||
/>
|
||||
)}
|
||||
@@ -2431,7 +2432,8 @@ export default function VirtualGallery(props: Props) {
|
||||
const sceneFog = interiorStyle?.fog ?? '#0d0906';
|
||||
// Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
|
||||
// three r155+ physical lights need ~π× legacy intensity for similar brightness.
|
||||
const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI;
|
||||
const lightScale = interiorStyle?.lightScale ?? 1;
|
||||
const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI * lightScale;
|
||||
|
||||
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!active || showExitNav || e.button !== 0) return;
|
||||
@@ -2546,22 +2548,22 @@ export default function VirtualGallery(props: Props) {
|
||||
<hemisphereLight
|
||||
color={interiorStyle?.warmLight ?? '#fff8f0'}
|
||||
groundColor={sceneFog}
|
||||
intensity={0.55 * Math.PI}
|
||||
intensity={0.55 * Math.PI * lightScale}
|
||||
/>
|
||||
<directionalLight
|
||||
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
||||
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI}
|
||||
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI * lightScale}
|
||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||
/>
|
||||
<directionalLight
|
||||
position={[-4, 6, 5]}
|
||||
intensity={0.55 * Math.PI}
|
||||
intensity={0.55 * Math.PI * lightScale}
|
||||
color={interiorStyle?.warmLight ?? '#fff5e8'}
|
||||
/>
|
||||
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
|
||||
<pointLight
|
||||
position={[0, 3.2, 0]}
|
||||
intensity={2.2 * Math.PI}
|
||||
intensity={2.2 * Math.PI * lightScale}
|
||||
distance={40}
|
||||
decay={0}
|
||||
color={interiorStyle?.warmLight ?? '#fff5e8'}
|
||||
@@ -2573,7 +2575,7 @@ export default function VirtualGallery(props: Props) {
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.55 : 0.25}
|
||||
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||||
/>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
|
||||
@@ -52,7 +52,9 @@ export interface MovementInteriorStyle {
|
||||
doorWood: [string, string, string];
|
||||
windows: GalleryWindowSpec[];
|
||||
trackLights: number;
|
||||
details: 'palazzo' | 'baroque' | 'medieval' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
|
||||
lightScale: number;
|
||||
details: 'palazzo' | 'baroque' | 'medieval' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
|
||||
}
|
||||
|
||||
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
|
||||
@@ -85,6 +87,7 @@ function mk(
|
||||
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
|
||||
windows: opts.windows,
|
||||
trackLights: opts.trackLights ?? 0.8,
|
||||
lightScale: opts.lightScale ?? 1,
|
||||
details: opts.details,
|
||||
};
|
||||
}
|
||||
@@ -114,25 +117,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
||||
),
|
||||
28: mk(
|
||||
'byzantine-sanctuary',
|
||||
'Byzantine chapel',
|
||||
'Gold mosaic walls · amber light through arched windows',
|
||||
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' },
|
||||
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' },
|
||||
'Byzantine basilica',
|
||||
'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
|
||||
{ wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
|
||||
{ wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
|
||||
{
|
||||
details: 'medieval',
|
||||
titleColor: '#f0e8c0',
|
||||
ambient: 0.48,
|
||||
warmLight: '#ffd898',
|
||||
sunLight: '#ffe8b0',
|
||||
fog: '#0a0806',
|
||||
background: '#060504',
|
||||
details: 'byzantine',
|
||||
titleColor: '#e8d8b0',
|
||||
ambient: 0.44,
|
||||
warmLight: '#ffdca8',
|
||||
sunLight: '#ffdca0',
|
||||
fog: '#100b07',
|
||||
background: '#0a0705',
|
||||
windows: windows(
|
||||
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 },
|
||||
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 },
|
||||
{ wall: 'right', x: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }
|
||||
{ wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
|
||||
{ wall: 'left', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
|
||||
{ wall: 'left', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
|
||||
{ wall: 'right', x: -3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 },
|
||||
{ wall: 'right', x: 3.5, y: 3.2, width: 0.7, height: 1.6, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.0 }
|
||||
),
|
||||
trackLights: 0.5,
|
||||
doorWood: ['#3a3020', '#4a3830', '#2a2018'],
|
||||
trackLights: 0.35,
|
||||
// Basilica interiors are lit by shafts from high windows, not evenly flooded.
|
||||
lightScale: 0.3,
|
||||
doorWood: ['#3a2416', '#4c3220', '#241608'],
|
||||
}
|
||||
),
|
||||
29: mk(
|
||||
|
||||
@@ -8,6 +8,9 @@ export type SurfaceTextureKind =
|
||||
| 'marble-veined-carrara'
|
||||
| 'marble-veined-emerald'
|
||||
| 'marble-checker'
|
||||
| 'marble-opus-sectile'
|
||||
| 'marble-revetment'
|
||||
| 'coffered-wood'
|
||||
| 'limestone'
|
||||
| 'sandstone'
|
||||
| 'rough-stone'
|
||||
@@ -244,6 +247,43 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
|
||||
noiseOverlay(ctx, size, 0.035, 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,
|
||||
@@ -346,6 +386,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
||||
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) {
|
||||
@@ -460,15 +655,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
|
||||
}
|
||||
break;
|
||||
case 'mosaic-byzantine': {
|
||||
fill(ctx, size, '#1a1814');
|
||||
const cell = size / 32;
|
||||
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
|
||||
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);
|
||||
// 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': {
|
||||
@@ -611,6 +850,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'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,
|
||||
'gilded-stucco': 0.22,
|
||||
'velvet-crimson': 0.92,
|
||||
'velvet-navy': 0.92,
|
||||
@@ -638,6 +880,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
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,
|
||||
@@ -647,6 +891,9 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
|
||||
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'marble-checker': 2.4,
|
||||
'marble-opus-sectile': 5.0,
|
||||
'marble-revetment': 4.4,
|
||||
'coffered-wood': 4.2,
|
||||
flagstone: 3.0,
|
||||
'mosaic-byzantine': 1.8,
|
||||
'mosaic-roman': 2.0,
|
||||
@@ -669,6 +916,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
|
||||
'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,
|
||||
'velvet-crimson': 0.5,
|
||||
'velvet-navy': 0.5,
|
||||
'velvet-emerald': 0.5,
|
||||
|
||||
Reference in New Issue
Block a user