diff --git a/Documentation/basics.md b/Documentation/basics.md
index 0cdc4b3..10f4f1a 100644
--- a/Documentation/basics.md
+++ b/Documentation/basics.md
@@ -301,7 +301,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
-| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
+| Influence lamps | A golden picture light marks works with any influence-graph edge (`has_influence_links` from the API). All lamps in a hall share one rail height: **40 cm above the tallest allocated frame** in that hall (so they line up regardless of canvas size). Fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx
index dd791d8..d7c4529 100644
--- a/client/src/components/VirtualGallery.tsx
+++ b/client/src/components/VirtualGallery.tsx
@@ -191,8 +191,8 @@ function galleryWallColors(movementColor?: string) {
};
}
-/** Height above frame top edge — keeps fixture out of the viewer's line of sight. */
-const INFLUENCE_LAMP_ABOVE_FRAME = 0.46;
+/** Clearance above the tallest frame in the hall for influence lamps (metres). */
+const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
type WallSide = 'back' | 'left' | 'right';
@@ -304,6 +304,20 @@ function frameOuterH(canvasH: number, reviewed: boolean): number {
return canvasH + matBorder * 2 + rail;
}
+/** World Y of the top edge of the tallest allocated frame in the hall. */
+function highestPaintingTopY(segments: WallSegment[]): number {
+ let maxTop = EYE_HEIGHT;
+ for (const seg of segments) {
+ for (let i = 0; i < seg.paintings.length; i++) {
+ const slot = seg.slots[i];
+ if (!slot) continue;
+ const top = slot.position[1] + frameOuterH(slot.maxH, paintingIsReviewed(seg.paintings[i])) / 2;
+ if (top > maxTop) maxTop = top;
+ }
+ }
+ return maxTop;
+}
+
function layoutRow(paintings: Painting[], span: number) {
const count = paintings.length;
if (count === 0) return { slots: [], spanNeeded: span };
@@ -803,17 +817,19 @@ function usePaintingTexture(urls: string[] | string | null) {
}
function InfluencePictureLamp({
- frameHeight,
- matBorder,
+ frameWorldY,
+ lampWorldY,
frameDepth,
highlighted,
}: {
- frameHeight: number;
- matBorder: number;
+ /** Painting group world Y — local lamp offset = lampWorldY − frameWorldY. */
+ frameWorldY: number;
+ /** Shared hall rail: 40 cm above the tallest frame. */
+ lampWorldY: number;
frameDepth: number;
highlighted: boolean;
}) {
- const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME;
+ const mountY = lampWorldY - frameWorldY;
const glow = highlighted ? 1.6 : 1.15;
const scale = 1.35;
@@ -918,6 +934,7 @@ function PaintingFrame({
wallSide,
imageRevision,
caption,
+ influenceLampWorldY,
onClick,
}: {
painting: Painting;
@@ -928,6 +945,7 @@ function PaintingFrame({
wallSide: WallSide;
imageRevision?: number;
caption?: string;
+ influenceLampWorldY: number;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
@@ -1018,8 +1036,8 @@ function PaintingFrame({
{hasInfluenceLinks && (
@@ -1505,6 +1523,10 @@ function ArtistHall({
const hallLightScale = interiorStyle?.lightScale ?? 1;
const wallRoughness = interiorStyle ? 0.75 : 0.92;
const wallMetalness = interiorStyle ? 0.08 : 0.06;
+ const influenceLampWorldY = useMemo(
+ () => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
+ [segments]
+ );
const wallMaterial = (color: string) => (
@@ -1752,6 +1774,7 @@ function ArtistHall({
wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]}
caption={showCaptions ? paintingWallCaption(painting) : undefined}
+ influenceLampWorldY={influenceLampWorldY}
onClick={() => onPaintingClick(painting.id)}
/>
))}