1'use client'
2
3import type { Group, Texture } from 'three'
4import React, { useEffect, useRef } from 'react'
5
6interface Props {
7 size?: number
8 className?: string
9}
10
11export const Logo3D: React.FC<Props> = ({ size = 48, className }) => {
12 const mountRef = useRef<HTMLDivElement>(null)
13
14 useEffect(() => {
15 let cancelled = false
16 let cleanup: () => void = () => {}
17
18 const init = async () => {
19 const THREE = await import('three')
20 const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js')
21
22 if (cancelled || !mountRef.current) return
23
24 const container = mountRef.current
25
26 const scene = new THREE.Scene()
27 const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100)
28 camera.position.set(0, 0.7, 2.2)
29 camera.lookAt(0, 0, 0)
30
31 const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
32 renderer.setSize(size, size)
33 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
34 renderer.setClearColor(0x000000, 0)
35 container.appendChild(renderer.domElement)
36
37 const textureLoader = new THREE.TextureLoader()
38 const matcap = await new Promise<Texture>((resolve) => {
39 textureLoader.load('/matcap.png', (tex) => {
40 tex.colorSpace = THREE.SRGBColorSpace
41 resolve(tex)
42 })
43 })
44
45 const loader = new GLTFLoader()
46 const gltf = await new Promise<{ scene: Group }>((resolve, reject) => {
47 loader.load('/model.glb', resolve as never, undefined, reject)
48 })
49
50 if (cancelled) return
51
52 const model = gltf.scene
53 model.traverse((child) => {
54 if (child instanceof THREE.Mesh) {
55 child.material = new THREE.MeshMatcapMaterial({ matcap })
56 }
57 })
58
59 const box = new THREE.Box3().setFromObject(model)
60 const center = box.getCenter(new THREE.Vector3())
61 const modelSize = box.getSize(new THREE.Vector3())
62 const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z)
63 const scale = 1.7 / maxDim
64 model.scale.setScalar(scale)
65 model.position.sub(center.multiplyScalar(scale))
66 scene.add(model)
67
68 let targetX = 0
69 let targetY = 0
70 let currentX = 0
71 let currentY = 0
72
73 const handleMouseMove = (e: MouseEvent) => {
74 targetX = ((e.clientX / window.innerWidth) * 2 - 1) * 0.4
75 targetY = ((e.clientY / window.innerHeight) * 2 - 1) * 0.25
76 }
77 window.addEventListener('mousemove', handleMouseMove)
78
79 let raf: number
80 const animate = () => {
81 raf = requestAnimationFrame(animate)
82 currentX += (targetX - currentX) * 0.1
83 currentY += (targetY - currentY) * 0.1
84 model.rotation.y = currentX
85 model.rotation.x = currentY
86 renderer.render(scene, camera)
87 }
88 animate()
89
90 cleanup = () => {
91 cancelAnimationFrame(raf)
92 window.removeEventListener('mousemove', handleMouseMove)
93 renderer.dispose()
94 if (container.contains(renderer.domElement)) {
95 container.removeChild(renderer.domElement)
96 }
97 }
98 }
99
100 init().catch(console.error)
101
102 return () => {
103 cancelled = true
104 cleanup()
105 }
106 }, [size])
107
108 return <div ref={mountRef} style={{ width: size, height: size }} className={className} />
109}
110