Files
Art-gallery/client/src/utils/movementHallLayout.ts
T
Danila KhodjaefandCursor 1853222e01 Fix movement-hall black walls and column clipping.
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>
2026-07-31 00:26:14 +03:00

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
/** Target capacity per movement wing (5060 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 };