Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { useMemo } from 'react';
|
|
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
|
|
import type { RoomDims } from './hall-details/geometry';
|
|
import { bayPositions } 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(() => bayPositions(depth, halfD, 4.2, 9), [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} />;
|
|
}
|
|
}
|