From f20f811f21921ecf57b034cc9aa7c114da01e862 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Sun, 9 Aug 2026 22:42:23 +0300 Subject: [PATCH] Align influence lamps 40cm above the tallest hall frame. Compute the highest allocated frame top per hall and place every influence lamp on that shared rail so markers line up across different canvas sizes. Co-authored-by: Cursor --- Documentation/basics.md | 2 +- client/src/components/VirtualGallery.tsx | 41 ++++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) 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)} /> ))}