Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps. Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|