Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-27 14:33:36 +03:00
co-authored by Cursor
parent 41d2d5d844
commit 67594ea4d3
7 changed files with 260 additions and 48 deletions
+6
View File
@@ -419,3 +419,9 @@
width: 100%;
margin-top: 8px;
}
.exit-nav-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(201, 169, 110, 0.25);
}
+182 -32
View File
@@ -110,6 +110,8 @@ const WALL_PADDING = 1.4;
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
const MAX_WALL_ROWS = 1;
const DOOR_WIDTH = 2.4;
/** Minimum horizontal distance from walls, corners, door planes, and painting faces. */
const PLAYER_CLEARANCE = 0.5;
const DOOR_HEIGHT = 2.5;
/** Museum exit — dimensions derived from door opening. */
const EXIT_JAMB = 0.13;
@@ -176,6 +178,73 @@ interface HallLayout {
segments: WallSegment[];
}
interface XZBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
/** Inner wall face half-extents (room center → inside of wall box). */
function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } {
return {
halfW: width / 2 - WALL_THICKNESS / 2,
halfD: depth / 2 - WALL_THICKNESS / 2,
};
}
/** Keep-out box in front of a hanging painting (XZ), including player clearance. */
function paintingKeepOutBounds(slot: FrameSlot): XZBounds {
const [px, , pz] = slot.position;
const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04;
const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE;
if (slot.side === 'left') {
return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong };
}
if (slot.side === 'right') {
return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong };
}
// back wall — faces into the room (+Z)
return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom };
}
/** Door jamb keep-outs at an opening in the front (+Z) or back (Z) wall. */
function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] {
const jambZ = atFront ? halfD : -halfD;
const along = PLAYER_CLEARANCE;
const intoRoom = PLAYER_CLEARANCE;
const leftX = -DOOR_WIDTH / 2;
const rightX = DOOR_WIDTH / 2;
if (atFront) {
return [
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
];
}
return [
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
];
}
function pointInBounds(x: number, z: number, b: XZBounds): boolean {
return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ;
}
/** Push a point out of an AABB via the shortest axis (XZ). */
function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void {
if (!pointInBounds(pos.x, pos.z, b)) return;
const dxMin = pos.x - b.minX;
const dxMax = b.maxX - pos.x;
const dzMin = pos.z - b.minZ;
const dzMax = b.maxZ - pos.z;
const m = Math.min(dxMin, dxMax, dzMin, dzMax);
if (m === dxMin) pos.x = b.minX;
else if (m === dxMax) pos.x = b.maxX;
else if (m === dzMin) pos.z = b.minZ;
else pos.z = b.maxZ;
}
function paintingIsReviewed(painting: Painting): boolean {
return !!painting.checkup_checked;
}
@@ -1749,7 +1818,7 @@ function MovementHallNavPanel({
))}
</ul>
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Exit to Timeline
Back to Timeline
</button>
</div>
</div>
@@ -1762,11 +1831,13 @@ function NavigationPanel({
navigation,
loading,
onSelect,
onExitTimeline,
onClose,
}: {
navigation: ArtistNavigation | null;
loading: boolean;
onSelect: (artistId: number) => void;
onExitTimeline: () => void;
onClose: () => void;
}) {
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
@@ -1822,6 +1893,11 @@ function NavigationPanel({
'No documented successors via painting influences.'
)}
</div>
<div className="exit-nav-footer">
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Back to Timeline
</button>
</div>
</div>
</div>
);
@@ -2017,11 +2093,62 @@ export default function VirtualGallery(props: Props) {
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
const halfW = layout.width / 2 - 0.55;
const halfD = layout.depth / 2 - 0.35;
const exitZ = layout.depth / 2 - 0.55;
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
() => innerWallHalfExtents(layout.width, layout.depth),
[layout.width, layout.depth]
);
/** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */
const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE;
const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE;
const exitZ = playHalfD;
const fogFar = Math.max(55, layout.depth + 42);
const collisionObstacles = useMemo(() => {
const boxes: XZBounds[] = [];
for (const seg of layout.segments) {
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
}
// Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable.
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
return boxes;
}, [layout.segments, wallInnerHalfD, isWingedHall]);
const resolvePlayerPosition = useCallback(
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
const clampToWalls = () => {
pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x));
const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35;
if (isWingedHall) {
let minZ = -playHalfD;
let maxZ = playHalfD;
if (inDoorBand) {
minZ = -wallInnerHalfD + 0.08;
if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
}
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
} else {
let maxZ = exitZ;
if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z));
}
};
clampToWalls();
for (const box of collisionObstacles) pushOutOfBounds(pos, box);
clampToWalls();
},
[
playHalfW,
playHalfD,
exitZ,
isWingedHall,
hasNextHall,
wallInnerHalfD,
collisionObstacles,
]
);
const initialPos = useMemo(
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
[layout.depth]
@@ -2091,8 +2218,24 @@ export default function VirtualGallery(props: Props) {
}
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
const frontPassageZ = layout.depth / 2 - 0.55;
const backExitZ = isWingedHall ? -playHalfD : exitZ;
const frontPassageZ = playHalfD;
const updateProximityFlags = useCallback(
(pos: { x: number; z: number }) => {
if (isWingedHall) {
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontPassage =
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atBackExit);
setNearPassage(!!atFrontPassage);
} else {
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atExit);
}
},
[isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ]
);
const moveCamera = useCallback(
(forward: number, strafe: number, rotY: number) => {
@@ -2100,43 +2243,49 @@ export default function VirtualGallery(props: Props) {
const target = camTargetRef.current.clone();
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
// Turn in place: never move; only change look direction.
if (rotY !== 0) {
const newAngle = angle + rotY;
const dist = pos.distanceTo(target);
const dist = Math.max(0.5, pos.distanceTo(target));
target.x = pos.x + Math.sin(newAngle) * dist;
target.z = pos.z + Math.cos(newAngle) * dist;
} else {
const newAngle = angle;
pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
levelHorizontalView(pos, target);
updateProximityFlags(pos);
setCamPos(pos);
setCamTarget(target);
return;
}
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
target.x = Math.max(-halfW, Math.min(halfW, target.x));
if (isWingedHall) {
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
target.z = Math.max(-halfD, Math.min(halfD, target.z));
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
const atFrontPassage =
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atBackExit);
setNearPassage(!!atFrontPassage);
} else {
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
target.z = Math.max(-halfD, Math.min(exitZ, target.z));
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
setNearExit(atExit);
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
if (dx === 0 && dz === 0) {
updateProximityFlags(pos);
return;
}
const proposed = { x: pos.x + dx, z: pos.z + dz };
const resolved = { x: proposed.x, z: proposed.z };
resolvePlayerPosition(resolved, true);
// Hit wall/painting/door jamb: cancel the whole step — no slide, no view change.
if (
Math.abs(resolved.x - proposed.x) > 1e-4 ||
Math.abs(resolved.z - proposed.z) > 1e-4
) {
updateProximityFlags(pos);
return;
}
pos.x = proposed.x;
pos.z = proposed.z;
target.x += dx;
target.z += dz;
levelHorizontalView(pos, target);
updateProximityFlags(pos);
setCamPos(pos);
setCamTarget(target);
},
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
[resolvePlayerPosition, updateProximityFlags]
);
useEffect(() => {
@@ -2362,6 +2511,7 @@ export default function VirtualGallery(props: Props) {
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
onExitTimeline={onBack}
onClose={() => setShowExitNav(false)}
/>
)}
@@ -2407,7 +2557,7 @@ export default function VirtualGallery(props: Props) {
) : (
<>
<li>Golden lamps mark works linked in the influence graph</li>
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
</>
)}
</ul>