Files
Art-gallery/client/src/components/MovementHallDetails.tsx
T
Danila KhodjaefandClaude Opus 5 f2a256132c Give every movement its own period interior.
Movement halls routed 24 of the 26 movements through 8 generic detail kinds,
four of which (modern, industrial, atelier, museum) rendered nothing at all,
and `salon` was a single ceiling ring shared by eight movements.

`details` is now a 26-value MovementDetailKind, one per movement, each built
from the architecture of the same period as the movement itself. Gothic and
Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference
implementations; the rest live under components/hall-details/, split by era,
over shared wall-flush primitives.

hall-details/geometry.ts records the budget the Gothic and Byzantine
components established, since frames hang 0.23 m proud with a 0.7 m clear
margin at each wall end: relief limits above and below the frame line, corner
pockets for freestanding masses, doorway and title-band clearance, and a cap
of two added lights per hall. Halls now pass their computed windows down so
upper-wall panels, arches and roundels step around the openings.

Also holds the pale interiors back from blowing out: gallery wall tints are
authored around 0.50-0.56 luminance instead of near-white, and the 19 halls
that read too bright take lightScale 0.30, matching the basilica.

Verified by rendering all 26 interiors offline at two hall sizes and
measuring the scene graph: nothing through walls, floor or ceiling, maximum
0.17 m intrusion into the hang zone, at most two added lights per hall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:44:05 +03:00

311 lines
12 KiB
TypeScript

