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>
This commit is contained in:
Danila KhodjaefandCursor committed 2026-09-15 12:02:10 +03:00
1 parent 1b760faf0d
commit 13f6f088b6
25 files changed
+1147 -318

No files matched your search

+34 -5
View File
@@ -61,11 +61,13 @@ function WindowFrame({
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>
{/* Outer frame — picture/ribbon draw their own thin reveals */}
{style !== 'picture' && style !== 'ribbon' && (
<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' && (
<>
@@ -180,6 +182,33 @@ function WindowFrame({
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
</mesh>
)}
{/* Open picture window: thin dark reveal only — no mullion / iron-bar grid. */}
{style === 'picture' && (
<>
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.2, height + frameW * 1.2, depth * 0.7]} />
<meshStandardMaterial color="#1a1a1a" roughness={0.42} metalness={0.55} />
</mesh>
{/* Single offset L-accent (collage frame), not a barred grid */}
<mesh position={[-width * 0.28, height * 0.22, -depth / 2 + 0.04]}>
<boxGeometry args={[frameW * 0.7, height * 0.42, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
<mesh position={[-width * 0.08, height * 0.38, -depth / 2 + 0.04]}>
<boxGeometry args={[width * 0.36, frameW * 0.7, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
</>
)}
{/* Open ribbon window: slim steel surround only — no bars. */}
{style === 'ribbon' && (
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.1, height + frameW * 1.1, depth * 0.65]} />
<meshStandardMaterial color="#9aa0a8" roughness={0.35} metalness={0.72} />
</mesh>
)}
</group>
);
}
@@ -1,6 +1,7 @@
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,
@@ -48,12 +49,7 @@ function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style'
// 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(() => {
const spacing = 4.2;
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD]);
const bays = useMemo(() => bayPositions(depth, halfD, 4.2, 9), [depth, halfD]);
const shaftHeight = 3.5;
+2 -2
View File
@@ -2177,8 +2177,8 @@ export default function VirtualGallery(props: Props) {
);
const movementHalls = useMemo(
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
[isWingedHall, paintings]
() => (isWingedHall ? buildAllMovementHallLayouts(paintings, interiorStyle?.details) : []),
[isWingedHall, paintings, interiorStyle?.details]
);
const [hallIndex, setHallIndex] = useState(0);
@@ -204,22 +204,22 @@ export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
* Roman palazzo — High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
* modelled on the Pantheon soffit.
* bay, a full marble entablature, panelled dado, and a plain coffered soffit —
* no gold ring in every cell.
*/
export function CinquecentoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
const marble = '#efe9dd';
const grey = shade(marble, -0.16);
const marble = '#d8d0c0';
const grey = shade(marble, -0.14);
return (
<group>
{/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.44} metalness={0.1} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.5} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.46} metalness={0.09} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.48} metalness={0.09} />
</group>
))}
@@ -235,11 +235,11 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
</group>
<mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
<meshStandardMaterial color={marble} roughness={0.48} metalness={0.08} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.1, 0.34]} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.46} metalness={0.09} />
</mesh>
</group>
))
@@ -250,21 +250,21 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.48} metalness={0.08} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.5} metalness={0.08} />
</group>
))}
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} />
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.42} metalness={0.24} />
{/* Plain coffers — larger cells, no gold rosettes */}
<CofferedCeiling
room={room}
y={4.08}
cell={2.6}
panelColor="#f2ede3"
ribColor="#ded5c4"
ribWidth={0.2}
ribDrop={0.18}
rosette={trim}
cell={3.4}
panelColor="#e6e0d4"
ribColor="#cfc4b2"
ribWidth={0.22}
ribDrop={0.16}
/>
</group>
);
+93 -57
View File
@@ -15,6 +15,7 @@ import {
useCorners,
useRepeat,
useWallClearance,
DOOR_CLEAR_HALF,
type PeriodProps,
} from './geometry';
@@ -32,11 +33,11 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34).filter((x) => Math.abs(x) > DOOR_CLEAR_HALF);
return (
<group>
{/* Wainscot ground, cap rail and skirting */}
{/* Wainscot ground, cap rail and skirting — WallBand clears the exit door */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
@@ -98,17 +99,26 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
/**
* Mannerist gallery — Mannerism.
*
* The order used against itself: herm pilasters that taper the wrong way,
* banded rustication blocking the shafts, oversized scroll consoles, and a
* cornice deliberately broken over each bay.
* The order used against itself: herm pilasters that taper the wrong way
* (narrow foot, wide shoulder), shallow banded rustication on the shaft, and a
* block capital with a broken cornice. Flush to the wall — no floating edge
* strips, no torus capitals that peel into the room.
*/
export function ManneristDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
const stone = '#ddc9ae';
const dark = shade(stone, -0.34);
const bands = [0.55, 1.55, 2.35, 3.05];
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
const stone = '#b89a78';
const dark = shade(stone, -0.28);
const band = shade(stone, -0.14);
// Inverted herm taper: foot → shoulder widths along the wall (Z).
const hermSegs = [
{ y: 1.15, h: 0.7, w: 0.18 },
{ y: 1.9, h: 0.8, w: 0.22 },
{ y: 2.7, h: 0.8, w: 0.26 },
{ y: 3.4, h: 0.6, w: 0.3 },
] as const;
const bandYs = [1.55, 2.35, 3.05];
return (
<group>
@@ -120,30 +130,36 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.9, 0]}>
<boxGeometry args={[0.09, 3.8, 0.3]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
{/* Rustication blocks interrupting the shaft */}
{bands.map((y) => (
<mesh key={y} position={[side * 0.03, y, 0]}>
<boxGeometry args={[0.14, 0.3, 0.44]} />
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<ShaftBase size={0.11} color={dark} />
{/* Stacked tapering shaft (proud ≤ 0.10 m) */}
{hermSegs.map((seg) => (
<mesh key={seg.y} position={[0, seg.y, 0]}>
<boxGeometry args={[0.1, seg.h, seg.w]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
))}
{/* Shallow rustication bands — same depth as shaft, slightly wider */}
{bandYs.map((y) => (
<mesh key={y} position={[0, y, 0]}>
<boxGeometry args={[0.105, 0.16, 0.34]} />
<meshStandardMaterial color={band} roughness={0.86} metalness={0.04} />
</mesh>
))}
{/* Block capital / console — clean abacus, no peeling torus */}
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.11, 0.14, 0.36]} />
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.06} />
</mesh>
))}
{/* Scroll console instead of a capital */}
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 4.03, 0]}>
<boxGeometry args={[0.16, 0.12, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
</mesh>
</group>
))}
<mesh position={[0, 3.92, 0]}>
<boxGeometry args={[0.12, 0.1, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.48} metalness={0.28} />
</mesh>
</group>
))}
</group>
))}
@@ -162,19 +178,19 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<sphereGeometry args={[0.17, 12, 8]} />
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 0, 0.04]}>
<torusGeometry args={[0.19, 0.03, 6, 18]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
</mesh>
</group>
))}
<group key={side} position={[side * (room.halfW - 0.06), 3.55, mid]}>
<mesh>
<boxGeometry args={[0.06, 0.28, 0.28]} />
<meshStandardMaterial color={shade(stone, 0.1)} roughness={0.7} metalness={0.06} />
</mesh>
<mesh position={[side * -0.02, 0, 0]}>
<boxGeometry args={[0.04, 0.18, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.35} />
</mesh>
</group>
))}
</group>
);
})}
@@ -186,8 +202,8 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
* Baroque state gallery — Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
* panels between the bays, a modillion cornice, and a crystal-and-gilt
* chandelier over the centre of the room.
* panels between the bays (true moulding rails, not solid gilt slabs), a
* modillion cornice, and a crystal-and-gilt chandelier over the centre.
*/
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
@@ -195,28 +211,48 @@ export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const gilt = trim;
const velvet = '#4a1220';
const modillions = useRepeat(room.depth, 0.86, 26);
return (
<group>
{/* Gilt-edged velvet field panels between the bays, clear of the windows */}
{/* Gilt-framed velvet field panels between the bays, clear of the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
const gap = bays[i + 1] - z;
const fieldW = Math.min(1.15, Math.max(0.55, gap * 0.32));
const fieldH = 0.9;
const rail = 0.07;
// Extra margin so rails never crowd / clip window openings.
const halfClear = fieldW / 2 + rail + 0.35;
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, halfClear))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
<group key={side} position={[side * (room.halfW - 0.05), 3.15, mid]}>
{/* Velvet field — faces the room */}
<mesh>
<boxGeometry args={[0.03, 1.0, w]} />
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
<boxGeometry args={[0.028, fieldH, fieldW]} />
<meshStandardMaterial color={velvet} roughness={0.92} metalness={0.03} />
</mesh>
<mesh position={[-side * 0.015, 0, 0]}>
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
{/* Gilt moulding rails around the field (not a covering slab) */}
<mesh position={[0, fieldH / 2 + rail / 2, 0]}>
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, -(fieldH / 2 + rail / 2), 0]}>
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, fieldW / 2 + rail / 2]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, -(fieldW / 2 + rail / 2)]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
</group>
))}
@@ -405,8 +441,8 @@ export function NeoclassicalDetails({ room, trim }: PeriodProps) {
room={room}
y={4.06}
cell={2.9}
panelColor="#f7f6f2"
ribColor="#e2ded4"
panelColor="#e6e2da"
ribColor="#cfc8bc"
ribWidth={0.18}
ribDrop={0.13}
/>
+11 -5
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
import { bayPositions } from '../../utils/sideColumnKeepOuts';
/**
* Shared geometry budget for every period interior.
@@ -27,6 +28,13 @@ export const WALL_INNER = 0.09;
export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02;
/**
* Half-width of the exit/entrance door clear zone on back/front walls.
* Mouldings below ~2.6 m must stop outside ±DOOR_CLEAR_HALF from wall centre.
*/
export const DOOR_CLEAR_HALF = 1.45;
/** Bands whose bottom edge is below this height are treated as crossing the door. */
export const DOOR_CLEAR_Y = 2.55;
export type WallId = 'back' | 'front' | 'left' | 'right';
@@ -57,13 +65,11 @@ export function shade(hex: string, amount: number): string {
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD, spacing, max]);
return useMemo(() => bayPositions(depth, halfD, spacing, max), [depth, halfD, spacing, max]);
}
export { bayPositions };
/**
* Evenly spaced positions centred on a wall, for the repeating small members —
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
+129 -127
View File
@@ -11,6 +11,7 @@ import {
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
@@ -409,81 +410,103 @@ export function ConstructivistDetails({ room, trim }: PeriodProps) {
}
/**
* Dada salon — Dada.
* Dada cabaret — Dada.
*
* A bourgeois room being dismantled: cornice fragments that stop and restart at
* different heights, a picture rail hung visibly out of level, and a service
* pipe left running across the wall where the moulding should be.
* Modern Cabaret Voltaire: cream gallery shell with collage panels (newsprint,
* ink black, scarlet) hung off-axis, a thin black rail, and open picture
* windows. No iron bars, no service pipe, no prison cell.
*/
export function DadaDetails({ room, trim }: PeriodProps) {
const fragments = useMemo(
export function DadaDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const ink = '#1a1a1a';
const scarlet = '#c8302a';
const newsprint = '#e8dfc8';
const mustard = '#d4a84b';
const collage = useMemo(
() =>
[
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 },
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
],
{ wall: 'left' as const, z: -0.42, y: 3.15, w: 0.9, h: 0.55, color: scarlet, tilt: 0.12 },
{ wall: 'left' as const, z: -0.08, y: 3.55, w: 1.15, h: 0.28, color: ink, tilt: -0.06 },
{ wall: 'left' as const, z: 0.35, y: 3.05, w: 0.7, h: 0.7, color: newsprint, tilt: 0.18 },
{ wall: 'right' as const, z: -0.3, y: 3.4, w: 1.0, h: 0.4, color: mustard, tilt: -0.1 },
{ wall: 'right' as const, z: 0.2, y: 2.95, w: 0.55, h: 0.85, color: ink, tilt: 0.08 },
{ wall: 'right' as const, z: 0.48, y: 3.5, w: 0.8, h: 0.32, color: scarlet, tilt: 0.14 },
{ wall: 'back' as const, z: -0.35, y: 3.55, w: 0.75, h: 0.35, color: mustard, tilt: -0.05 },
{ wall: 'back' as const, z: 0.4, y: 3.2, w: 0.5, h: 0.6, color: ink, tilt: 0.2 },
] as const,
[]
);
const plaster = '#e6dac4';
const pipe = '#6a6259';
return (
<group>
{/* Cornice, in pieces */}
{fragments.map((f, i) => (
<WallBand
key={i}
wall={f.wall}
room={room}
y={f.y}
height={0.13}
proud={f.proud}
span={f.span}
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
roughness={0.76}
metalness={0.05}
/>
))}
{/* Collage panels above the hang — off-axis, cabaret colour */}
{collage.map((p, i) => {
const along = p.z * (p.wall === 'back' ? room.halfW : room.halfD);
const clear =
p.wall === 'left' ? clearLeft : p.wall === 'right' ? clearRight : () => true;
if (!clear(along, p.w / 2)) return null;
const off = room.halfW - 0.06;
const pos: [number, number, number] =
p.wall === 'left'
? [-off, p.y, along]
: p.wall === 'right'
? [off, p.y, along]
: [along, p.y, -room.halfD + 0.06];
const rot: [number, number, number] =
p.wall === 'left' || p.wall === 'right' ? [p.tilt, 0, 0] : [0, 0, p.tilt];
return (
<mesh key={i} position={pos} rotation={rot}>
<boxGeometry
args={
p.wall === 'back'
? [p.w, p.h, 0.04]
: [0.04, p.h, p.w]
}
/>
<meshStandardMaterial color={p.color} roughness={0.72} metalness={0.04} />
</mesh>
);
})}
{/* Picture rail, visibly out of level */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
{/* Thin modern black rail — slight intentional skew */}
{([-1, 1] as const).map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.05), 2.58, 0]} rotation={[side * 0.02, 0, 0]}>
<boxGeometry args={[0.05, 0.04, room.depth * 0.82]} />
<meshStandardMaterial color={ink} roughness={0.45} metalness={0.35} />
</mesh>
))}
{/* Service pipe run, with a clumsy elbow into the ceiling */}
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
{/* Clean modern skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.08} height={0.16} proud={0.05} color={ink} roughness={0.55} metalness={0.2} />
))}
{/* Skirting that changes its mind halfway along */}
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
{/* Shadow-gap cornice instead of crumbling moulding */}
<CorniceRing room={room} y={4.05} height={0.04} proud={0.03} color="#e6e0d6" roughness={0.85} metalness={0.02} />
<CorniceRing room={room} y={4.14} height={0.06} proud={0.1} color={trim} roughness={0.5} metalness={0.25} />
<HangingFixture
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
dropLength={0.7}
radius={0.2}
arms={3}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffb45a"
light
lightColor="#ffd0a0"
lightIntensity={0.7}
lightDistance={8}
/>
{/* Geometric hanging mobile (box collage, not a pipe) */}
<group position={[room.halfW * 0.28, 3.95, room.halfD * 0.15]}>
<mesh position={[0, -0.15, 0]}>
<boxGeometry args={[0.02, 0.35, 0.02]} />
<meshStandardMaterial color={ink} roughness={0.4} metalness={0.5} />
</mesh>
<mesh position={[-0.18, -0.38, 0]} rotation={[0, 0.4, 0.15]}>
<boxGeometry args={[0.28, 0.08, 0.08]} />
<meshStandardMaterial color={scarlet} roughness={0.6} metalness={0.08} />
</mesh>
<mesh position={[0.16, -0.48, 0.05]} rotation={[0.2, -0.3, 0]}>
<boxGeometry args={[0.2, 0.2, 0.05]} />
<meshStandardMaterial color={mustard} roughness={0.65} metalness={0.06} />
</mesh>
<mesh position={[0.02, -0.62, -0.08]} rotation={[0, 0.6, 0.1]}>
<boxGeometry args={[0.14, 0.14, 0.04]} />
<meshStandardMaterial color={ink} roughness={0.55} metalness={0.2} />
</mesh>
<pointLight position={[0, -0.4, 0.2]} intensity={0.55} distance={7} color="#fff0d8" />
</group>
</group>
);
}
@@ -492,8 +515,8 @@ export function DadaDetails({ room, trim }: PeriodProps) {
* Surrealist interior — Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
* pulled across the cornice, and a door standing in a corner where no door can
* lead anywhere. The room is furnished as an image, not as a gallery.
* gathered along the cornice, and a brazier glow in one corner. The room is
* furnished as an image, not as a gallery — no freestanding corner door.
*/
export function SurrealistDetails({ room, trim }: PeriodProps) {
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
@@ -528,22 +551,6 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
))
)}
{/* A door that leads nowhere, standing in the corner pocket */}
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
<mesh position={[0, 1.05, 0]}>
<boxGeometry args={[0.72, 2.1, 0.07]} />
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
</mesh>
<mesh position={[0, 1.12, 0]}>
<boxGeometry args={[0.84, 2.24, 0.05]} />
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
</mesh>
<mesh position={[0.26, 1.02, 0.06]}>
<sphereGeometry args={[0.05, 10, 10]} />
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
</mesh>
</group>
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
@@ -566,83 +573,78 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
}
/**
* NYC loft — Abstract Expressionism.
* Painter loft — Abstract Expressionism.
*
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
* of the room. Industrial fabric left exactly as the painters found it.
* A Tenth Street studio after the painters moved in: whitewashed walls, square
* timber posts, open wood joists, and wide north-light picture windows. Warm
* and open — not cast-iron bars, not a sprinkler cage.
*/
export function LoftDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.26);
const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
const iron = '#5c5852';
const joists = useBayZ(room.depth, room.halfD, 2.0, 11);
const timber = '#8a7358';
const timberLight = '#c4a882';
return (
<group>
{/* Cast-iron columns with corbelled caps */}
{/* Square timber posts — studio structure, not iron bars */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.34, 0.2, 0.34]} />
<meshStandardMaterial color={shade(timber, -0.15)} roughness={0.82} metalness={0.04} />
</mesh>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
<boxGeometry args={[0.22, 3.7, 0.22]} />
<meshStandardMaterial color={timber} roughness={0.78} metalness={0.05} />
</mesh>
<mesh position={[0, 3.92, 0]}>
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
</mesh>
<mesh position={[0, 4.06, 0]}>
<boxGeometry args={[0.4, 0.14, 0.4]} />
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
<mesh position={[0, 3.98, 0]}>
<boxGeometry args={[0.38, 0.16, 0.38]} />
<meshStandardMaterial color={shade(timber, 0.08)} roughness={0.76} metalness={0.05} />
</mesh>
</group>
))}
{/* Pressed-tin ceiling field on close-set joists */}
{/* Open timber joists — painter studio ceiling */}
<CeilingBeams
room={room}
positions={joists}
axis="x"
width={0.08}
height={0.09}
y={4.05}
color="#b4b0a6"
roughness={0.48}
metalness={0.42}
width={0.12}
height={0.18}
y={4.0}
color={timberLight}
roughness={0.78}
metalness={0.04}
/>
<CeilingBeams
room={room}
positions={[-room.width * 0.24, 0, room.width * 0.24]}
positions={[-room.width * 0.22, room.width * 0.22]}
axis="z"
width={0.14}
height={0.16}
y={3.96}
color={iron}
roughness={0.6}
metalness={0.4}
width={0.16}
height={0.2}
y={3.88}
color={timber}
roughness={0.8}
metalness={0.04}
/>
{/* Sprinkler main with drops */}
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
</mesh>
{joists
.filter((_, i) => i % 4 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.68, z]}>
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
</mesh>
))}
{/* Steel skirting angle */}
{/* Soft wood skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
<WallBand
key={wall}
wall={wall}
room={room}
y={0.1}
height={0.2}
proud={0.06}
color={trim}
roughness={0.78}
metalness={0.05}
/>
))}
<CorniceRing room={room} y={4.12} height={0.06} proud={0.08} color={shade(timberLight, 0.12)} roughness={0.8} metalness={0.03} />
</group>
);
}
@@ -1,5 +1,14 @@
import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
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 {
@@ -32,9 +41,40 @@ export function WallBand({
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 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'
@@ -48,13 +88,7 @@ export function WallBand({
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
<meshStandardMaterial {...matProps} />
</mesh>
);
}
+60 -46
View File
@@ -11,7 +11,11 @@ export type GalleryWindowStyle =
| 'art-nouveau'
| 'glass-block'
| 'clerestory'
| 'round-oculus';
| 'round-oculus'
/** Thin frame, open glass — no mullion grid (modern / Dada cabaret). */
| 'picture'
/** Thin steel ribbon frame, open glass — Futurist / modern loft daylight. */
| 'ribbon';
export interface GalleryWindowSpec {
wall: 'back' | 'left' | 'right' | 'ceiling';
@@ -132,7 +136,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#a08868', trim: '#a89878' },
{
details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as
@@ -211,7 +215,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows',
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
{ wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
{ wall: '#89847e', ceiling: '#96948f', floor: '#a07050', trim: '#c9a227' },
{
details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -228,9 +232,9 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
31: mk(
'high-renaissance-palazzo',
'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor',
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
'Marble and stucco · calm coffered ceiling · veined stone floor',
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-veined-carrara' },
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#9a9080', trim: '#c9a227' },
{
details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -241,7 +245,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 }
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 3.2 }
),
}
),
@@ -271,7 +275,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
{ wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
{ wall: '#98816b', ceiling: '#a79456', floor: '#7a8a82', trim: '#b8860b' },
{
details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -290,7 +294,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Baroque state gallery',
'Crimson velvet · gilded stucco · grand windows',
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' },
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#8a8078', trim: '#d4af37' },
{
details: 'baroque',
titleColor: '#f0d890',
@@ -312,7 +316,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet',
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
{ wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
{ wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', trim: '#d4af37' },
{
details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
@@ -330,22 +334,22 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
36: mk(
'neoclassical-museum',
'Neoclassical museum',
'White painted walls · coffered ceiling · skylit salon',
'Cool painted walls · coffered ceiling · subdued stone floor',
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#8a8680', trim: '#a0988c' },
{
details: 'neoclassical',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a4844',
ambient: 0.65,
ambient: 0.58,
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#f4f2ec', lightIntensity: 3.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#f0eee8', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
),
trackLights: 0.7,
trackLights: 0.65,
}
),
37: mk(
@@ -388,7 +392,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', trim: '#c9a96e' },
{
details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
@@ -415,7 +419,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
),
}
@@ -443,7 +447,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor',
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#9a9084', trim: '#6a9868' },
{
details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as
@@ -471,7 +475,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
titleColor: '#402010',
ambient: 0.7,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 },
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'ribbon', lightColor: '#fff8f0', lightIntensity: 5.0 },
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
),
}
@@ -488,14 +492,14 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
ambient: 0.52,
windows: windows(
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 }
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'ribbon', lightColor: '#fff0d8', lightIntensity: 3.5 }
),
}
),
45: mk(
'cubist-studio',
'Cubist studio',
'Paris atelier · factory windows · painted plaster',
'Paris atelier · open ribbon windows · painted plaster',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{
@@ -505,7 +509,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
lightScale: 0.3,
ambient: 0.65,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 5.5 },
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
),
}
@@ -513,7 +517,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
46: mk(
'futurist-loft',
'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight',
'Steel and open glass · ribbon windows · industrial concrete',
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{
@@ -525,8 +529,9 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
ambient: 0.62,
fog: '#181818',
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
{ wall: 'back', x: 0, y: 2.65, width: 4.2, height: 2.2, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.8 },
{ wall: 'left', x: -1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
{ wall: 'left', x: 1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
),
doorWood: ['#606060', '#707070', '#505050'],
@@ -537,7 +542,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Suprematist white cube',
'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', trim: '#cccccc' },
{
details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -560,7 +565,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888888', trim: '#808080' },
{
details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -578,21 +583,26 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
49: mk(
'dada-salon',
'Dada salon',
'Eclectic bourgeois room · painted walls and exposed brick',
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
'Dada cabaret',
'Modern cabaret · collage panels · open picture windows',
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#f0ebe3', wallSide: '#e4d8c4', ceiling: '#f5f1ea', floor: '#949088', trim: '#1a1a1a' },
{
details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#1a1a1a',
ambient: 0.68,
warmLight: '#fff6ea',
sunLight: '#f4f8ff',
windows: windows(
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
{ wall: 'back', x: -1.6, y: 2.55, width: 1.5, height: 1.7, style: 'picture', lightColor: '#eef4ff', lightIntensity: 3.4 },
{ wall: 'back', x: 1.7, y: 2.7, width: 1.1, height: 1.35, style: 'picture', lightColor: '#fff0d8', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 2.5, width: 2.2, height: 1.9, style: 'picture', lightColor: '#f5f9ff', lightIntensity: 3.8 }
),
trackLights: 1.0,
doorWood: ['#2a2a2a', '#3a3a3a', '#1a1a1a'],
}
),
50: mk(
@@ -616,21 +626,25 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
51: mk(
'abstract-expressionist-loft',
'NYC loft',
'Raw brick and painted plaster · north-light factory windows',
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
'Painter loft',
'Whitewashed plaster · warm timber · open north-light picture windows',
{ wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'plaster-warm', floor: 'industrial-floor' },
{ wall: '#e8e2d6', wallSide: '#ddd5c6', ceiling: '#f0ebe2', floor: '#9a9288', trim: '#6a5848' },
{
details: 'loft',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.65,
titleColor: '#3a3028',
ambient: 0.72,
warmLight: '#fff6e8',
sunLight: '#f2f6ff',
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
{ wall: 'left', x: -1.2, y: 2.55, width: 2.4, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.5 },
{ wall: 'left', x: 1.4, y: 2.55, width: 2.2, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.2, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.2 }
),
doorWood: ['#5a4838', '#6a5848', '#4a3828'],
}
),
52: mk(
@@ -638,7 +652,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', trim: '#cccccc' },
{
details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
@@ -650,7 +664,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
fog: '#1a1a1a',
background: '#101010',
windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 },
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }
),
trackLights: 0.6,
+150 -32
View File
@@ -1,5 +1,6 @@
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import type { GalleryWindowSpec, MovementDetailKind, MovementInteriorStyle } from '../data/movement-interior-styles';
import { sideColumnKeepOutIntervals } from './sideColumnKeepOuts';
/** Target capacity per movement wing (50–60 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -69,7 +70,7 @@ function frameOuterW(w: number, reviewed: boolean) {
return w + matBorder * 2 + rail;
}
function layoutRow(paintings: Painting[], span: number) {
function layoutRow(paintings: Painting[], span: number, endPad = WALL_PADDING) {
const count = paintings.length;
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
@@ -80,7 +81,7 @@ function layoutRow(paintings: Painting[], span: number) {
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
if (i < count - 1) total += FRAME_GAP;
}
if (total <= span - WALL_PADDING) break;
if (total <= span - endPad) break;
frameW -= 0.015;
}
frameW = Math.max(MIN_FRAME_W, frameW);
@@ -93,7 +94,7 @@ function layoutRow(paintings: Painting[], span: number) {
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
cursor += outers[i] + FRAME_GAP;
}
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
return { slots, spanNeeded: Math.max(span, rowWidth + endPad) };
}
function formatYearLabel(paintings: Painting[]) {
@@ -140,31 +141,141 @@ function distributeAcrossWalls(paintings: Painting[]) {
};
}
/** Usable runs along ±halfSpan after end padding and column keep-outs. */
function sideWallGaps(halfSpan: number, keepOuts: [number, number][]): [number, number][] {
const pad = WALL_PADDING / 2;
const occupied = mergeIntervals([
[-halfSpan - 1, -halfSpan + pad],
...keepOuts,
[halfSpan - pad, halfSpan + 1],
]);
const gaps: [number, number][] = [];
let cursor = -halfSpan;
for (const [a, b] of occupied) {
if (a > cursor) gaps.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (cursor < halfSpan) gaps.push([cursor, halfSpan]);
return gaps.filter(([a, b]) => b - a >= MIN_FRAME_W + 0.08);
}
/** Split paintings across gaps in visit order (first gap gets the earliest works). */
function distributeIntoGaps(paintings: Painting[], gaps: [number, number][]): Painting[][] {
if (paintings.length === 0 || gaps.length === 0) return gaps.map(() => []);
const lengths = gaps.map(([a, b]) => b - a);
const total = lengths.reduce((s, L) => s + L, 0);
const counts = lengths.map((L) => Math.floor((paintings.length * L) / total));
let assigned = counts.reduce((s, n) => s + n, 0);
// Give leftover works to the widest gaps first.
const order = lengths.map((L, i) => ({ i, L })).sort((a, b) => b.L - a.L);
let k = 0;
while (assigned < paintings.length) {
counts[order[k % order.length].i] += 1;
assigned += 1;
k += 1;
}
// Prefer empty-avoidance: if a gap got 0 but another has many, leave as-is
// (zero-count gaps simply stay empty).
const groups: Painting[][] = [];
let cursor = 0;
for (const n of counts) {
groups.push(paintings.slice(cursor, cursor + n));
cursor += n;
}
return groups;
}
function slotsFitInGaps(paintings: Painting[], gaps: [number, number][]): boolean {
if (paintings.length === 0) return true;
if (gaps.length === 0) return false;
const groups = distributeIntoGaps(paintings, gaps);
for (let i = 0; i < gaps.length; i++) {
const group = groups[i];
if (group.length === 0) continue;
const span = gaps[i][1] - gaps[i][0];
const { spanNeeded } = layoutRow(group, span, 0);
if (spanNeeded > span + 1e-6) return false;
}
return true;
}
/** Depth large enough that side-wall hang clears bay columns for this detail kind. */
function depthForSideHang(
left: Painting[],
right: Painting[],
details: MovementDetailKind | undefined
): number {
let depth = Math.max(
MIN_HALL_SIZE,
layoutRow(left, MIN_HALL_SIZE).spanNeeded,
layoutRow(right, MIN_HALL_SIZE).spanNeeded
);
if (!sideColumnKeepOutIntervals(details, depth, depth / 2).length) return depth;
for (let guard = 0; guard < 80; guard++) {
const halfD = depth / 2;
const keepOuts = sideColumnKeepOutIntervals(details, depth, halfD);
const gaps = sideWallGaps(halfD, keepOuts);
if (slotsFitInGaps(left, gaps) && slotsFitInGaps(right, gaps)) return depth;
depth += 0.35;
}
return depth;
}
function layoutSideSlots(
paintings: Painting[],
span: number,
side: 'left' | 'right',
halfW: number,
inset: number
inset: number,
details?: MovementDetailKind
): FrameSlot[] {
if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
};
const halfD = span / 2;
const keepOuts = sideColumnKeepOutIntervals(details, span, halfD);
const mapSlot = (alongWall: number, s: { maxW: number; maxH: number }): FrameSlot => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
});
if (keepOuts.length === 0) {
const { slots: rowSlots } = layoutRow(paintings, span);
return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return mapSlot(alongWall, s);
});
}
// Visit order along each wall: left entrance→back (+Z→−Z), right back→entrance (−Z→+Z).
let gaps = sideWallGaps(halfD, keepOuts);
gaps = side === 'left' ? [...gaps].sort((a, b) => b[0] - a[0]) : [...gaps].sort((a, b) => a[0] - b[0]);
const groups = distributeIntoGaps(paintings, gaps);
const out: FrameSlot[] = [];
for (let i = 0; i < gaps.length; i++) {
const group = groups[i];
if (group.length === 0) continue;
const [g0, g1] = gaps[i];
const gapSpan = g1 - g0;
const center = (g0 + g1) / 2;
const { slots: rowSlots } = layoutRow(group, gapSpan, 0);
for (const s of rowSlots) {
// Within a gap, keep the same entrance/back orientation as the continuous row.
const alongWall = side === 'left' ? center - s.offset : center + s.offset;
out.push(mapSlot(alongWall, s));
}
}
return out;
}
/** Far wall ahead of the entrance — full span, or split across door flanks. */
@@ -225,12 +336,11 @@ function layoutBackSlots(
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
hallCount: number
hallCount: number,
details?: MovementDetailKind
): MovementHallLayout {
const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
const depth = depthForSideHang(left, right, details);
// Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit.
@@ -261,13 +371,13 @@ export function buildMovementHallLayout(
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
paintings: left,
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
slots: layoutSideSlots(left, depth, 'left', halfW, inset, details),
},
{
side: 'right',
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
paintings: right,
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
slots: layoutSideSlots(right, depth, 'right', halfW, inset, details),
},
];
@@ -283,9 +393,12 @@ export function buildMovementHallLayout(
};
}
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
export function buildAllMovementHallLayouts(
paintings: Painting[],
details?: MovementDetailKind
): MovementHallLayout[] {
const chunks = splitPaintingsIntoMovementHalls(paintings);
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length, details));
}
function mergeIntervals(intervals: [number, number][]): [number, number][] {
@@ -336,15 +449,20 @@ export function computeSideWallWindows(
const specs: GalleryWindowSpec[] = [];
const windowY = 3.15;
const minGap = tmpl.width + 0.6;
const columnKeepOuts = sideColumnKeepOutIntervals(interiorStyle.details, layout.depth, halfD);
for (const side of ['left', 'right'] as const) {
const seg = layout.segments.find((s) => s.side === side);
if (!seg) continue;
const occupied = seg.slots.map((s): [number, number] => {
const outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
});
const occupied = [
...seg.slots.map((s): [number, number] => {
const outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
}),
// Keep windows off bay columns so period modules still draw their shafts.
...columnKeepOuts,
];
const gaps = findWallGaps(occupied, halfD, minGap);
const maxWindows = Math.min(3, gaps.length);
+58
View File
@@ -0,0 +1,58 @@
import type { MovementDetailKind } from '../data/movement-interior-styles';
/**
* Even bay centres along the depth axis — same formula as historic `useBayZ`.
* Pure (no React) so hang layout can reserve the same Z as period columns.
*/
export function bayPositions(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}
export type SideColumnRhythm = {
/** Bay spacing passed to bayPositions (matches the period module). */
spacing: number;
max: number;
/**
* Half-width along the wall (Z) of the keep-out around each bay centre.
* Covers shaft/base/capital plus a small gap so frames do not kiss the column.
*/
halfClear: number;
};
/**
* Mid-wall vertical members that share the hang line on left/right walls.
* Corner-only orders are omitted — WALL_PADDING already clears the pockets.
*/
export const SIDE_WALL_COLUMN_RHYTHMS = {
gothic: { spacing: 4.2, max: 9, halfClear: 0.42 },
roman: { spacing: 4.0, max: 9, halfClear: 0.3 },
quattrocento: { spacing: 3.8, max: 9, halfClear: 0.34 },
// Paired pilasters sit at bay±0.19 with r≈0.11
cinquecento: { spacing: 5.2, max: 6, halfClear: 0.44 },
mannerist: { spacing: 4.8, max: 6, halfClear: 0.36 },
neoclassical: { spacing: 4.8, max: 6, halfClear: 0.3 },
'gothic-revival': { spacing: 3.8, max: 8, halfClear: 0.24 },
'art-nouveau': { spacing: 3.0, max: 10, halfClear: 0.22 },
} as const satisfies Partial<Record<MovementDetailKind, SideColumnRhythm>>;
export type SideColumnDetailKind = keyof typeof SIDE_WALL_COLUMN_RHYTHMS;
export function sideColumnRhythm(details: MovementDetailKind | undefined): SideColumnRhythm | null {
if (!details) return null;
return (SIDE_WALL_COLUMN_RHYTHMS as Partial<Record<MovementDetailKind, SideColumnRhythm>>)[details] ?? null;
}
/** Merged [z0, z1] intervals that side-wall paintings (and windows) must avoid. */
export function sideColumnKeepOutIntervals(
details: MovementDetailKind | undefined,
depth: number,
halfD: number
): [number, number][] {
const rhythm = sideColumnRhythm(details);
if (!rhythm) return [];
return bayPositions(depth, halfD, rhythm.spacing, rhythm.max).map(
(z) => [z - rhythm.halfClear, z + rhythm.halfClear] as [number, number]
);
}