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:
Danila Khodjaef
2026-07-31 18:59:56 +03:00
co-authored by Claude Opus 5
parent 1853222e01
commit 1cb9eef935
5 changed files with 400 additions and 37 deletions
+5 -3
View File
@@ -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 | | 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) | | Wall order | Same U-shaped hang as artist halls (left → end → right) |
| Frame captions | **Year · artist** label below each frame | | 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 **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone | | 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 **126** → authored keys **2752**, 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 — 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 | | 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 | | 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`) | | 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 | | 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** | | 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 | | 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 | | 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 ~100200 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). **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 ~100200 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`). **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. **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 }) { function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the // 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. // 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} />; return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'medieval': case 'medieval':
return <MedievalDetails halfW={halfW} halfD={halfD} />; return <MedievalDetails halfW={halfW} halfD={halfD} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'neoclassical': case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />; return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical': case 'classical':
+12 -10
View File
@@ -1484,6 +1484,7 @@ function ArtistHall({
); );
const titleColor = interiorStyle?.titleColor ?? '#4a3020'; const titleColor = interiorStyle?.titleColor ?? '#4a3020';
const warmLight = interiorStyle?.warmLight ?? '#fff5e8'; const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
const hallLightScale = interiorStyle?.lightScale ?? 1;
const wallRoughness = interiorStyle ? 0.75 : 0.92; const wallRoughness = interiorStyle ? 0.75 : 0.92;
const wallMetalness = interiorStyle ? 0.08 : 0.06; const wallMetalness = interiorStyle ? 0.08 : 0.06;
@@ -1656,7 +1657,7 @@ function ArtistHall({
<GalleryTrackLights <GalleryTrackLights
width={width} width={width}
depth={depth} 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} color={interiorStyle.warmLight}
/> />
</> </>
@@ -1736,14 +1737,14 @@ function ArtistHall({
<pointLight <pointLight
position={[0, WALL_HEIGHT - 0.4, 0]} 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} distance={width + depth}
decay={0} decay={0}
color={warmLight} color={warmLight}
/> />
<pointLight <pointLight
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} 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)} distance={Math.max(12, depth * 0.75)}
decay={0} decay={0}
color={warmLight} color={warmLight}
@@ -1751,7 +1752,7 @@ function ArtistHall({
{interiorStyle && ( {interiorStyle && (
<directionalLight <directionalLight
position={[0, 6, -halfD - 2]} 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} color={interiorStyle.sunLight}
/> />
)} )}
@@ -2431,7 +2432,8 @@ export default function VirtualGallery(props: Props) {
const sceneFog = interiorStyle?.fog ?? '#0d0906'; const sceneFog = interiorStyle?.fog ?? '#0d0906';
// Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL. // Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
// three r155+ physical lights need ~π× legacy intensity for similar brightness. // 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>) => { const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return; if (!active || showExitNav || e.button !== 0) return;
@@ -2546,22 +2548,22 @@ export default function VirtualGallery(props: Props) {
<hemisphereLight <hemisphereLight
color={interiorStyle?.warmLight ?? '#fff8f0'} color={interiorStyle?.warmLight ?? '#fff8f0'}
groundColor={sceneFog} groundColor={sceneFog}
intensity={0.55 * Math.PI} intensity={0.55 * Math.PI * lightScale}
/> />
<directionalLight <directionalLight
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]} 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'} color={interiorStyle?.sunLight ?? '#fff8f0'}
/> />
<directionalLight <directionalLight
position={[-4, 6, 5]} position={[-4, 6, 5]}
intensity={0.55 * Math.PI} intensity={0.55 * Math.PI * lightScale}
color={interiorStyle?.warmLight ?? '#fff5e8'} color={interiorStyle?.warmLight ?? '#fff5e8'}
/> />
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */} {/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
<pointLight <pointLight
position={[0, 3.2, 0]} position={[0, 3.2, 0]}
intensity={2.2 * Math.PI} intensity={2.2 * Math.PI * lightScale}
distance={40} distance={40}
decay={0} decay={0}
color={interiorStyle?.warmLight ?? '#fff5e8'} color={interiorStyle?.warmLight ?? '#fff5e8'}
@@ -2573,7 +2575,7 @@ export default function VirtualGallery(props: Props) {
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment <Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'} preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.55 : 0.25} environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
/> />
</Suspense> </Suspense>
</SceneErrorBoundary> </SceneErrorBoundary>
+24 -17
View File
@@ -52,7 +52,9 @@ export interface MovementInteriorStyle {
doorWood: [string, string, string]; doorWood: [string, string, string];
windows: GalleryWindowSpec[]; windows: GalleryWindowSpec[];
trackLights: number; 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[] { function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
@@ -85,6 +87,7 @@ function mk(
doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'], doorWood: opts.doorWood ?? ['#3d2818', '#4e3624', '#261a10'],
windows: opts.windows, windows: opts.windows,
trackLights: opts.trackLights ?? 0.8, trackLights: opts.trackLights ?? 0.8,
lightScale: opts.lightScale ?? 1,
details: opts.details, details: opts.details,
}; };
} }
@@ -114,25 +117,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
), ),
28: mk( 28: mk(
'byzantine-sanctuary', 'byzantine-sanctuary',
'Byzantine chapel', 'Byzantine basilica',
'Gold mosaic walls · amber light through arched windows', 'Marble revetment · coffered timber ceiling · Cosmatesque stone floor',
{ wall: 'mosaic-byzantine', ceiling: 'gilded-stucco', floor: 'marble-checker' }, { wall: 'marble-revetment', ceiling: 'coffered-wood', floor: 'marble-opus-sectile' },
{ wall: '#d4af37', ceiling: '#c8a840', floor: '#ddd8cc', trim: '#b8860b' }, { wall: '#a89880', ceiling: '#6a4526', floor: '#a09079', trim: '#8a6440' },
{ {
details: 'medieval', details: 'byzantine',
titleColor: '#f0e8c0', titleColor: '#e8d8b0',
ambient: 0.48, ambient: 0.44,
warmLight: '#ffd898', warmLight: '#ffdca8',
sunLight: '#ffe8b0', sunLight: '#ffdca0',
fog: '#0a0806', fog: '#100b07',
background: '#060504', background: '#0a0705',
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 2.4 }, { wall: 'back', x: 0, y: 3.1, width: 0.9, height: 1.9, style: 'roman-arch', lightColor: '#ffd28a', lightIntensity: 3.4 },
{ wall: 'left', x: -1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 }, { 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: 1, y: 2.6, width: 0.7, height: 1.8, style: 'roman-arch', lightColor: '#ffb860', lightIntensity: 1.8 } { 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, trackLights: 0.35,
doorWood: ['#3a3020', '#4a3830', '#2a2018'], // Basilica interiors are lit by shafts from high windows, not evenly flooded.
lightScale: 0.3,
doorWood: ['#3a2416', '#4c3220', '#241608'],
} }
), ),
29: mk( 29: mk(
+257 -7
View File
@@ -8,6 +8,9 @@ export type SurfaceTextureKind =
| 'marble-veined-carrara' | 'marble-veined-carrara'
| 'marble-veined-emerald' | 'marble-veined-emerald'
| 'marble-checker' | 'marble-checker'
| 'marble-opus-sectile'
| 'marble-revetment'
| 'coffered-wood'
| 'limestone' | 'limestone'
| 'sandstone' | 'sandstone'
| 'rough-stone' | 'rough-stone'
@@ -244,6 +247,43 @@ function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string,
noiseOverlay(ctx, size, 0.035, seed + 1); 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( function woodPanelSurface(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
size: number, size: number,
@@ -346,6 +386,161 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.03, 103); noiseOverlay(ctx, size, 0.03, 103);
break; 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': case 'limestone':
fill(ctx, size, '#ddd4c4'); fill(ctx, size, '#ddd4c4');
for (let y = 0; y < size; y += 4) { for (let y = 0; y < size; y += 4) {
@@ -460,15 +655,59 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
} }
break; break;
case 'mosaic-byzantine': { case 'mosaic-byzantine': {
fill(ctx, size, '#1a1814'); // Gold-ground tessera field (gold-leaf glass, individually jittered) with
const cell = size / 32; // sparse inlaid accent tesserae and a meander border course — Hagia Sophia /
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040']; // Ravenna style, not a flat multicolor checker.
for (let y = 0; y < size; y += cell) { fill(ctx, size, '#1a1408');
for (let x = 0; x < size; x += cell) { const grid = 32;
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length]; const cell = size / grid;
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2); 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; break;
} }
case 'mosaic-roman': { case 'mosaic-roman': {
@@ -611,6 +850,9 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-veined-carrara': 0.16, 'marble-veined-carrara': 0.16,
'marble-veined-emerald': 0.18, 'marble-veined-emerald': 0.18,
'marble-checker': 0.14, 'marble-checker': 0.14,
'marble-opus-sectile': 0.2,
'marble-revetment': 0.34,
'coffered-wood': 0.72,
'gilded-stucco': 0.22, 'gilded-stucco': 0.22,
'velvet-crimson': 0.92, 'velvet-crimson': 0.92,
'velvet-navy': 0.92, 'velvet-navy': 0.92,
@@ -638,6 +880,8 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = { const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.08, 'marble-white': 0.08,
'marble-veined-carrara': 0.1, 'marble-veined-carrara': 0.1,
'marble-opus-sectile': 0.14,
'marble-revetment': 0.1,
'gilded-stucco': 0.65, 'gilded-stucco': 0.65,
'concrete-polished': 0.12, 'concrete-polished': 0.12,
'terrazzo': 0.15, 'terrazzo': 0.15,
@@ -647,6 +891,9 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
const METERS: Partial<Record<SurfaceTextureKind, number>> = { const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4, 'marble-checker': 2.4,
'marble-opus-sectile': 5.0,
'marble-revetment': 4.4,
'coffered-wood': 4.2,
flagstone: 3.0, flagstone: 3.0,
'mosaic-byzantine': 1.8, 'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0, 'mosaic-roman': 2.0,
@@ -669,6 +916,9 @@ const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'painted-lime': 0.62, 'painted-lime': 0.62,
'marble-white': 0.75, 'marble-white': 0.75,
'marble-veined-carrara': 0.8, 'marble-veined-carrara': 0.8,
'marble-opus-sectile': 0.55,
'marble-revetment': 0.6,
'coffered-wood': 1.0,
'velvet-crimson': 0.5, 'velvet-crimson': 0.5,
'velvet-navy': 0.5, 'velvet-navy': 0.5,
'velvet-emerald': 0.5, 'velvet-emerald': 0.5,