import { useMemo } from 'react';
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { RoomDims } from './hall-details/geometry';
import {
CinquecentoDetails,
QuattrocentoDetails,
RomanAtriumDetails,
} from './hall-details/classical';
import {
BaroqueStateDetails,
FlemishDetails,
ManneristDetails,
NeoclassicalDetails,
RococoDetails,
} from './hall-details/courtly';
import {
ArtNouveauDetails,
BourgeoisSalonDetails,
GothicRevivalDetails,
NorthLightSalonDetails,
ParisAtelierDetails,
SymbolistDetails,
} from './hall-details/nineteenth';
import {
ConstructivistDetails,
CubistAtelierDetails,
DadaDetails,
ExpressionistDetails,
FauvistDetails,
FuturistDetails,
LoftDetails,
SuprematistDetails,
SurrealistDetails,
WhiteCubeDetails,
} from './hall-details/modernism';
interface Props {
style: MovementInteriorStyle;
width: number;
depth: number;
halfW: number;
halfD: number;
/** Runtime window placement, so upper-wall relief can step around openings. */
windows?: GalleryWindowSpec[];
}
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
// Compound piers along the side walls, spaced by bay, each rising into the
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
// never intrude on the ~0.7 m frame hang margin.
const bays = useMemo(() => {
const spacing = 4.2;
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD]);
const shaftHeight = 3.5;
return (
<group>
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (halfW - 0.09), 0, z]}>
{/* Compound pier: a heavier centre shaft flanked by colonnettes */}
<mesh position={[0, shaftHeight / 2, 0]}>
<cylinderGeometry args={[0.1, 0.115, shaftHeight, 12]} />
<meshStandardMaterial color="#a89e88" roughness={0.78} metalness={0.04} />
</mesh>
{[-0.17, 0.17].map((dz) => (
<mesh key={dz} position={[0, shaftHeight / 2, dz]}>
<cylinderGeometry args={[0.052, 0.06, shaftHeight, 10]} />
<meshStandardMaterial color="#9e9482" roughness={0.82} metalness={0.03} />
</mesh>
))}
{/* Moulded base and foliate capital */}
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.28, 0.2, 0.52]} />
<meshStandardMaterial color="#978d79" roughness={0.85} />
</mesh>
<mesh position={[0, shaftHeight + 0.12, 0]}>
<boxGeometry args={[0.3, 0.24, 0.56]} />
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</mesh>
{/* Vault springer resting on the capital */}
<mesh position={[0, shaftHeight + 0.36, 0]} rotation={[0, 0, Math.PI / 4]}>
<boxGeometry args={[0.17, 0.17, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.72} metalness={0.06} />
</mesh>
</group>
))}
{/* Transverse rib arching across the nave between opposite piers */}
<group position={[0, shaftHeight + 0.42, z]}>
{Array.from({ length: 9 }, (_, s) => {
const segs = 9;
const t0 = s / segs;
const t1 = (s + 1) / segs;
const xAt = (t: number) => -halfW + 0.09 + t * (halfW - 0.09) * 2;
const yAt = (t: number) => Math.sin(Math.PI * t) * 0.34;
const x0 = xAt(t0);
const x1 = xAt(t1);
const y0 = yAt(t0);
const y1 = yAt(t1);
const len = Math.hypot(x1 - x0, y1 - y0);
return (
<mesh
key={s}
position={[(x0 + x1) / 2, (y0 + y1) / 2, 0]}
rotation={[0, 0, Math.atan2(y1 - y0, x1 - x0)]}
>
<boxGeometry args={[len * 1.06, 0.13, 0.16]} />
<meshStandardMaterial color="#b0a58f" roughness={0.74} metalness={0.05} />
</mesh>
);
})}
</group>
</group>
))}
{/* Moulded string course running the length of both side walls */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (halfW - 0.05), 2.62, 0]}>
<boxGeometry args={[0.12, 0.1, depth - 0.2]} />
<meshStandardMaterial color="#b3a892" roughness={0.7} metalness={0.05} />
</mesh>
))}
{/* Chancel-style cornice on the end wall */}
<mesh position={[0, 3.96, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, width - 0.4), 0.16, 0.18]} />
<meshStandardMaterial color="#b0a58f" roughness={0.7} metalness={0.05} />
</mesh>
</group>
);
}
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>
);
}
/**
* Period architecture for a movement hall.
*
* Every movement has its own interior, built from the architecture of the same
* period as the movement itself. The Gothic nave and Byzantine basilica below
* are the reference implementations; everything else lives in
* `hall-details/`, sharing the geometry budget documented in
* `hall-details/primitives.tsx`.
*/
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
const trim = style.tints.trim;
const room: RoomDims = useMemo(
() => ({ width, depth, halfW, halfD }),
[width, depth, halfW, halfD]
);
const period = { room, trim, windows };
switch (style.details) {
case 'gothic':
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'roman':
return <RomanAtriumDetails {...period} />;
case 'quattrocento':
return <QuattrocentoDetails {...period} />;
case 'cinquecento':
return <CinquecentoDetails {...period} />;
case 'flemish':
return <FlemishDetails {...period} />;
case 'mannerist':
return <ManneristDetails {...period} />;
case 'baroque':
return <BaroqueStateDetails {...period} />;
case 'rococo':
return <RococoDetails {...period} />;
case 'neoclassical':
return <NeoclassicalDetails {...period} />;
case 'gothic-revival':
return <GothicRevivalDetails {...period} />;
case 'bourgeois':
return <BourgeoisSalonDetails {...period} />;
case 'north-light':
return <NorthLightSalonDetails {...period} />;
case 'paris-atelier':
return <ParisAtelierDetails {...period} />;
case 'symbolist':
return <SymbolistDetails {...period} />;
case 'art-nouveau':
return <ArtNouveauDetails {...period} />;
case 'fauvist':
return <FauvistDetails {...period} />;
case 'expressionist':
return <ExpressionistDetails {...period} />;
case 'cubist-atelier':
return <CubistAtelierDetails {...period} />;
case 'futurist':
return <FuturistDetails {...period} />;
case 'suprematist':
return <SuprematistDetails {...period} />;
case 'constructivist':
return <ConstructivistDetails {...period} />;
case 'dada':
return <DadaDetails {...period} />;
case 'surrealist':
return <SurrealistDetails {...period} />;
case 'loft':
return <LoftDetails {...period} />;
case 'white-cube':
return <WhiteCubeDetails {...period} />;
}
}