Improve 3D gallery UX, placeholders, and image accuracy.
Add multi-row dynamic halls, eye-level camera, canvas covers for missing works, preserved view when returning from detail, and corrected image overrides for Kauffman and Raphael. Update documentation and add fetched painting assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
08f99d7a29
commit
792a0b1a77
@@ -14,6 +14,7 @@ import './VirtualGallery.css';
|
||||
|
||||
interface Props {
|
||||
data: ArtistDetail;
|
||||
active?: boolean;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
onNavigateArtist: (artistId: number) => void;
|
||||
onBack: () => void;
|
||||
@@ -22,17 +23,23 @@ interface Props {
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
const MOUNT_OFFSET = 0.06;
|
||||
const MOUNT_OFFSET = 0.16;
|
||||
const WALL_STANDOFF = 0.07;
|
||||
const BACK_WALL_EXTRA = 0.05;
|
||||
const FRAME_FACE_Z = 0.018;
|
||||
const EYE_HEIGHT = 1.65;
|
||||
const HANG_HEIGHT = 1.55;
|
||||
const FRAME_GAP = 0.18;
|
||||
const MIN_FRAME_W = 0.45;
|
||||
const MAX_FRAME_W = 1.05;
|
||||
const MAX_FRAME_H = 1.35;
|
||||
const MIN_HALL_SIZE = 9;
|
||||
const MAX_FRAMES_PER_WALL = 10;
|
||||
const ROW_GAP = 0.2;
|
||||
const WALL_PADDING = 1.4;
|
||||
const DOOR_WIDTH = 2.4;
|
||||
const DOOR_HEIGHT = 2.5;
|
||||
const TURN_SPEED = 0.032;
|
||||
const MOUSE_TURN_SENSITIVITY = 0.004;
|
||||
const DRAG_START_THRESHOLD_PX = 5;
|
||||
|
||||
type WallSide = 'back' | 'left' | 'right';
|
||||
|
||||
@@ -41,6 +48,7 @@ interface FrameSlot {
|
||||
rotationY: number;
|
||||
maxW: number;
|
||||
maxH: number;
|
||||
side: WallSide;
|
||||
}
|
||||
|
||||
interface WallSegment {
|
||||
@@ -74,115 +82,185 @@ function layoutRow(count: number, span: number) {
|
||||
});
|
||||
}
|
||||
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + padding) };
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||
}
|
||||
|
||||
function wallRowHeights(count: number, span: number, rows: number) {
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const heights: number[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const inRow = Math.min(perRow, count - r * perRow);
|
||||
const { slots } = layoutRow(inRow, span);
|
||||
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
function wallStackHeight(heights: number[]) {
|
||||
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
|
||||
}
|
||||
|
||||
function fitsOnWall(count: number, span: number, rows: number) {
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const available = span - WALL_PADDING;
|
||||
const frameW = Math.min(
|
||||
MAX_FRAME_W,
|
||||
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
|
||||
);
|
||||
if (frameW < MIN_FRAME_W) return false;
|
||||
|
||||
const totalHeight = wallStackHeight(wallRowHeights(count, span, rows));
|
||||
const bottom = EYE_HEIGHT - totalHeight / 2;
|
||||
const top = EYE_HEIGHT + totalHeight / 2;
|
||||
if (bottom < 0.35) return false;
|
||||
if (top > WALL_HEIGHT - 0.45) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rowCountForWall(count: number, span: number) {
|
||||
for (let rows = 1; rows <= count; rows++) {
|
||||
if (fitsOnWall(count, span, rows)) return rows;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function minSpanForWall(count: number) {
|
||||
if (count === 0) return MIN_HALL_SIZE;
|
||||
|
||||
let best = Infinity;
|
||||
for (let rows = 1; rows <= count; rows++) {
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
|
||||
if (fitsOnWall(count, span, rows)) {
|
||||
best = Math.min(best, span);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
|
||||
}
|
||||
|
||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||
const sorted = [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0));
|
||||
const walls: Painting[][] = [[], [], []];
|
||||
sorted.forEach((p, i) => walls[i % 3].push(p));
|
||||
return walls;
|
||||
}
|
||||
|
||||
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
|
||||
const periodIds = new Set(
|
||||
wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0)
|
||||
);
|
||||
const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name);
|
||||
const hasUnassigned = wallPaintings.some((p) => !p.period_id);
|
||||
if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`;
|
||||
if (names.length > 0) return names.join(' · ');
|
||||
if (hasUnassigned) return 'Other Works';
|
||||
return wallPaintings.length > 0 ? 'Works' : '';
|
||||
}
|
||||
|
||||
function layoutWallSlots(
|
||||
paintings: Painting[],
|
||||
span: number,
|
||||
side: WallSide,
|
||||
halfW: number,
|
||||
halfD: number,
|
||||
inset: number
|
||||
): FrameSlot[] {
|
||||
const count = paintings.length;
|
||||
if (count === 0) return [];
|
||||
|
||||
const rows = rowCountForWall(count, span);
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const slots: FrameSlot[] = [];
|
||||
|
||||
const rowLayouts = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const rowStart = r * perRow;
|
||||
const inRow = Math.min(perRow, count - rowStart);
|
||||
rowLayouts.push(layoutRow(inRow, span));
|
||||
}
|
||||
|
||||
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
|
||||
const totalHeight = wallStackHeight(rowHeights);
|
||||
let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const { slots: rowSlots } = rowLayouts[r];
|
||||
const rowFrameH = rowHeights[r];
|
||||
|
||||
for (let i = 0; i < rowSlots.length; i++) {
|
||||
const s = rowSlots[i];
|
||||
if (side === 'back') {
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: 0,
|
||||
side,
|
||||
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
|
||||
});
|
||||
} else if (side === 'left') {
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: Math.PI / 2,
|
||||
side,
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
|
||||
});
|
||||
} else {
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: -Math.PI / 2,
|
||||
side,
|
||||
position: [halfW - inset - WALL_STANDOFF, y, s.offset],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
y += rowFrameH + ROW_GAP;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
|
||||
const byPeriod = new Map<number, Painting[]>();
|
||||
for (const p of paintings) {
|
||||
const key = p.period_id || 0;
|
||||
const list = byPeriod.get(key) || [];
|
||||
list.push(p);
|
||||
byPeriod.set(key, list);
|
||||
}
|
||||
|
||||
const periodGroups: { label: string; paintings: Painting[] }[] = [];
|
||||
for (const period of periods) {
|
||||
const list = byPeriod.get(period.id) || [];
|
||||
if (list.length > 0) {
|
||||
periodGroups.push({
|
||||
label: period.name,
|
||||
paintings: [...list].sort((a, b) => (a.year || 0) - (b.year || 0)),
|
||||
});
|
||||
}
|
||||
}
|
||||
const other = byPeriod.get(0) || [];
|
||||
if (other.length > 0) {
|
||||
periodGroups.push({
|
||||
label: 'Other Works',
|
||||
paintings: [...other].sort((a, b) => (a.year || 0) - (b.year || 0)),
|
||||
});
|
||||
}
|
||||
|
||||
if (periodGroups.length === 0 && paintings.length > 0) {
|
||||
periodGroups.push({
|
||||
label: 'Works',
|
||||
paintings: [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0)),
|
||||
});
|
||||
}
|
||||
|
||||
const walls: WallSide[] = ['back', 'left', 'right'];
|
||||
const wallBuckets: { label: string; paintings: Painting[] }[][] = [[], [], []];
|
||||
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
||||
|
||||
periodGroups.forEach((group, i) => {
|
||||
wallBuckets[i % 3].push(group);
|
||||
});
|
||||
let width = minSpanForWall(wallPaintings[0].length);
|
||||
let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
|
||||
|
||||
let width = MIN_HALL_SIZE;
|
||||
let depth = MIN_HALL_SIZE;
|
||||
const segments: WallSegment[] = [];
|
||||
|
||||
const addWallFrames = (side: WallSide, groups: { label: string; paintings: Painting[] }[]) => {
|
||||
if (groups.length === 0) return;
|
||||
|
||||
const flat: Painting[] = [];
|
||||
const labels: string[] = [];
|
||||
for (const g of groups) {
|
||||
for (const p of g.paintings) {
|
||||
if (flat.length >= MAX_FRAMES_PER_WALL) break;
|
||||
flat.push(p);
|
||||
}
|
||||
if (flat.length <= MAX_FRAMES_PER_WALL) labels.push(g.label);
|
||||
}
|
||||
|
||||
const label = labels.join(' · ');
|
||||
const span = side === 'back' ? width : depth;
|
||||
const { spanNeeded } = layoutRow(flat.length, span);
|
||||
|
||||
if (side === 'back') depth = Math.max(depth, spanNeeded);
|
||||
else width = Math.max(width, spanNeeded);
|
||||
|
||||
segments.push({ side, label, paintings: flat, slots: [] });
|
||||
};
|
||||
|
||||
walls.forEach((side, i) => addWallFrames(side, wallBuckets[i]));
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const nextWidth = minSpanForWall(wallPaintings[0].length);
|
||||
const nextDepth = Math.max(
|
||||
minSpanForWall(wallPaintings[1].length),
|
||||
minSpanForWall(wallPaintings[2].length)
|
||||
);
|
||||
if (nextWidth === width && nextDepth === depth) break;
|
||||
width = nextWidth;
|
||||
depth = nextDepth;
|
||||
}
|
||||
|
||||
width = Math.max(width, MIN_HALL_SIZE);
|
||||
depth = Math.max(depth, MIN_HALL_SIZE);
|
||||
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const y = HANG_HEIGHT;
|
||||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||||
|
||||
for (const seg of segments) {
|
||||
const row = layoutRow(seg.paintings.length, seg.side === 'back' ? width : depth);
|
||||
|
||||
seg.slots = row.slots.map((s) => {
|
||||
if (seg.side === 'back') {
|
||||
return {
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: 0,
|
||||
position: [s.offset, y, -halfD + inset],
|
||||
};
|
||||
}
|
||||
if (seg.side === 'left') {
|
||||
return {
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: Math.PI / 2,
|
||||
position: [-halfW + inset, y, s.offset],
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: -Math.PI / 2,
|
||||
position: [halfW - inset, y, s.offset],
|
||||
};
|
||||
});
|
||||
}
|
||||
const segments: WallSegment[] = walls.map((side, i) => ({
|
||||
side,
|
||||
label: wallLabelForPaintings(wallPaintings[i], periods),
|
||||
paintings: wallPaintings[i],
|
||||
slots: layoutWallSlots(
|
||||
wallPaintings[i],
|
||||
side === 'back' ? width : depth,
|
||||
side,
|
||||
halfW,
|
||||
halfD,
|
||||
inset
|
||||
),
|
||||
}));
|
||||
|
||||
return { width, depth, segments };
|
||||
}
|
||||
@@ -197,6 +275,108 @@ function computeFrameSize(aspect: number, maxW: number, maxH: number) {
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
function paintingHasGalleryImage(painting: Painting) {
|
||||
return !!(painting.thumbnail_path || painting.image_path);
|
||||
}
|
||||
|
||||
let canvasWeaveTexture: THREE.CanvasTexture | null = null;
|
||||
|
||||
function getCanvasWeaveTexture() {
|
||||
if (canvasWeaveTexture) return canvasWeaveTexture;
|
||||
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#ddd0b8';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
for (let y = 0; y < size; y += 4) {
|
||||
for (let x = 0; x < size; x += 4) {
|
||||
ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8';
|
||||
ctx.fillRect(x, y, 4, 4);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= size; i += 8) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, 0);
|
||||
ctx.lineTo(i, size);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i);
|
||||
ctx.lineTo(size, i);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
canvasWeaveTexture = new THREE.CanvasTexture(canvas);
|
||||
canvasWeaveTexture.wrapS = THREE.RepeatWrapping;
|
||||
canvasWeaveTexture.wrapT = THREE.RepeatWrapping;
|
||||
canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
return canvasWeaveTexture;
|
||||
}
|
||||
|
||||
function CanvasCover({
|
||||
width,
|
||||
height,
|
||||
frameDepth,
|
||||
faceZ,
|
||||
matBorder,
|
||||
hovered,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
frameDepth: number;
|
||||
faceZ: number;
|
||||
matBorder: number;
|
||||
hovered: boolean;
|
||||
}) {
|
||||
const weave = useMemo(() => {
|
||||
const tex = getCanvasWeaveTexture().clone();
|
||||
tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5));
|
||||
return tex;
|
||||
}, [width, height]);
|
||||
|
||||
useEffect(() => () => weave.dispose(), [weave]);
|
||||
|
||||
const cloth = hovered ? '#ddd3bc' : '#c4b494';
|
||||
const z = frameDepth + faceZ;
|
||||
|
||||
return (
|
||||
<group renderOrder={20}>
|
||||
<mesh position={[0, 0, z]}>
|
||||
<planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
|
||||
<meshBasicMaterial
|
||||
map={weave}
|
||||
color={cloth}
|
||||
toneMapped={false}
|
||||
depthWrite
|
||||
polygonOffset
|
||||
polygonOffsetFactor={-6}
|
||||
polygonOffsetUnits={-6}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
|
||||
<planeGeometry args={[width * 0.48, height * 0.98]} />
|
||||
<meshBasicMaterial
|
||||
map={weave}
|
||||
color={hovered ? '#a89478' : '#958470'}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.55}
|
||||
depthWrite={false}
|
||||
polygonOffset
|
||||
polygonOffsetFactor={-5}
|
||||
polygonOffsetUnits={-5}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function usePaintingTexture(url: string | null) {
|
||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||
const [failed, setFailed] = useState(!url);
|
||||
@@ -220,6 +400,12 @@ function usePaintingTexture(url: string | null) {
|
||||
tex.dispose();
|
||||
return;
|
||||
}
|
||||
const img = tex.image as HTMLImageElement | undefined;
|
||||
if (!img || img.width < 4 || img.height < 4) {
|
||||
tex.dispose();
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
loaded = tex;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4;
|
||||
@@ -247,6 +433,7 @@ function PaintingFrame({
|
||||
rotationY,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
wallSide,
|
||||
onClick,
|
||||
}: {
|
||||
painting: Painting;
|
||||
@@ -254,6 +441,7 @@ function PaintingFrame({
|
||||
rotationY: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
wallSide: WallSide;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -261,15 +449,20 @@ function PaintingFrame({
|
||||
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
|
||||
const frameDepth = 0.06;
|
||||
const matBorder = 0.05;
|
||||
const url = galleryImageUrl(painting);
|
||||
const hasImage = paintingHasGalleryImage(painting);
|
||||
const url = hasImage ? galleryImageUrl(painting) : null;
|
||||
const { texture, failed } = usePaintingTexture(url);
|
||||
const showImage = hasImage && !failed && !!texture;
|
||||
const showCanvas = !showImage;
|
||||
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showImage) return;
|
||||
const img = texture?.image as HTMLImageElement | undefined;
|
||||
if (img?.width && img.height) {
|
||||
setAspect(img.width / img.height);
|
||||
}
|
||||
}, [texture]);
|
||||
}, [texture, showImage]);
|
||||
|
||||
return (
|
||||
<group position={position} rotation={[0, rotationY, 0]}>
|
||||
@@ -277,14 +470,15 @@ function PaintingFrame({
|
||||
position={[0, height / 2 + 0.25, 0.3]}
|
||||
angle={0.5}
|
||||
penumbra={0.75}
|
||||
intensity={hovered ? 2.6 : 1.9}
|
||||
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
|
||||
distance={4.5}
|
||||
color="#fff8ee"
|
||||
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
|
||||
/>
|
||||
|
||||
<mesh
|
||||
position={[0, 0, frameDepth / 2]}
|
||||
castShadow
|
||||
renderOrder={1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
@@ -293,22 +487,42 @@ function PaintingFrame({
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
|
||||
<meshStandardMaterial color={hovered ? '#c9a227' : '#6b4f1d'} roughness={0.45} metalness={0.5} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0, frameDepth + 0.002]}>
|
||||
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
|
||||
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0, frameDepth + 0.006]} renderOrder={2}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={texture}
|
||||
color={texture ? '#ffffff' : failed ? '#8a7355' : '#4a3828'}
|
||||
toneMapped={false}
|
||||
<meshStandardMaterial
|
||||
color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
|
||||
roughness={0.45}
|
||||
metalness={showCanvas ? 0.15 : 0.5}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{!showCanvas && (
|
||||
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
|
||||
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
|
||||
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{showCanvas ? (
|
||||
<CanvasCover
|
||||
width={width}
|
||||
height={height}
|
||||
frameDepth={frameDepth}
|
||||
faceZ={faceZ}
|
||||
matBorder={matBorder}
|
||||
hovered={hovered}
|
||||
/>
|
||||
) : (
|
||||
<mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={texture}
|
||||
color="#ffffff"
|
||||
toneMapped={false}
|
||||
polygonOffset
|
||||
polygonOffsetFactor={-4}
|
||||
polygonOffsetUnits={-4}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -326,7 +540,7 @@ function GalleryWall({
|
||||
}) {
|
||||
const [w, h] = size;
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} receiveShadow>
|
||||
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
|
||||
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
||||
<meshStandardMaterial color={color} roughness={0.92} />
|
||||
</mesh>
|
||||
@@ -345,17 +559,14 @@ function ExitPortal({
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const glow = active || hovered;
|
||||
|
||||
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
onActivate();
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh
|
||||
position={[0, DOOR_HEIGHT / 2, 0]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onActivate();
|
||||
}}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
|
||||
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
|
||||
<meshStandardMaterial
|
||||
color={glow ? '#d4af37' : '#8b6914'}
|
||||
@@ -365,11 +576,28 @@ function ExitPortal({
|
||||
metalness={0.35}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Large invisible target — easier to click from anywhere in the hall */}
|
||||
<mesh
|
||||
position={[0, DOOR_HEIGHT / 2, -0.12]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
|
||||
<Text
|
||||
position={[0, DOOR_HEIGHT + 0.25, 0.08]}
|
||||
position={[0, DOOR_HEIGHT + 0.25, -0.06]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
fontSize={0.22}
|
||||
color={glow ? '#f5e6c8' : '#c9a96e'}
|
||||
anchorX="center"
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
EXIT
|
||||
</Text>
|
||||
@@ -460,6 +688,7 @@ function ArtistHall({
|
||||
rotationY={seg.slots[i].rotationY}
|
||||
maxWidth={seg.slots[i].maxW}
|
||||
maxHeight={seg.slots[i].maxH}
|
||||
wallSide={seg.slots[i].side}
|
||||
onClick={() => onPaintingClick(painting.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -493,6 +722,11 @@ function ArtistHall({
|
||||
);
|
||||
}
|
||||
|
||||
function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
||||
pos.y = EYE_HEIGHT;
|
||||
target.y = EYE_HEIGHT;
|
||||
}
|
||||
|
||||
function CameraController({
|
||||
position,
|
||||
target,
|
||||
@@ -502,8 +736,9 @@ function CameraController({
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
useFrame(() => {
|
||||
levelHorizontalView(position, target);
|
||||
camera.position.lerp(position, 0.12);
|
||||
camera.lookAt(target);
|
||||
camera.lookAt(target.x, EYE_HEIGHT, target.z);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -579,6 +814,7 @@ function NavigationPanel({
|
||||
|
||||
export default function VirtualGallery({
|
||||
data,
|
||||
active = true,
|
||||
onPaintingClick,
|
||||
onNavigateArtist,
|
||||
onBack,
|
||||
@@ -592,6 +828,7 @@ export default function VirtualGallery({
|
||||
const [navigation, setNavigation] = useState<ArtistNavigation | null>(null);
|
||||
const [navLoading, setNavLoading] = useState(false);
|
||||
const [nearExit, setNearExit] = useState(false);
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -632,13 +869,15 @@ export default function VirtualGallery({
|
||||
[layout.depth]
|
||||
);
|
||||
const initialTarget = useMemo(
|
||||
() => new THREE.Vector3(0, HANG_HEIGHT, -layout.depth / 4),
|
||||
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4),
|
||||
[layout.depth]
|
||||
);
|
||||
|
||||
const [camPos, setCamPos] = useState(() => initialPos.clone());
|
||||
const [camTarget, setCamTarget] = useState(() => initialTarget.clone());
|
||||
const keysPressed = useRef<Set<string>>(new Set());
|
||||
const dragTurnActive = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const camPosRef = useRef(camPos);
|
||||
const camTargetRef = useRef(camTarget);
|
||||
camPosRef.current = camPos;
|
||||
@@ -688,6 +927,8 @@ export default function VirtualGallery({
|
||||
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(exitZ, target.z));
|
||||
|
||||
levelHorizontalView(pos, target);
|
||||
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
|
||||
@@ -698,9 +939,19 @@ export default function VirtualGallery({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
keysPressed.current.clear();
|
||||
dragTurnActive.current = false;
|
||||
dragStartRef.current = null;
|
||||
setIsLooking(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
keysPressed.current.add(e.key);
|
||||
if ((e.key === 'e' || e.key === 'E') && nearExit && !showExitNav) {
|
||||
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
|
||||
openExitNav();
|
||||
}
|
||||
};
|
||||
@@ -710,10 +961,14 @@ export default function VirtualGallery({
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const keys = keysPressed.current;
|
||||
if (keys.has('ArrowUp') || keys.has('w')) moveCamera(-0.1, 0, 0);
|
||||
if (keys.has('ArrowDown') || keys.has('s')) moveCamera(0.1, 0, 0);
|
||||
if (keys.has('ArrowLeft') || keys.has('a')) moveCamera(0, -0.08, 0);
|
||||
if (keys.has('ArrowRight') || keys.has('d')) moveCamera(0, 0.08, 0);
|
||||
if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0);
|
||||
if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0);
|
||||
if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) {
|
||||
moveCamera(0, 0, TURN_SPEED);
|
||||
}
|
||||
if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) {
|
||||
moveCamera(0, 0, -TURN_SPEED);
|
||||
}
|
||||
}, 16);
|
||||
|
||||
return () => {
|
||||
@@ -721,13 +976,45 @@ export default function VirtualGallery({
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [moveCamera, nearExit, showExitNav, openExitNav]);
|
||||
}, [active, moveCamera, showExitNav, openExitNav]);
|
||||
|
||||
const handleNavigate = (artistId: number) => {
|
||||
setShowExitNav(false);
|
||||
onNavigateArtist(artistId);
|
||||
};
|
||||
|
||||
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!active || showExitNav || e.button !== 0) return;
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!active || showExitNav || !dragStartRef.current) return;
|
||||
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) {
|
||||
dragTurnActive.current = true;
|
||||
setIsLooking(true);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
if (dragTurnActive.current && e.movementX !== 0) {
|
||||
moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY);
|
||||
}
|
||||
};
|
||||
|
||||
const endCanvasDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
dragStartRef.current = null;
|
||||
dragTurnActive.current = false;
|
||||
setIsLooking(false);
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="virtual-gallery">
|
||||
<div className="gallery-header">
|
||||
@@ -737,20 +1024,36 @@ export default function VirtualGallery({
|
||||
<p className="gallery-career-path">Personal hall · {paintings.length} works on the walls</p>
|
||||
</div>
|
||||
<div className="gallery-header-meta">
|
||||
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
|
||||
Exit →
|
||||
</button>
|
||||
<button className="gallery-bio-btn" onClick={onBioClick}>Biography</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gallery-canvas-container">
|
||||
<div
|
||||
className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
|
||||
onPointerDown={handleCanvasPointerDown}
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onPointerUp={endCanvasDrag}
|
||||
onPointerLeave={endCanvasDrag}
|
||||
onPointerCancel={endCanvasDrag}
|
||||
>
|
||||
{syncStatus && (
|
||||
<div className="gallery-loading-overlay gallery-sync-badge">
|
||||
<p>{syncStatus}</p>
|
||||
</div>
|
||||
)}
|
||||
{nearExit && !showExitNav && (
|
||||
<div className="gallery-exit-hint">At the exit — click the doorway or press <kbd>E</kbd></div>
|
||||
{!showExitNav && (
|
||||
<div className="gallery-exit-hint">
|
||||
Click the exit door, <kbd>E</kbd>, or <strong>Exit →</strong> above
|
||||
</div>
|
||||
)}
|
||||
<Canvas shadows camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}>
|
||||
<Canvas
|
||||
shadows
|
||||
frameloop={active ? 'always' : 'never'}
|
||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||
>
|
||||
<color attach="background" args={['#0d0906']} />
|
||||
<fog attach="fog" args={['#0d0906', 18, 55]} />
|
||||
<ambientLight intensity={0.42} />
|
||||
@@ -782,19 +1085,20 @@ export default function VirtualGallery({
|
||||
<div className="control-pad">
|
||||
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward">▲</button>
|
||||
<div className="control-row">
|
||||
<button onClick={() => moveCamera(0, -0.28, 0)} title="Step left">◀</button>
|
||||
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk toward exit">▼</button>
|
||||
<button onClick={() => moveCamera(0, 0.28, 0)} title="Step right">▶</button>
|
||||
<button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left">↺</button>
|
||||
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back">▼</button>
|
||||
<button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gallery-instructions">
|
||||
<h4>{artist.name}'s Hall</h4>
|
||||
<ul>
|
||||
<li><kbd>W</kbd> / <kbd>↑</kbd> Walk into the room</li>
|
||||
<li><kbd>S</kbd> / <kbd>↓</kbd> Walk toward the exit</li>
|
||||
<li><kbd>A</kbd> / <kbd>D</kbd> Step sideways along the walls</li>
|
||||
<li>Paintings hang on three walls — the centre stays open</li>
|
||||
<li>Click a painting for details, or use the exit to visit related artists</li>
|
||||
<li><kbd>W</kbd> / <kbd>↑</kbd> Walk forward</li>
|
||||
<li><kbd>S</kbd> / <kbd>↓</kbd> Walk back</li>
|
||||
<li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
|
||||
<li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
|
||||
<li>Drag on the view to look around</li>
|
||||
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user