Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 98 additions & 127 deletions app/components/sponsors/ReunionTower.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import * as THREE from "three";

const MODEL_PATH = "/models/reunion-tower.glb";

const GLOBE_RADIUS = 36;

// ── Types ───────────────────────────────────────────────────
interface TriFace {
center: THREE.Vector3;
Expand All @@ -25,102 +27,35 @@ interface TowerSceneProps {
sponsors: SponsorData[];
}

// ── Extract triangle faces from globe region of model ───────
function extractGlobeTriangles(
scene: THREE.Object3D,
scale: number,
centerY: number
): TriFace[] {
// ── Procedurally generate band positions around the globe ──
function generateBandPositions(sponsorsCount: number): TriFace[] {
const bands = 4;
const globeCenterY = 125; // Centered near the top of the 350-unit tall model
const radius = GLOBE_RADIUS; // Constant radius for cylindrical layout
const faces: TriFace[] = [];
const box = new THREE.Box3().setFromObject(scene);
const modelHeight = (box.max.y - box.min.y) * scale;
// Globe is top ~30% of the tower
const globeThresholdY = (box.min.y + (box.max.y - box.min.y) * 0.65) * scale - centerY;

scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const geo = child.geometry;
if (!geo || !geo.attributes.position) return;

const pos = geo.attributes.position;
const index = geo.index;
const worldMatrix = child.matrixWorld.clone();

const triCount = index ? index.count / 3 : pos.count / 3;
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();

for (let i = 0; i < triCount; i++) {
if (index) {
vA.fromBufferAttribute(pos, index.getX(i * 3));
vB.fromBufferAttribute(pos, index.getX(i * 3 + 1));
vC.fromBufferAttribute(pos, index.getX(i * 3 + 2));
} else {
vA.fromBufferAttribute(pos, i * 3);
vB.fromBufferAttribute(pos, i * 3 + 1);
vC.fromBufferAttribute(pos, i * 3 + 2);
}

// Transform to world space then scale
vA.applyMatrix4(worldMatrix).multiplyScalar(scale);
vB.applyMatrix4(worldMatrix).multiplyScalar(scale);
vC.applyMatrix4(worldMatrix).multiplyScalar(scale);

// Offset Y to match model positioning
vA.y -= centerY;
vB.y -= centerY;
vC.y -= centerY;

const center = new THREE.Vector3()
.addVectors(vA, vB)
.add(vC)
.divideScalar(3);

// Only faces in globe region
if (center.y < globeThresholdY) continue;

// Compute face normal
const edge1 = new THREE.Vector3().subVectors(vB, vA);
const edge2 = new THREE.Vector3().subVectors(vC, vA);
const normal = new THREE.Vector3().crossVectors(edge1, edge2).normalize();

// Face size (area)
const area = new THREE.Vector3().crossVectors(edge1, edge2).length() * 0.5;

// Skip tiny degenerate triangles
if (area < 0.01) continue;

faces.push({ center, normal, size: Math.sqrt(area) * 0.6 });

const sponsorsPerBand = Math.ceil(sponsorsCount / bands);
const yOffsets = [-12, -4, 4, 12]; // Vertical distribution

for (let b = 0; b < bands; b++) {
const y = globeCenterY + yOffsets[b];
const r = radius;

for (let i = 0; i < sponsorsPerBand; i++) {
if (faces.length >= sponsorsCount) break;

const angle = (i / sponsorsPerBand) * Math.PI * 2;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;

const center = new THREE.Vector3(x, y, z);
const normal = new THREE.Vector3(x, 0, z).normalize();

// Use a consistent size for all procedural "faces"
faces.push({ center, normal, size: 22 });
}
});

return faces;
}

// ── Select well-spaced triangles for sponsor placement ──────
function selectSponsorFaces(faces: TriFace[], count: number): TriFace[] {
if (faces.length === 0) return [];

// Sort by size descending — prefer larger triangles
const sorted = [...faces].sort((a, b) => b.size - a.size);

const selected: TriFace[] = [];
const minDist = 1.2; // minimum distance between selected faces

for (const face of sorted) {
if (selected.length >= count) break;

// Check distance to already selected faces
const tooClose = selected.some(
(s) => s.center.distanceTo(face.center) < minDist
);
if (tooClose) continue;

selected.push(face);
}

return selected;
return faces;
}

// ── Single sponsor logo plane ───────────────────────────────
Expand All @@ -132,7 +67,7 @@ function SponsorPlane({
logoUrl: string;
}) {
const texture = useLoader(THREE.TextureLoader, logoUrl);
const meshRef = useRef<THREE.Mesh>(null);
const groupRef = useRef<THREE.Group>(null);

const quaternion = useMemo(() => {
const q = new THREE.Quaternion();
Expand All @@ -141,28 +76,50 @@ function SponsorPlane({
return q;
}, [face.normal]);

const aspect = texture.image ? texture.image.width / texture.image.height : 1;
const logoWidth = face.size * 0.15 * aspect;
const logoHeight = face.size * 0.15;

const bgWidth = face.size * 0.18 * aspect;
const bgHeight = face.size * 0.18;

// Calculate angular spans for the curved surface (chord length / radius approx)
const thetaLength = logoWidth / GLOBE_RADIUS;
const thetaStart = -thetaLength / 2;
const bgThetaLength = bgWidth / GLOBE_RADIUS;
const bgThetaStart = -bgThetaLength / 2;

return (
<mesh
ref={meshRef}
<group
ref={groupRef}
position={[face.center.x, face.center.y, face.center.z]}
quaternion={quaternion}
>
<planeGeometry args={[face.size * 1.4, face.size * 1.4]} />
{/* Test background curved segment (Pink) */}
<mesh position={[0, 0, -GLOBE_RADIUS + 0.05]} renderOrder={1}>
<cylinderGeometry args={[GLOBE_RADIUS, GLOBE_RADIUS, bgHeight, 16, 1, true, bgThetaStart, bgThetaLength]} />
<meshBasicMaterial color="pink" transparent opacity={0.9} />
</mesh>
{/* Logo curved segment */}
<mesh position={[0, 0, -GLOBE_RADIUS + 0.11]} renderOrder={2}>
<cylinderGeometry args={[GLOBE_RADIUS, GLOBE_RADIUS, logoHeight, 16, 1, true, thetaStart, thetaLength]} />
<meshBasicMaterial
map={texture}
transparent
side={THREE.DoubleSide}
depthWrite={false}
opacity={0.92}
/>
</mesh>
</mesh>
</group>
);
}

// ── Tower model with sponsor logos in globe triangles ────────
function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerSceneProps) {
const { scene } = useGLTF(MODEL_PATH);
const groupRef = useRef<THREE.Group>(null);
const globeRef = useRef<THREE.Object3D | null>(null);
const sponsorGroupRef = useRef<THREE.Group>(null);
const smoothRotation = useRef(0);
const autoAngle = useRef(0);

Expand All @@ -175,17 +132,23 @@ function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerScenePr
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const s = 200 / size.y;
const s = 350 / size.y;
return { normScale: s, centerY: center.y * s };
}, [clonedScene]);

// Find the PlatonicSphere node (the actual globe ball) to rotate independently
useEffect(() => {
clonedScene.traverse((child) => {
if (child.name === "PlatonicSphere") {
globeRef.current = child;
}
});
}, [clonedScene]);

// Extract globe triangle faces for sponsor placement
const sponsorFaces = useMemo(() => {
// Update world matrices before scanning
clonedScene.updateMatrixWorld(true);
const allFaces = extractGlobeTriangles(clonedScene, normScale, centerY);
return selectSponsorFaces(allFaces, sponsors.length);
}, [clonedScene, normScale, centerY, sponsors.length]);
return generateBandPositions(sponsors.length);
}, [sponsors.length]);

