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>
This commit is contained in:
Danila KhodjaefandClaude Opus 5 committed 2026-08-14 18:44:05 +03:00
1 parent 8e52415ea8
commit f2a256132c
11 files changed
+2828 -233

No files matched your search

@@ -0,0 +1,513 @@
import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
/** Wall-flush building blocks shared by the period interiors in this folder. */
interface SurfaceProps {
color: string;
roughness?: number;
metalness?: number;
emissive?: string;
emissiveIntensity?: number;
}
/** A moulding running the length of one wall, flush against its inner face. */
export function WallBand({
wall,
room,
y,
height,
proud = 0.1,
span = 1,
color,
roughness = 0.7,
metalness = 0.06,
emissive,
emissiveIntensity,
}: SurfaceProps & {
wall: WallId;
room: RoomDims;
y: number;
height: number;
proud?: number;
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] =
wall === 'back'
? [0, y, -room.halfD + off]
: wall === 'front'
? [0, y, room.halfD - off]
: wall === 'left'
? [-room.halfW + off, y, 0]
: [room.halfW - off, y, 0];
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
</mesh>
);
}
/** A moulding ring running right around the room at one height. */
export function CorniceRing({
room,
y,
height,
proud = 0.12,
walls = ['back', 'front', 'left', 'right'],
...surface
}: SurfaceProps & {
room: RoomDims;
y: number;
height: number;
proud?: number;
walls?: WallId[];
}) {
return (
<group>
{walls.map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/** String course / picture rail on the two side walls only. */
export function SideRails({
room,
y,
height,
proud = 0.1,
...surface
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
return (
<group>
{(['left', 'right'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/**
* Vertical shaft with carved flutes, read as alternating light stone and
* shadowed grooves. Sits flush: the caller places it against a wall face.
*/
export function FlutedShaft({
height,
radius,
color,
flutes = 12,
taper = 0.9,
facing = 'x',
}: {
height: number;
radius: number;
color: string;
flutes?: number;
taper?: number;
/** Wall normal axis — grooves are only cut on the exposed half. */
facing?: 'x' | 'z';
}) {
const groove = shade(color, -0.28);
const arcs = useMemo(
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
[flutes]
);
return (
<group>
<mesh position={[0, height / 2, 0]}>
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
</mesh>
{arcs.map((a, i) => {
const dx = Math.cos(a) * radius * 0.94;
const dz = Math.sin(a) * radius * 0.94;
// Skip the grooves buried in the wall behind the shaft.
if (facing === 'x' ? dx > 0 : dz > 0) return null;
return (
<mesh key={i} position={[dx, height / 2, dz]}>
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
</mesh>
);
})}
</group>
);
}
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
return (
<group position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
</mesh>
<mesh position={[0, size * 0.36, 0]}>
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
</mesh>
</group>
);
}
/** Ionic capital — abacus over a pair of spiral volutes. */
export function IonicCapital({
y,
size,
color,
axis = 'z',
}: {
y: number;
size: number;
color: string;
/** Axis the volute pair spreads along. */
axis?: 'x' | 'z';
}) {
return (
<group position={[0, y, 0]}>
<mesh position={[0, size * 0.1, 0]}>
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
</mesh>
{[-1, 1].map((s) => (
<mesh
key={s}
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
>
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
</mesh>
))}
</group>
);
}
/** Moulded plinth under a shaft. */
export function ShaftBase({ size, color }: { size: number; color: string }) {
return (
<group>
<mesh position={[0, 0.08, 0]}>
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
</mesh>
<mesh position={[0, 0.21, 0]}>
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
</mesh>
</group>
);
}
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
export function RoundArch({
span,
rise,
thickness = 0.14,
depth = 0.16,
color,
segments = 11,
roughness = 0.72,
metalness = 0.05,
}: {
span: number;
rise: number;
thickness?: number;
depth?: number;
color: string;
segments?: number;
roughness?: number;
metalness?: number;
}) {
const blocks = useMemo(() => {
const out: { x: number; y: number; len: number; rot: number }[] = [];
for (let s = 0; s < segments; s++) {
const t0 = s / segments;
const t1 = (s + 1) / segments;
const at = (t: number) => {
const a = Math.PI * (1 - t);
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
};
const p0 = at(t0);
const p1 = at(t1);
out.push({
x: (p0.x + p1.x) / 2,
y: (p0.y + p1.y) / 2,
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
});
}
return out;
}, [span, rise, segments]);
return (
<group>
{blocks.map((b, i) => (
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
<boxGeometry args={[b.len, thickness, depth]} />
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Recessed coffer grid under the ceiling plane. */
export function CofferedCeiling({
room,
y = CEILING_Y,
cell = 2.3,
panelColor,
ribColor,
ribWidth = 0.16,
ribDrop = 0.12,
rosette,
}: {
room: RoomDims;
y?: number;
cell?: number;
panelColor: string;
ribColor: string;
ribWidth?: number;
ribDrop?: number;
rosette?: string;
}) {
const grid = useMemo(() => {
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
const cw = room.width / cols;
const cd = room.depth / rows;
const cells: { x: number; z: number }[] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
}
}
return { cells, cw, cd, cols, rows };
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
return (
<group>
{grid.cells.map((c, i) => (
<group key={i} position={[c.x, y, c.z]}>
<mesh position={[0, 0.01, 0]}>
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
</mesh>
{rosette && (
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
</mesh>
)}
</group>
))}
{/* Ribs between the coffers */}
{Array.from({ length: grid.cols + 1 }, (_, c) => (
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
{Array.from({ length: grid.rows + 1 }, (_, r) => (
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
</group>
);
}
/** Exposed structural beams spanning the room. */
export function CeilingBeams({
room,
positions,
axis = 'x',
width = 0.22,
height = 0.3,
y = CEILING_Y - 0.16,
color,
roughness = 0.82,
metalness = 0.05,
}: SurfaceProps & {
room: RoomDims;
positions: number[];
/** Axis the beams run along. */
axis?: 'x' | 'z';
width?: number;
height?: number;
y?: number;
}) {
return (
<group>
{positions.map((p, i) => (
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
<boxGeometry
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
/>
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
export function CeilingRose({
y = CEILING_Y,
radius = 1.0,
color,
rings = 3,
}: {
y?: number;
radius?: number;
color: string;
rings?: number;
}) {
return (
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
{Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return (
<mesh key={i} position={[0, 0, i * 0.012]}>
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
</mesh>
);
})}
</group>
);
}
/**
* Hanging light fixture. `light` adds a real point light — keep at most two per
* hall so the shared track lighting stays inside the WebGL light budget.
*/
export function HangingFixture({
position,
dropLength = 0.6,
radius = 0.42,
arms = 6,
metalColor,
flameColor = '#ffcf80',
glowColor = '#ff9830',
light = false,
lightColor = '#ffb060',
lightIntensity = 0.8,
lightDistance = 7,
}: {
position: [number, number, number];
dropLength?: number;
radius?: number;
arms?: number;
metalColor: string;
flameColor?: string;
glowColor?: string;
light?: boolean;
lightColor?: string;
lightIntensity?: number;
lightDistance?: number;
}) {
const ringY = position[1] - dropLength;
return (
<group position={[position[0], 0, position[2]]}>
<mesh position={[0, position[1] - dropLength / 2, 0]}>
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
</mesh>
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius, 0.028, 8, 22]} />
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
</mesh>
{Array.from({ length: arms }, (_, i) => {
const a = (i / arms) * Math.PI * 2;
return (
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial
color={flameColor}
emissive={glowColor}
emissiveIntensity={0.9}
toneMapped={false}
/>
</mesh>
);
})}
{light && (
<pointLight
position={[0, ringY - 0.09, 0]}
intensity={lightIntensity}
distance={lightDistance}
color={lightColor}
/>
)}
</group>
);
}
/**
* Wall light mounted above the frame line. Emissive only — sconces are for
* period character, not for lighting the hall.
*/
export function WallSconce({
wall,
room,
along,
y = 2.86,
armColor,
glowColor = '#ffb45a',
shadeColor = '#ffdca8',
}: {
wall: 'left' | 'right' | 'back';
room: RoomDims;
/** Offset along the wall from its centre. */
along: number;
y?: number;
armColor: string;
glowColor?: string;
shadeColor?: string;
}) {
const pos: [number, number, number] =
wall === 'back'
? [along, y, -room.halfD + WALL_INNER + 0.02]
: wall === 'left'
? [-room.halfW + WALL_INNER + 0.02, y, along]
: [room.halfW - WALL_INNER - 0.02, y, along];
const rot: [number, number, number] =
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
return (
<group position={pos} rotation={rot}>
<mesh position={[0, 0, 0.03]}>
<boxGeometry args={[0.16, 0.22, 0.05]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.15, 0.13]}>
<sphereGeometry args={[0.075, 10, 10]} />
<meshStandardMaterial
color={shadeColor}
emissive={glowColor}
emissiveIntensity={0.75}
toneMapped={false}
/>
</mesh>
</group>
);
}