import { useMemo } from 'react';
import {
CEILING_Y,
DOOR_CLEAR_HALF,
DOOR_CLEAR_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 thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
const matProps = {
color,
roughness,
metalness,
emissive,
emissiveIntensity,
};
// End-wall bands that sit in the door height must leave a clear gap for exits.
const crossesDoor =
!alongZ && y - height / 2 < DOOR_CLEAR_Y && room.halfW > DOOR_CLEAR_HALF + 0.35;
if (crossesDoor) {
const z = wall === 'back' ? -room.halfD + off : room.halfD - off;
const sideLen = room.halfW - DOOR_CLEAR_HALF;
const leftX = (-room.halfW - DOOR_CLEAR_HALF) / 2;
const rightX = (room.halfW + DOOR_CLEAR_HALF) / 2;
return (
);
}
const len = (alongZ ? room.depth : room.width) * span;
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 (
);
}
/** 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 (
{walls.map((wall) => (
))}
);
}
/** 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 (
{(['left', 'right'] as const).map((wall) => (
))}
);
}
/**
* 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 (
{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 (
);
})}
);
}
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
return (
);
}
/** 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 (
{[-1, 1].map((s) => (
))}
);
}
/** Moulded plinth under a shaft. */
export function ShaftBase({ size, color }: { size: number; color: string }) {
return (
);
}
/** 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 (
{blocks.map((b, i) => (
))}
);
}
/** 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 (
{grid.cells.map((c, i) => (
{rosette && (
)}
))}
{/* Ribs between the coffers */}
{Array.from({ length: grid.cols + 1 }, (_, c) => (
))}
{Array.from({ length: grid.rows + 1 }, (_, r) => (
))}
);
}
/** 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 (
{positions.map((p, i) => (
))}
);
}
/** 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 (
{Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return (
);
})}
);
}
/**
* 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 (
{Array.from({ length: arms }, (_, i) => {
const a = (i / arms) * Math.PI * 2;
return (
);
})}
{light && (
)}
);
}
/**
* 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 (
);
}