// Filter sponsors to ones with valid logos
const validSponsors = useMemo(
Expand All @@ -194,34 +157,42 @@ function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerScenePr
);

useFrame((_, delta) => {
if (!groupRef.current) return;
if (!globeRef.current) return;

autoAngle.current += delta * 0.3;
const scrollAngle = (scrollProgressRef.current ?? 0) * Math.PI * 2;
const dragAngle = (dragOffsetRef.current ?? 0) * 0.01;
const target = autoAngle.current + scrollAngle + dragAngle;

smoothRotation.current += (target - smoothRotation.current) * 0.08;
groupRef.current.rotation.y = smoothRotation.current;

// 1. Inside part (sponsors) rotates in the original direction
if (sponsorGroupRef.current) {
sponsorGroupRef.current.rotation.y = smoothRotation.current;
}
// 2. Globe rotates in the opposite direction at half speed
globeRef.current.rotation.y = -smoothRotation.current * 0.5;
});

return (
<group ref={groupRef} dispose={null}>
<group dispose={null}>
<primitive
object={clonedScene}
scale={[normScale, normScale, normScale]}
position={[0, -centerY, 0]}
/>
{/* Sponsor logos placed in globe triangle gaps */}
{sponsorFaces.map((face, i) => {
const sponsor = validSponsors[i % validSponsors.length];
if (!sponsor?.logo) return null;
return (
<Suspense key={`sponsor-${i}`} fallback={null}>
<SponsorPlane face={face} logoUrl={sponsor.logo} />
</Suspense>
);
})}
{/* Sponsor group handles the primary rotation */}
<group ref={sponsorGroupRef} position={[-2, 0, 0]}>
{sponsorFaces.map((face, i) => {
const sponsor = validSponsors[i % validSponsors.length];
if (!sponsor?.logo) return null;
return (
<Suspense key={`sponsor-${i}`} fallback={null}>
<SponsorPlane face={face} logoUrl={sponsor.logo} />
</Suspense>
);
})}
</group>
</group>
);
}
Expand All @@ -232,17 +203,17 @@ function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerScenePr
function CameraRig({ scrollProgressRef }: { scrollProgressRef: React.RefObject<number> }) {
const { camera } = useThree();
const smoothZ = useRef(20);
const smoothY = useRef(97);
const smoothLookY = useRef(94);
const smoothY = useRef(155);
const smoothLookY = useRef(150);

useFrame(() => {
const p = scrollProgressRef.current ?? 0;

// p=0: lookAt Y=94, visible top = 94+10.4 = 104.4, globe top Y=100 → 4.4 units headroom
// p=1: pulled back to see full tower
const targetZ = 20 + p * 170;
const targetY = 97 - p * 67;
const targetLookY = 94 - p * 84;
// Values recalculated for a base scale of 320 (1.6x the original 200)
// targetZ at p=1 moves from 190 to 304 to maintain vertical framing while increasing width
const targetZ = 20 + p * 284;
const targetY = 155 - p * 107;
const targetLookY = 150 - p * 134;

smoothZ.current += (targetZ - smoothZ.current) * 0.06;
smoothY.current += (targetY - smoothY.current) * 0.06;
Expand Down Expand Up @@ -279,7 +250,7 @@ export default function ReunionTower({
stencil: false,
depth: true,
}}
camera={{ position: [0, 97, 20], fov: 55, near: 0.1, far: 1000 }}
camera={{ position: [0, 155, 20], fov: 55, near: 0.1, far: 2000 }}
style={{ background: "transparent", touchAction: "pan-y" }}
frameloop="always"
>
Expand All @@ -300,4 +271,4 @@ export default function ReunionTower({
<CameraRig scrollProgressRef={scrollProgressRef} />
</Canvas>
);
}
}