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>
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import type { Painting } from '../types';
|
||
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
|
||
|
||
/** Target capacity per movement wing (50–60 works). */
|
||
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
|
||
|
||
export type WallSide = 'back' | 'left' | 'right';
|
||
|
||
export interface FrameSlot {
|
||
position: [number, number, number];
|
||
rotationY: number;
|
||
maxW: number;
|
||
maxH: number;
|
||
side: WallSide;
|
||
}
|
||
|
||
export interface WallSegment {
|
||
side: WallSide;
|
||
label: string;
|
||
paintings: Painting[];
|
||
slots: FrameSlot[];
|
||
}
|
||
|
||
export interface MovementHallLayout {
|
||
hallIndex: number;
|
||
hallCount: number;
|
||
width: number;
|
||
depth: number;
|
||
segments: WallSegment[];
|
||
paintingCount: number;
|
||
yearLabel: string;
|
||
/** When true, far (−Z) wall has a center exit; paintings hang on flanks only. */
|
||
endWallHasDoor: boolean;
|
||
}
|
||
|
||
const WALL_HEIGHT = 4.2;
|
||
const WALL_THICKNESS = 0.18;
|
||
const MOUNT_OFFSET = 0.16;
|
||
const WALL_STANDOFF = 0.07;
|
||
const EYE_HEIGHT = 1.65;
|
||
const FRAME_GAP = 0.32;
|
||
const MIN_FRAME_W = 0.45;
|
||
const MAX_FRAME_W = 1.05;
|
||
const MAX_FRAME_H = 1.35;
|
||
const MIN_HALL_SIZE = 10;
|
||
const MIN_HALL_WIDTH = 11;
|
||
const WALL_PADDING = 1.4;
|
||
/** Exit opening on the far wall — paintings hang on the flanking panels only. */
|
||
const DOOR_WIDTH = 2.4;
|
||
const DOOR_CLEARANCE = DOOR_WIDTH + 0.55;
|
||
|
||
const FRAME_MAT_BORDER = 0.1;
|
||
const FRAME_RAIL = 0.08;
|
||
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
||
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||
|
||
function paintingIsReviewed(p: Painting) {
|
||
return !!p.checkup_checked;
|
||
}
|
||
|
||
function frameDims(reviewed: boolean) {
|
||
return reviewed
|
||
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL }
|
||
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL };
|
||
}
|
||
|
||
function frameOuterW(w: number, reviewed: boolean) {
|
||
const { matBorder, rail } = frameDims(reviewed);
|
||
return w + matBorder * 2 + rail;
|
||
}
|
||
|
||
function layoutRow(paintings: Painting[], span: number) {
|
||
const count = paintings.length;
|
||
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
|
||
|
||
let frameW = MAX_FRAME_W;
|
||
for (let attempt = 0; attempt < 40; attempt++) {
|
||
let total = 0;
|
||
for (let i = 0; i < count; i++) {
|
||
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
|
||
if (i < count - 1) total += FRAME_GAP;
|
||
}
|
||
if (total <= span - WALL_PADDING) break;
|
||
frameW -= 0.015;
|
||
}
|
||
frameW = Math.max(MIN_FRAME_W, frameW);
|
||
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
|
||
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
|
||
const rowWidth = outers.reduce((s, w) => s + w, 0) + (count - 1) * FRAME_GAP;
|
||
const slots: { offset: number; maxW: number; maxH: number }[] = [];
|
||
let cursor = -rowWidth / 2;
|
||
for (let i = 0; i < count; i++) {
|
||
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) };
|
||
}
|
||
|
||
function formatYearLabel(paintings: Painting[]) {
|
||
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
|
||
if (years.length === 0) return 'Undated works';
|
||
const min = Math.min(...years);
|
||
const max = Math.max(...years);
|
||
return min === max ? `${min}` : `${min} – ${max}`;
|
||
}
|
||
|
||
/** Preserve caller order (chrono for movements, stop order for tours). */
|
||
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
|
||
if (paintings.length === 0) return [[]];
|
||
const chunks: Painting[][] = [];
|
||
for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
|
||
chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
/**
|
||
* U-shaped visit order: left → far/end wall → right.
|
||
* Callers pass paintings already in visit order; first work hangs near the
|
||
* entrance on the left, last work near the entrance on the right.
|
||
*/
|
||
function distributeAcrossWalls(paintings: Painting[]) {
|
||
const n = paintings.length;
|
||
if (n < 3) {
|
||
const mid = Math.ceil(n / 2);
|
||
return {
|
||
back: [] as Painting[],
|
||
left: paintings.slice(0, mid),
|
||
right: paintings.slice(mid),
|
||
};
|
||
}
|
||
const q = Math.floor(n / 3);
|
||
const r = n % 3;
|
||
const leftCount = q + (r > 0 ? 1 : 0);
|
||
const backCount = q + (r > 1 ? 1 : 0);
|
||
return {
|
||
left: paintings.slice(0, leftCount),
|
||
back: paintings.slice(leftCount, leftCount + backCount),
|
||
right: paintings.slice(leftCount + backCount),
|
||
};
|
||
}
|
||
|
||
function layoutSideSlots(
|
||
paintings: Painting[],
|
||
span: number,
|
||
side: 'left' | 'right',
|
||
halfW: number,
|
||
inset: number
|
||
): 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]),
|
||
};
|
||
});
|
||
}
|
||
|
||
/** Far wall ahead of the entrance — full span, or split across door flanks. */
|
||
function layoutBackSlots(
|
||
paintings: Painting[],
|
||
width: number,
|
||
halfD: number,
|
||
inset: number,
|
||
hasDoor: boolean
|
||
): FrameSlot[] {
|
||
if (paintings.length === 0) return [];
|
||
const y = EYE_HEIGHT;
|
||
const z = -halfD + inset + WALL_STANDOFF;
|
||
|
||
if (!hasDoor) {
|
||
const { slots: rowSlots } = layoutRow(paintings, width);
|
||
return rowSlots.map((s) => ({
|
||
maxW: s.maxW,
|
||
maxH: s.maxH,
|
||
rotationY: 0,
|
||
side: 'back' as const,
|
||
position: [s.offset, y, z] as [number, number, number],
|
||
}));
|
||
}
|
||
|
||
const flankSpan = Math.max(MIN_FRAME_W + WALL_PADDING, (width - DOOR_CLEARANCE) / 2);
|
||
const mid = Math.ceil(paintings.length / 2);
|
||
const leftFlank = paintings.slice(0, mid);
|
||
const rightFlank = paintings.slice(mid);
|
||
const leftCenterX = -DOOR_CLEARANCE / 2 - flankSpan / 2;
|
||
const rightCenterX = DOOR_CLEARANCE / 2 + flankSpan / 2;
|
||
const doorEdge = DOOR_CLEARANCE / 2 + 0.08;
|
||
|
||
const mapFlank = (group: Painting[], centerX: number, side: 'left' | 'right'): FrameSlot[] => {
|
||
if (group.length === 0) return [];
|
||
const { slots: rowSlots } = layoutRow(group, flankSpan);
|
||
return rowSlots.map((s) => {
|
||
let x = centerX + s.offset;
|
||
const halfOuter = frameOuterW(s.maxW, false) / 2;
|
||
if (side === 'left' && x + halfOuter > -doorEdge) {
|
||
x = -doorEdge - halfOuter;
|
||
} else if (side === 'right' && x - halfOuter < doorEdge) {
|
||
x = doorEdge + halfOuter;
|
||
}
|
||
return {
|
||
maxW: s.maxW,
|
||
maxH: s.maxH,
|
||
rotationY: 0,
|
||
side: 'back' as const,
|
||
position: [x, y, z] as [number, number, number],
|
||
};
|
||
});
|
||
};
|
||
|
||
return [...mapFlank(leftFlank, leftCenterX, 'left'), ...mapFlank(rightFlank, rightCenterX, 'right')];
|
||
}
|
||
|
||
export function buildMovementHallLayout(
|
||
paintings: Painting[],
|
||
hallIndex: number,
|
||
hallCount: number
|
||
): 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);
|
||
|
||
// 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.
|
||
const endWallHasDoor = hallCount > 1;
|
||
|
||
let width: number;
|
||
if (endWallHasDoor) {
|
||
const mid = Math.ceil(back.length / 2);
|
||
const leftFlankNeed = layoutRow(back.slice(0, mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
|
||
const rightFlankNeed = layoutRow(back.slice(mid), MIN_FRAME_W + WALL_PADDING).spanNeeded;
|
||
width = Math.max(MIN_HALL_WIDTH, leftFlankNeed + DOOR_CLEARANCE + rightFlankNeed);
|
||
} else {
|
||
width = Math.max(MIN_HALL_WIDTH, layoutRow(back, MIN_HALL_SIZE).spanNeeded);
|
||
}
|
||
|
||
const halfW = width / 2;
|
||
const halfD = depth / 2;
|
||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||
|
||
const segments: WallSegment[] = [
|
||
{
|
||
side: 'back',
|
||
label: back.length > 0 ? `Wing ${hallIndex + 1} · End wall` : '',
|
||
paintings: back,
|
||
slots: layoutBackSlots(back, width, halfD, inset, endWallHasDoor),
|
||
},
|
||
{
|
||
side: 'left',
|
||
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
|
||
paintings: left,
|
||
slots: layoutSideSlots(left, depth, 'left', halfW, inset),
|
||
},
|
||
{
|
||
side: 'right',
|
||
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
|
||
paintings: right,
|
||
slots: layoutSideSlots(right, depth, 'right', halfW, inset),
|
||
},
|
||
];
|
||
|
||
return {
|
||
hallIndex,
|
||
hallCount,
|
||
width,
|
||
depth,
|
||
segments,
|
||
paintingCount: paintings.length,
|
||
yearLabel: formatYearLabel(paintings),
|
||
endWallHasDoor,
|
||
};
|
||
}
|
||
|
||
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] {
|
||
const chunks = splitPaintingsIntoMovementHalls(paintings);
|
||
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length));
|
||
}
|
||
|
||
function mergeIntervals(intervals: [number, number][]): [number, number][] {
|
||
if (intervals.length === 0) return [];
|
||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||
const out: [number, number][] = [sorted[0]];
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const last = out[out.length - 1];
|
||
if (sorted[i][0] <= last[1]) last[1] = Math.max(last[1], sorted[i][1]);
|
||
else out.push(sorted[i]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function findWallGaps(occupied: [number, number][], halfSpan: number, minGap: number): [number, number][] {
|
||
const merged = mergeIntervals(occupied);
|
||
const gaps: [number, number][] = [];
|
||
let cursor = -halfSpan + 1.2;
|
||
for (const [a, b] of merged) {
|
||
if (a - cursor >= minGap) gaps.push([cursor, a]);
|
||
cursor = Math.max(cursor, b);
|
||
}
|
||
if (halfSpan - 1.2 - cursor >= minGap) gaps.push([cursor, halfSpan - 1.2]);
|
||
return gaps.sort((a, b) => b[1] - b[0] - (a[1] - a[0]));
|
||
}
|
||
|
||
function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, 'style' | 'lightColor' | 'lightIntensity' | 'width' | 'height'> {
|
||
const side = style.windows.find((w) => w.wall === 'left' || w.wall === 'right');
|
||
if (side) {
|
||
return {
|
||
style: side.style,
|
||
lightColor: side.lightColor,
|
||
lightIntensity: side.lightIntensity,
|
||
width: Math.min(side.width, 1.6),
|
||
height: Math.min(side.height, 1.5),
|
||
};
|
||
}
|
||
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
|
||
}
|
||
|
||
/** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
|
||
export function computeSideWallWindows(
|
||
layout: MovementHallLayout,
|
||
interiorStyle: MovementInteriorStyle
|
||
): GalleryWindowSpec[] {
|
||
const halfD = layout.depth / 2;
|
||
const tmpl = windowTemplate(interiorStyle);
|
||
const specs: GalleryWindowSpec[] = [];
|
||
const windowY = 3.15;
|
||
const minGap = tmpl.width + 0.6;
|
||
|
||
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 gaps = findWallGaps(occupied, halfD, minGap);
|
||
const maxWindows = Math.min(3, gaps.length);
|
||
for (let i = 0; i < maxWindows; i++) {
|
||
const [g0, g1] = gaps[i];
|
||
const center = (g0 + g1) / 2;
|
||
const w = Math.min(tmpl.width, g1 - g0 - 0.35);
|
||
if (w < 0.9) continue;
|
||
specs.push({
|
||
wall: side,
|
||
x: center,
|
||
y: windowY,
|
||
width: w,
|
||
height: tmpl.height,
|
||
style: tmpl.style,
|
||
lightColor: tmpl.lightColor,
|
||
lightIntensity: tmpl.lightIntensity,
|
||
});
|
||
}
|
||
|
||
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
|
||
if (!specs.some((s) => s.wall === side)) {
|
||
const span = Math.max(2.4, halfD * 1.2);
|
||
const count = span > 8 ? 2 : 1;
|
||
for (let i = 0; i < count; i++) {
|
||
const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
|
||
specs.push({
|
||
wall: side,
|
||
x: halfD * t,
|
||
y: 3.45,
|
||
width: Math.min(tmpl.width, 1.15),
|
||
height: Math.min(tmpl.height, 1.05),
|
||
style: tmpl.style,
|
||
lightColor: tmpl.lightColor,
|
||
lightIntensity: tmpl.lightIntensity * 0.85,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return specs;
|
||
}
|
||
|
||
export { WALL_HEIGHT };
|