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:
co-authored by
Cursor
parent
41d2d5d844
commit
67594ea4d3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user