Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget. Co-authored-by: Cursor <cursoragent@cursor.com>
254 lines
7.4 KiB
TypeScript
254 lines
7.4 KiB
TypeScript
import { useMemo } from 'react';
|
|
import * as THREE from 'three';
|
|
import type { GalleryWindowSpec, GalleryWindowStyle } from '../data/movement-interior-styles';
|
|
|
|
const WALL_HEIGHT = 4.2;
|
|
|
|
interface Props {
|
|
windows: GalleryWindowSpec[];
|
|
halfW: number;
|
|
halfD: number;
|
|
trimColor: string;
|
|
}
|
|
|
|
function windowWorldPosition(
|
|
spec: GalleryWindowSpec,
|
|
halfW: number,
|
|
halfD: number
|
|
): { position: [number, number, number]; rotation: [number, number, number] } {
|
|
const inset = 0.1;
|
|
switch (spec.wall) {
|
|
case 'back':
|
|
return {
|
|
position: [spec.x, spec.y, -halfD + inset],
|
|
rotation: [0, 0, 0],
|
|
};
|
|
case 'left':
|
|
return {
|
|
position: [-halfW + inset, spec.y, spec.x],
|
|
rotation: [0, Math.PI / 2, 0],
|
|
};
|
|
case 'right':
|
|
return {
|
|
position: [halfW - inset, spec.y, spec.x],
|
|
rotation: [0, -Math.PI / 2, 0],
|
|
};
|
|
case 'ceiling':
|
|
return {
|
|
position: [spec.x, WALL_HEIGHT - 0.06, spec.x === 0 ? 0 : spec.x],
|
|
rotation: [Math.PI / 2, 0, 0],
|
|
};
|
|
default:
|
|
return { position: [0, spec.y, -halfD + inset], rotation: [0, 0, 0] };
|
|
}
|
|
}
|
|
|
|
function WindowFrame({
|
|
style,
|
|
width,
|
|
height,
|
|
trimColor,
|
|
}: {
|
|
style: GalleryWindowStyle;
|
|
width: number;
|
|
height: number;
|
|
trimColor: string;
|
|
}) {
|
|
const frameW = 0.08;
|
|
const depth = 0.12;
|
|
|
|
const arch =
|
|
style === 'roman-arch' || style === 'gothic-lancet' || style === 'art-nouveau';
|
|
|
|
return (
|
|
<group>
|
|
{/* Outer frame */}
|
|
<mesh position={[0, 0, -depth / 2]}>
|
|
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
|
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
|
</mesh>
|
|
|
|
{arch && style === 'gothic-lancet' && (
|
|
<mesh position={[0, height / 2 + 0.15, -depth / 2 + 0.02]}>
|
|
<coneGeometry args={[width / 2 + frameW, 0.5, 4]} />
|
|
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
|
</mesh>
|
|
)}
|
|
|
|
{style === 'roman-arch' && (
|
|
<mesh position={[0, height / 2 + 0.05, -depth / 2 + 0.02]} rotation={[0, 0, Math.PI]}>
|
|
<sphereGeometry args={[width / 2, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
|
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.35} />
|
|
</mesh>
|
|
)}
|
|
|
|
{style === 'factory' && (
|
|
<>
|
|
{[-width / 3, 0, width / 3].map((ox) => (
|
|
<mesh key={ox} position={[ox, 0, -depth / 2 + 0.02]}>
|
|
<boxGeometry args={[0.06, height, depth + 0.02]} />
|
|
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
|
</mesh>
|
|
))}
|
|
{[height / 4, -height / 4].map((oy) => (
|
|
<mesh key={oy} position={[0, oy, -depth / 2 + 0.02]}>
|
|
<boxGeometry args={[width, 0.06, depth + 0.02]} />
|
|
<meshStandardMaterial color="#3a3a3a" roughness={0.5} metalness={0.6} />
|
|
</mesh>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{style === 'glass-block' &&
|
|
Array.from({ length: 4 }, (_, row) =>
|
|
Array.from({ length: 3 }, (_, col) => (
|
|
<mesh
|
|
key={`${row}-${col}`}
|
|
position={[
|
|
-width / 3 + col * (width / 3),
|
|
-height / 4 + row * (height / 4),
|
|
-depth / 2 + 0.03,
|
|
]}
|
|
>
|
|
<boxGeometry args={[width / 3 - 0.06, height / 4 - 0.06, 0.04]} />
|
|
<meshStandardMaterial
|
|
color="#d0e8f8"
|
|
roughness={0.1}
|
|
metalness={0.05}
|
|
transparent
|
|
opacity={0.75}
|
|
/>
|
|
</mesh>
|
|
))
|
|
)}
|
|
|
|
{style === 'sash' && (
|
|
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
|
<boxGeometry args={[0.05, height, depth]} />
|
|
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.3} />
|
|
</mesh>
|
|
)}
|
|
|
|
{style === 'baroque-pair' && (
|
|
<mesh position={[0, 0, -depth / 2 + 0.03]}>
|
|
<boxGeometry args={[0.06, height, depth]} />
|
|
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
function SingleWindow({
|
|
spec,
|
|
trimColor,
|
|
}: {
|
|
spec: GalleryWindowSpec;
|
|
trimColor: string;
|
|
}) {
|
|
const glassColor = useMemo(() => new THREE.Color(spec.lightColor), [spec.lightColor]);
|
|
|
|
return (
|
|
<group>
|
|
<WindowFrame style={spec.style} width={spec.width} height={spec.height} trimColor={trimColor} />
|
|
|
|
{/* Sky / daylight pane */}
|
|
<mesh position={[0, 0, 0.02]}>
|
|
<planeGeometry args={[spec.width - 0.12, spec.height - 0.12]} />
|
|
<meshStandardMaterial
|
|
color={spec.lightColor}
|
|
emissive={spec.lightColor}
|
|
emissiveIntensity={0.85}
|
|
roughness={0.05}
|
|
metalness={0.02}
|
|
toneMapped={false}
|
|
transparent
|
|
opacity={0.92}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Soft sky gradient overlay */}
|
|
<mesh position={[0, spec.height * 0.15, 0.03]}>
|
|
<planeGeometry args={[spec.width - 0.2, spec.height * 0.5]} />
|
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
|
</mesh>
|
|
|
|
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
|
|
<spotLight
|
|
position={[0, 0, 0.15]}
|
|
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
|
penumbra={0.95}
|
|
intensity={spec.lightIntensity * Math.PI * 0.85}
|
|
distance={14}
|
|
decay={0}
|
|
color={spec.lightColor}
|
|
/>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export default function GalleryWindows({ windows, halfW, halfD, trimColor }: Props) {
|
|
return (
|
|
<group>
|
|
{windows.map((spec, i) => {
|
|
const { position, rotation } = windowWorldPosition(spec, halfW, halfD);
|
|
return (
|
|
<group key={`${spec.wall}-${spec.x}-${i}`} position={position} rotation={rotation}>
|
|
<SingleWindow spec={spec} trimColor={trimColor} />
|
|
</group>
|
|
);
|
|
})}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
/** Ceiling-mounted gallery track lights for even illumination. */
|
|
export function GalleryTrackLights({
|
|
width,
|
|
depth,
|
|
intensity,
|
|
color,
|
|
}: {
|
|
width: number;
|
|
depth: number;
|
|
intensity: number;
|
|
color: string;
|
|
}) {
|
|
const positions = useMemo(() => {
|
|
const pts: [number, number, number][] = [];
|
|
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
|
|
const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
|
|
const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
|
|
for (let c = 0; c < cols; c++) {
|
|
for (let r = 0; r < rows; r++) {
|
|
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
|
const z = -depth / 2 + (depth / (rows + 1)) * (r + 1);
|
|
pts.push([x, WALL_HEIGHT - 0.15, z]);
|
|
}
|
|
}
|
|
return pts;
|
|
}, [width, depth]);
|
|
|
|
return (
|
|
<group>
|
|
{positions.map(([x, y, z], i) => (
|
|
<group key={i} position={[x, y, z]}>
|
|
<mesh rotation={[Math.PI, 0, 0]}>
|
|
<cylinderGeometry args={[0.02, 0.025, 0.12, 8]} />
|
|
<meshStandardMaterial color="#2a2a2a" metalness={0.8} roughness={0.25} />
|
|
</mesh>
|
|
<spotLight
|
|
position={[0, -0.04, 0]}
|
|
angle={0.7}
|
|
penumbra={0.9}
|
|
intensity={intensity}
|
|
distance={12}
|
|
decay={0}
|
|
color={color}
|
|
/>
|
|
</group>
|
|
))}
|
|
</group>
|
|
);
|
|
}
|