Files
Art-gallery/client/src/components/hall-details/geometry.ts
T
Danila KhodjaefandClaude Opus 5 f2a256132c Give every movement its own period interior.
Movement halls routed 24 of the 26 movements through 8 generic detail kinds,
four of which (modern, industrial, atelier, museum) rendered nothing at all,
and `salon` was a single ceiling ring shared by eight movements.

`details` is now a 26-value MovementDetailKind, one per movement, each built
from the architecture of the same period as the movement itself. Gothic and
Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference
implementations; the rest live under components/hall-details/, split by era,
over shared wall-flush primitives.

hall-details/geometry.ts records the budget the Gothic and Byzantine
components established, since frames hang 0.23 m proud with a 0.7 m clear
margin at each wall end: relief limits above and below the frame line, corner
pockets for freestanding masses, doorway and title-band clearance, and a cap
of two added lights per hall. Halls now pass their computed windows down so
upper-wall panels, arches and roundels step around the openings.

Also holds the pale interiors back from blowing out: gallery wall tints are
authored around 0.50-0.56 luminance instead of near-white, and the 19 halls
that read too bright take lightScale 0.30, matching the basilica.

Verified by rendering all 26 interiors offline at two hall sizes and
measuring the scene graph: nothing through walls, floor or ceiling, maximum
0.17 m intrusion into the hang zone, at most two added lights per hall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:44:05 +03:00

110 lines
4.4 KiB
TypeScript

import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
/**
* Shared geometry budget for every period interior.
*
* Movement halls hang frames 0.23 m proud of the inner wall face
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
* basilica do:
*
* - relief below the frame line: at most 0.11 m proud of the wall face
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
* - the end wall carries the hall title from 3.4 m to 3.9 m — keep it clear
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
* anything wider than a mullion up there goes through `useWallClearance`
* - added lights: at most two per hall, so hall materials stay inside the
* WebGL light limits the shared track lighting already budgets for
*/
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
export const WALL_INNER = 0.09;
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
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;
export type WallId = 'back' | 'front' | 'left' | 'right';
export interface RoomDims {
width: number;
depth: number;
halfW: number;
halfD: number;
}
/** Every period module takes the same three inputs. */
export interface PeriodProps {
room: RoomDims;
/** The movement's trim colour, already blended with its accent. */
trim: string;
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
windows?: GalleryWindowSpec[];
}
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.replace('#', ''), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
);
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
}
/** 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]);
}
/**
* Evenly spaced positions centred on a wall, for the repeating small members —
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
*/
export function useRepeat(span: number, step: number, max: number): number[] {
return useMemo(() => {
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
const run = (count - 1) * step;
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
}, [span, step, max]);
}
/**
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
* any panel, arch or roundel a period module paints across the upper wall has
* to step around them. Returns a predicate: true when the span is clear.
*/
export function useWallClearance(
windows: GalleryWindowSpec[] | undefined,
wall: 'left' | 'right' | 'back'
) {
return useMemo(() => {
const spans = (windows ?? [])
.filter((w) => w.wall === wall)
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
return (pos: number, halfSpan = 0.2) =>
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
}, [windows, wall]);
}
/** The four corner columns of the room, each inside its hang-margin pocket. */
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
return useMemo(
() =>
[
[-halfW + inset, -halfD + inset],
[halfW - inset, -halfD + inset],
[-halfW + inset, halfD - inset],
[halfW - inset, halfD - inset],
] as [number, number][],
[halfW, halfD, inset]
);
}