Files
Art-gallery/client/src/components/hall-details/primitives.tsx
T
Danila KhodjaefandCursor 13f6f088b6 Polish movement hall interiors: clear doors and bay columns, tone floors and windows.
Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:10 +03:00

548 lines
15 KiB
TypeScript

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 (
<group>
<mesh position={[leftX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
<mesh position={[rightX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
</group>
);
}
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 (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial {...matProps} />
</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>
);
}