1- import React , { useEffect , useRef } from "react" ;
1+ import React from "react" ;
22import { Link } from "react-router-dom" ;
33import { ArrowRight } from "lucide-react" ;
44import { Card , CardContent , CardHeader , CardTitle , CardDescription } from "@/components/ui/card" ;
55import { AnimatedButton } from "@/components/ui/animated-button" ;
6- import * as THREE from "three" ;
7- import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js" ;
86import { cn } from "@/lib/utils" ;
97import { AnimatedIcon } from "@/components/ui/animated-icon" ;
108import { LucideIcon } from "lucide-react" ;
11- import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js" ;
129
1310type Props = {
1411 title : string ;
1512 description : string ;
1613 to : string ;
17- modelPath ?: string ;
1814 icon ?: LucideIcon ;
1915 colorClass ?: string ;
20- preset ?: "lowpoly-bot" | "pyramid" | "nodes-graph" | "workshop-tools" | "picture-frame" | "old-book" | "airplane" | "technician" ;
2116} ;
2217
23- export const AppCard3D : React . FC < Props > = ( { title, description, to, modelPath, icon, colorClass = "bg-primary" , preset } ) => {
24- const canvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
25- const containerRef = useRef < HTMLDivElement | null > ( null ) ;
18+ export const AppCard3D : React . FC < Props > = ( { title, description, to, icon, colorClass = "bg-primary" } ) => {
2619 const Line = ( { className = "" } ) => (
2720 < div
2821 className = { cn (
@@ -40,301 +33,17 @@ export const AppCard3D: React.FC<Props> = ({ title, description, to, modelPath,
4033 </ >
4134 ) ;
4235
43- useEffect ( ( ) => {
44- const canvas = canvasRef . current ;
45- const container = containerRef . current ;
46- if ( ! canvas || ! container ) return ;
47-
48- const renderer = new THREE . WebGLRenderer ( { canvas, antialias : true , alpha : true } ) ;
49- renderer . setPixelRatio ( Math . min ( window . devicePixelRatio , 2 ) ) ;
50- renderer . setClearColor ( 0x000000 , 0 ) ;
51-
52- const scene = new THREE . Scene ( ) ;
53- const camera = new THREE . PerspectiveCamera ( 45 , 1 , 0.1 , 100 ) ;
54- camera . position . set ( 2 , 1.5 , 3 ) ;
55- camera . lookAt ( 0 , 0 , 0 ) ;
56-
57- const hemi = new THREE . HemisphereLight ( 0xffffff , 0x202020 , 0.8 ) ;
58- scene . add ( hemi ) ;
59- const dir = new THREE . DirectionalLight ( 0xffffff , 0.7 ) ;
60- dir . position . set ( 3 , 5 , 2 ) ;
61- scene . add ( dir ) ;
62- const dir2 = new THREE . DirectionalLight ( 0xffffff , 0.3 ) ;
63- dir2 . position . set ( - 4 , - 3 , - 2 ) ;
64- scene . add ( dir2 ) ;
65-
66- const group = new THREE . Group ( ) ;
67- scene . add ( group ) ;
68-
69- let loaded : THREE . Object3D | null = null ;
70-
71- const centerAndScale = ( obj : THREE . Object3D ) => {
72- const pivot = new THREE . Group ( ) ;
73- pivot . add ( obj ) ;
74- const box = new THREE . Box3 ( ) . setFromObject ( pivot ) ;
75- const size = new THREE . Vector3 ( ) ;
76- box . getSize ( size ) ;
77- const maxDim = Math . max ( size . x , size . y , size . z ) || 1 ;
78- const scale = 1.8 / maxDim ;
79- pivot . scale . setScalar ( scale ) ;
80- const box2 = new THREE . Box3 ( ) . setFromObject ( pivot ) ;
81- const center = new THREE . Vector3 ( ) ;
82- box2 . getCenter ( center ) ;
83- pivot . position . set ( - center . x , - center . y , - center . z ) ;
84- return pivot ;
85- } ;
86-
87- if ( preset === "lowpoly-bot" ) {
88- const bot = new THREE . Group ( ) ;
89- const matBody = new THREE . MeshPhysicalMaterial ( { color : 0x7c3aed , metalness : 0.35 , roughness : 0.35 , clearcoat : 0.25 } ) ;
90- const matAccent = new THREE . MeshPhysicalMaterial ( { color : 0x22d3ee , metalness : 0.4 , roughness : 0.28 , clearcoat : 0.15 , emissive : 0x0b9edb , emissiveIntensity : 0.2 } ) ;
91- const matDark = new THREE . MeshPhysicalMaterial ( { color : 0x1f2937 , metalness : 0.15 , roughness : 0.7 } ) ;
92-
93- const body = new THREE . Mesh ( new RoundedBoxGeometry ( 1.2 , 0.9 , 0.6 , 4 , 0.18 ) , matBody ) ;
94- body . position . set ( 0 , - 0.1 , 0 ) ;
95- bot . add ( body ) ;
96-
97- const head = new THREE . Mesh ( new RoundedBoxGeometry ( 0.9 , 0.6 , 0.6 , 4 , 0.16 ) , matBody ) ;
98- head . position . set ( 0 , 0.6 , 0 ) ;
99- bot . add ( head ) ;
100-
101- const eyeL = new THREE . Mesh ( new THREE . CylinderGeometry ( 0.06 , 0.06 , 0.02 , 8 ) , matAccent ) ;
102- eyeL . rotation . x = Math . PI / 2 ;
103- eyeL . position . set ( - 0.22 , 0.65 , 0.32 ) ;
104- const eyeR = eyeL . clone ( ) ;
105- eyeR . position . x = 0.22 ;
106- bot . add ( eyeL , eyeR ) ;
107-
108- const stem = new THREE . Mesh ( new THREE . CylinderGeometry ( 0.04 , 0.04 , 0.3 , 6 ) , matDark ) ;
109- stem . position . set ( 0 , 0.95 , 0 ) ;
110- const tip = new THREE . Mesh ( new THREE . SphereGeometry ( 0.08 , 8 , 8 ) , matAccent ) ;
111- tip . position . set ( 0 , 1.12 , 0 ) ;
112- bot . add ( stem , tip ) ;
113-
114- const armL = new THREE . Mesh ( new RoundedBoxGeometry ( 0.2 , 0.6 , 0.2 , 2 , 0.08 ) , matBody ) ;
115- armL . position . set ( - 0.8 , 0.1 , 0 ) ;
116- const armR = armL . clone ( ) ;
117- armR . position . x = 0.8 ;
118- bot . add ( armL , armR ) ;
119-
120- const visor = new THREE . Mesh ( new RoundedBoxGeometry ( 0.5 , 0.22 , 0.05 , 2 , 0.05 ) , matDark ) ;
121- visor . position . set ( 0 , 0.65 , 0.33 ) ;
122- bot . add ( visor ) ;
123-
124- const pivot = centerAndScale ( bot ) ;
125- loaded = pivot ;
126- group . add ( pivot ) ;
127- } else if ( preset === "pyramid" ) {
128- const g = new THREE . Group ( ) ;
129- const matSand = new THREE . MeshPhysicalMaterial ( { color : 0xC8A76F , roughness : 0.65 , metalness : 0.06 } ) ;
130- const levels = 4 ;
131- for ( let i = 0 ; i < levels ; i ++ ) {
132- const s = 1.4 - i * 0.3 ;
133- const h = 0.22 + i * 0.02 ;
134- const step = new THREE . Mesh ( new RoundedBoxGeometry ( s , h , s , 2 , 0.04 ) , matSand ) ;
135- step . position . y = - 0.5 + i * h + i * 0.05 ;
136- g . add ( step ) ;
137- }
138- const cap = new THREE . Mesh ( new THREE . ConeGeometry ( 0.35 , 0.35 , 4 ) , matSand ) ;
139- cap . rotation . y = Math . PI / 4 ;
140- cap . position . y = 0.15 ;
141- g . add ( cap ) ;
142- const base = new THREE . Mesh ( new RoundedBoxGeometry ( 1.8 , 0.08 , 1.8 , 3 , 0.05 ) , new THREE . MeshPhysicalMaterial ( { color : 0x9E7F4F , roughness : 0.8 } ) ) ;
143- base . position . y = - 0.54 ;
144- g . add ( base ) ;
145- const pivot = centerAndScale ( g ) ;
146- loaded = pivot ; group . add ( pivot ) ;
147- } else if ( preset === "nodes-graph" ) {
148- const g = new THREE . Group ( ) ;
149- const matNode = new THREE . MeshPhysicalMaterial ( { color : 0x4F46E5 , roughness : 0.35 , metalness : 0.25 , clearcoat : 0.1 } ) ;
150- const matEdge = new THREE . MeshPhysicalMaterial ( { color : 0x22D3EE , roughness : 0.25 , metalness : 0.25 } ) ;
151- const positions = [
152- new THREE . Vector3 ( - 0.9 , 0.5 , 0 ) ,
153- new THREE . Vector3 ( 0.9 , 0.5 , 0.1 ) ,
154- new THREE . Vector3 ( - 0.6 , - 0.6 , - 0.1 ) ,
155- new THREE . Vector3 ( 0.6 , - 0.6 , 0 ) ,
156- new THREE . Vector3 ( 0 , 0.1 , 0.6 ) ,
157- new THREE . Vector3 ( 0.2 , - 0.1 , - 0.6 ) ,
158- new THREE . Vector3 ( - 0.2 , 0.3 , - 0.4 ) ,
159- new THREE . Vector3 ( 0.4 , 0.2 , 0.4 ) ,
160- ] ;
161- positions . forEach ( p => {
162- const n = new THREE . Mesh ( new THREE . SphereGeometry ( 0.16 , 20 , 20 ) , matNode ) ;
163- n . position . copy ( p ) ;
164- g . add ( n ) ;
165- } ) ;
166- const connectCurve = ( a : THREE . Vector3 , b : THREE . Vector3 , off : THREE . Vector3 ) => {
167- const curve = new THREE . QuadraticBezierCurve3 ( a , new THREE . Vector3 ( ) . addVectors ( a , b ) . multiplyScalar ( 0.5 ) . add ( off ) , b ) ;
168- const tube = new THREE . Mesh ( new THREE . TubeGeometry ( curve , 20 , 0.035 , 8 , false ) , matEdge ) ;
169- g . add ( tube ) ;
170- } ;
171- connectCurve ( positions [ 0 ] , positions [ 2 ] , new THREE . Vector3 ( 0 , 0.2 , 0 ) ) ;
172- connectCurve ( positions [ 1 ] , positions [ 3 ] , new THREE . Vector3 ( 0 , 0.25 , 0.15 ) ) ;
173- connectCurve ( positions [ 0 ] , positions [ 4 ] , new THREE . Vector3 ( 0.1 , 0.15 , 0 ) ) ;
174- connectCurve ( positions [ 1 ] , positions [ 4 ] , new THREE . Vector3 ( - 0.1 , 0.15 , 0 ) ) ;
175- connectCurve ( positions [ 2 ] , positions [ 3 ] , new THREE . Vector3 ( 0 , - 0.15 , 0 ) ) ;
176- connectCurve ( positions [ 6 ] , positions [ 7 ] , new THREE . Vector3 ( 0.1 , - 0.05 , 0.1 ) ) ;
177- const pivot = centerAndScale ( g ) ;
178- loaded = pivot ; group . add ( pivot ) ;
179- } else if ( preset === "workshop-tools" ) {
180- const g = new THREE . Group ( ) ;
181- const matMetal = new THREE . MeshPhysicalMaterial ( { color : 0x9CA3AF , roughness : 0.4 , metalness : 0.7 , clearcoat : 0.1 } ) ;
182- const matWood = new THREE . MeshPhysicalMaterial ( { color : 0x8B5E3C , roughness : 0.65 , metalness : 0.12 } ) ;
183- const handle = new THREE . Mesh ( new RoundedBoxGeometry ( 0.15 , 0.7 , 0.15 , 3 , 0.05 ) , matWood ) ;
184- handle . position . set ( - 0.4 , 0.0 , 0 ) ;
185- const head = new THREE . Mesh ( new RoundedBoxGeometry ( 0.45 , 0.18 , 0.18 , 3 , 0.06 ) , matMetal ) ;
186- head . position . set ( - 0.4 , 0.35 , 0 ) ;
187- g . add ( handle , head ) ;
188- const jaw = new THREE . Mesh ( new RoundedBoxGeometry ( 0.45 , 0.12 , 0.18 , 3 , 0.06 ) , matMetal ) ;
189- jaw . position . set ( 0.35 , 0.18 , 0 ) ;
190- const shaft = new THREE . Mesh ( new RoundedBoxGeometry ( 0.12 , 0.6 , 0.12 , 3 , 0.05 ) , matMetal ) ;
191- shaft . position . set ( 0.35 , - 0.15 , 0 ) ;
192- g . add ( jaw , shaft ) ;
193- const screwdriverHandle = new THREE . Mesh ( new RoundedBoxGeometry ( 0.12 , 0.35 , 0.12 , 3 , 0.05 ) , new THREE . MeshPhysicalMaterial ( { color : 0xEF4444 , roughness : 0.5 , metalness : 0.2 } ) ) ;
194- screwdriverHandle . position . set ( 0.05 , - 0.1 , 0.25 ) ;
195- const screwdriverShaft = new THREE . Mesh ( new THREE . CylinderGeometry ( 0.035 , 0.035 , 0.35 , 12 ) , matMetal ) ;
196- screwdriverShaft . position . set ( 0.05 , 0.15 , 0.25 ) ;
197- g . add ( screwdriverHandle , screwdriverShaft ) ;
198- const pivot = centerAndScale ( g ) ;
199- loaded = pivot ; group . add ( pivot ) ;
200- } else if ( preset === "picture-frame" ) {
201- const g = new THREE . Group ( ) ;
202- const matFrame = new THREE . MeshPhysicalMaterial ( { color : 0xA78BFA , roughness : 0.45 , metalness : 0.25 , clearcoat : 0.15 } ) ;
203- const frame = new THREE . Mesh ( new RoundedBoxGeometry ( 1.3 , 1.0 , 0.12 , 4 , 0.12 ) , matFrame ) ;
204- const inner = new THREE . Mesh ( new RoundedBoxGeometry ( 0.9 , 0.6 , 0.02 , 3 , 0.03 ) , new THREE . MeshPhysicalMaterial ( { color : 0x0f172a , roughness : 0.85 } ) ) ;
205- inner . position . z = 0.06 ;
206- g . add ( frame , inner ) ;
207- const matBorder = new THREE . MeshPhysicalMaterial ( { color : 0xffffff , roughness : 0.9 } ) ;
208- const mat1 = new THREE . Mesh ( new RoundedBoxGeometry ( 1.0 , 0.8 , 0.015 , 2 , 0.02 ) , matBorder ) ;
209- mat1 . position . z = 0.055 ;
210- g . add ( mat1 ) ;
211- const pivot = centerAndScale ( g ) ;
212- loaded = pivot ; group . add ( pivot ) ;
213- } else if ( preset === "old-book" ) {
214- const g = new THREE . Group ( ) ;
215- const cover = new THREE . Mesh ( new RoundedBoxGeometry ( 1.1 , 0.7 , 0.2 , 4 , 0.08 ) , new THREE . MeshPhysicalMaterial ( { color : 0x6B4E2E , roughness : 0.7 , metalness : 0.08 } ) ) ;
216- const pages = new THREE . Mesh ( new RoundedBoxGeometry ( 1.02 , 0.62 , 0.18 , 2 , 0.05 ) , new THREE . MeshPhysicalMaterial ( { color : 0xF2E9D8 , roughness : 0.95 } ) ) ;
217- pages . position . set ( 0 , 0 , 0.01 ) ;
218- g . add ( cover , pages ) ;
219- const strap = new THREE . Mesh ( new RoundedBoxGeometry ( 1.15 , 0.08 , 0.05 , 2 , 0.02 ) , new THREE . MeshPhysicalMaterial ( { color : 0x3f3f46 , roughness : 0.6 } ) ) ;
220- strap . position . set ( 0 , 0 , 0.12 ) ;
221- g . add ( strap ) ;
222- const pivot = centerAndScale ( g ) ;
223- loaded = pivot ; group . add ( pivot ) ;
224- } else if ( preset === "airplane" ) {
225- const g = new THREE . Group ( ) ;
226- const mat = new THREE . MeshPhysicalMaterial ( { color : 0x60A5FA , metalness : 0.45 , roughness : 0.35 , clearcoat : 0.2 } ) ;
227- const body = new THREE . Mesh ( new THREE . CapsuleGeometry ( 0.22 , 1.1 , 8 , 16 ) , mat ) ;
228- body . rotation . z = Math . PI / 2 ;
229- const wing = new THREE . Mesh ( new RoundedBoxGeometry ( 1.0 , 0.06 , 0.3 , 3 , 0.06 ) , mat ) ;
230- const tail = new THREE . Mesh ( new RoundedBoxGeometry ( 0.3 , 0.06 , 0.22 , 3 , 0.05 ) , mat ) ;
231- wing . position . set ( 0 , 0 , 0 ) ;
232- tail . position . set ( - 0.45 , 0.18 , 0 ) ;
233- const finV = new THREE . Mesh ( new RoundedBoxGeometry ( 0.18 , 0.25 , 0.06 , 2 , 0.04 ) , mat ) ;
234- finV . position . set ( - 0.5 , 0.22 , 0 ) ;
235- const finH = new THREE . Mesh ( new RoundedBoxGeometry ( 0.25 , 0.06 , 0.15 , 2 , 0.04 ) , mat ) ;
236- finH . position . set ( - 0.52 , 0.1 , 0 ) ;
237- const propHub = new THREE . Mesh ( new THREE . CylinderGeometry ( 0.05 , 0.05 , 0.1 , 12 ) , mat ) ;
238- propHub . rotation . x = Math . PI / 2 ;
239- propHub . position . set ( 0.55 , 0 , 0 ) ;
240- const blade1 = new THREE . Mesh ( new RoundedBoxGeometry ( 0.02 , 0.35 , 0.08 , 2 , 0.01 ) , mat ) ;
241- blade1 . position . set ( 0.55 , 0.18 , 0 ) ;
242- const blade2 = blade1 . clone ( ) ;
243- blade2 . position . set ( 0.55 , - 0.18 , 0 ) ;
244- g . add ( body , wing , tail , finV , finH , propHub , blade1 , blade2 ) ;
245- const pivot = centerAndScale ( g ) ;
246- loaded = pivot ; group . add ( pivot ) ;
247- } else if ( preset === "technician" ) {
248- const g = new THREE . Group ( ) ;
249- const matSuit = new THREE . MeshPhysicalMaterial ( { color : 0x2563EB , roughness : 0.45 , metalness : 0.25 } ) ;
250- const matSkin = new THREE . MeshPhysicalMaterial ( { color : 0xF2C7A5 , roughness : 0.65 , metalness : 0.08 } ) ;
251- const matTool = new THREE . MeshPhysicalMaterial ( { color : 0x9CA3AF , roughness : 0.5 , metalness : 0.7 } ) ;
252- const torso = new THREE . Mesh ( new RoundedBoxGeometry ( 0.6 , 0.8 , 0.3 , 3 , 0.1 ) , matSuit ) ;
253- const head = new THREE . Mesh ( new THREE . SphereGeometry ( 0.2 , 12 , 12 ) , matSkin ) ;
254- head . position . set ( 0 , 0.6 , 0 ) ;
255- const legL = new THREE . Mesh ( new RoundedBoxGeometry ( 0.18 , 0.6 , 0.18 , 2 , 0.06 ) , matSuit ) ;
256- legL . position . set ( - 0.15 , - 0.6 , 0 ) ;
257- const legR = legL . clone ( ) ; legR . position . x = 0.15 ;
258- const armL = new THREE . Mesh ( new RoundedBoxGeometry ( 0.16 , 0.5 , 0.16 , 2 , 0.06 ) , matSuit ) ;
259- armL . position . set ( - 0.45 , 0.0 , 0 ) ;
260- const armR = armL . clone ( ) ; armR . position . x = 0.45 ;
261- const helmet = new THREE . Mesh ( new THREE . SphereGeometry ( 0.22 , 12 , 12 , 0 , Math . PI * 2 , 0 , Math . PI / 2 ) , matSuit ) ;
262- helmet . position . set ( 0 , 0.7 , 0 ) ;
263- const belt = new THREE . Mesh ( new RoundedBoxGeometry ( 0.62 , 0.12 , 0.32 , 2 , 0.04 ) , new THREE . MeshPhysicalMaterial ( { color : 0x1f2937 , roughness : 0.6 } ) ) ;
264- belt . position . set ( 0 , - 0.1 , 0 ) ;
265- const tablet = new THREE . Mesh ( new RoundedBoxGeometry ( 0.35 , 0.22 , 0.02 , 2 , 0.03 ) , matTool ) ;
266- tablet . position . set ( 0.5 , 0.05 , 0.15 ) ;
267- g . add ( torso , head , legL , legR , armL , armR , helmet , belt , tablet ) ;
268- const pivot = centerAndScale ( g ) ;
269- loaded = pivot ; group . add ( pivot ) ;
270- } else {
271- const loader = new GLTFLoader ( ) ;
272- const slug = ( title || "" ) . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 ] + / g, "-" ) . replace ( / ( ^ - | - $ ) / g, "" ) ;
273- const src = modelPath || `/${ slug } .glb` ;
274- loader . load (
275- src ,
276- ( gltf ) => {
277- const pivot = centerAndScale ( gltf . scene ) ;
278- loaded = pivot ;
279- group . add ( pivot ) ;
280- } ,
281- undefined ,
282- ( ) => {
283- const geo = new THREE . TorusKnotGeometry ( 0.6 , 0.2 , 120 , 16 ) ;
284- const mat = new THREE . MeshStandardMaterial ( { color : 0x8b5cf6 , metalness : 0.5 , roughness : 0.3 } ) ;
285- loaded = new THREE . Mesh ( geo , mat ) ;
286- group . add ( loaded as THREE . Object3D ) ;
287- }
288- ) ;
289- }
290-
291- const resize = ( ) => {
292- const w = container . clientWidth ;
293- const h = w ; // keep square
294- renderer . setSize ( w , h , false ) ;
295- camera . aspect = w / h ;
296- camera . updateProjectionMatrix ( ) ;
297- } ;
298- resize ( ) ;
299- const ro = new ResizeObserver ( resize ) ;
300- ro . observe ( container ) ;
301-
302- let raf = 0 ;
303- const tick = ( ) => {
304- if ( group ) {
305- group . rotation . y += 0.01 ;
306- }
307- renderer . render ( scene , camera ) ;
308- raf = requestAnimationFrame ( tick ) ;
309- } ;
310- raf = requestAnimationFrame ( tick ) ;
311-
312- return ( ) => {
313- cancelAnimationFrame ( raf ) ;
314- ro . disconnect ( ) ;
315- if ( loaded && loaded . parent ) loaded . parent . remove ( loaded ) ;
316- renderer . dispose ( ) ;
317- scene . traverse ( ( obj : any ) => {
318- if ( obj . geometry ) obj . geometry . dispose ?.( ) ;
319- if ( obj . material ) {
320- if ( Array . isArray ( obj . material ) ) obj . material . forEach ( ( m : any ) => m . dispose ?.( ) ) ;
321- else obj . material . dispose ?.( ) ;
322- }
323- } ) ;
324- } ;
325- } , [ title , modelPath ] ) ;
326-
32736 return (
32837 < div className = "relative" >
32938 < Lines />
33039 < Card className = "w-full border-none shadow-none bg-card/60 backdrop-blur rounded-xl overflow-hidden h-full flex flex-col" >
33140 < div className = "p-4" >
33241 < div
333- ref = { containerRef }
334- className = "w-full aspect-square rounded-lg border border-border/50 bg-muted/10 backdrop-blur supports-[backdrop-filter]:bg-muted/20 overflow-hidden"
335- >
336- < canvas ref = { canvasRef } className = "block w-full h-full" />
337- </ div >
42+ className = { cn (
43+ "w-full h-2 rounded-md" ,
44+ colorClass ?? "bg-primary"
45+ ) }
46+ / >
33847 </ div >
33948 < CardHeader className = "pt-0" >
34049 < div className = "flex items-center gap-3" >
0 commit comments