diff --git a/app/components/sponsors/ReunionTower.tsx b/app/components/sponsors/ReunionTower.tsx index d7b3dd3..25f7e17 100644 --- a/app/components/sponsors/ReunionTower.tsx +++ b/app/components/sponsors/ReunionTower.tsx @@ -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; @@ -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 ─────────────────────────────── @@ -132,7 +67,7 @@ function SponsorPlane({ logoUrl: string; }) { const texture = useLoader(THREE.TextureLoader, logoUrl); - const meshRef = useRef(null); + const groupRef = useRef(null); const quaternion = useMemo(() => { const q = new THREE.Quaternion(); @@ -141,13 +76,33 @@ 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 ( - - + {/* Test background curved segment (Pink) */} + + + + + {/* Logo curved segment */} + + - + + ); } // ── Tower model with sponsor logos in globe triangles ──────── function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerSceneProps) { const { scene } = useGLTF(MODEL_PATH); - const groupRef = useRef(null); + const globeRef = useRef(null); + const sponsorGroupRef = useRef(null); const smoothRotation = useRef(0); const autoAngle = useRef(0); @@ -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( @@ -194,7 +157,7 @@ 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; @@ -202,26 +165,34 @@ function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerScenePr 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 ( - + - {/* Sponsor logos placed in globe triangle gaps */} - {sponsorFaces.map((face, i) => { - const sponsor = validSponsors[i % validSponsors.length]; - if (!sponsor?.logo) return null; - return ( - - - - ); - })} + {/* Sponsor group handles the primary rotation */} + + {sponsorFaces.map((face, i) => { + const sponsor = validSponsors[i % validSponsors.length]; + if (!sponsor?.logo) return null; + return ( + + + + ); + })} + ); } @@ -232,17 +203,17 @@ function TowerModel({ scrollProgressRef, dragOffsetRef, sponsors }: TowerScenePr function CameraRig({ scrollProgressRef }: { scrollProgressRef: React.RefObject }) { 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; @@ -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" > @@ -300,4 +271,4 @@ export default function ReunionTower({ ); -} +} \ No newline at end of file