Long Hoang commited on
Commit
61176cc
·
1 Parent(s): d1eac9d

add viewer

Browse files
viewer/glb-viewer/.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
viewer/glb-viewer/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GLB Model Viewer
2
+
3
+ A web-based GLB model viewer that loads 3D models from Supabase Storage.
4
+
5
+ ## Features
6
+
7
+ - Load and display GLB/GLTF 3D models from Supabase Storage
8
+ - Interactive 3D controls (orbit, zoom, pan)
9
+ - Grid floor with environment lighting
10
+ - Load models via URL parameters or UI input
11
+
12
+ ## Setup
13
+
14
+ ### 1. Install dependencies
15
+
16
+ ```bash
17
+ npm install
18
+ ```
19
+
20
+ ### 2. Configure Supabase
21
+
22
+ Update `.env.local` with your Supabase credentials:
23
+
24
+ ```env
25
+ NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
26
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
27
+ ```
28
+
29
+ You can find these values in your Supabase project dashboard under Settings > API.
30
+
31
+ ### 3. Set up Supabase Storage
32
+
33
+ 1. Go to your Supabase project dashboard
34
+ 2. Navigate to Storage
35
+ 3. Create a new bucket (e.g., "models")
36
+ 4. Upload your GLB files to the bucket
37
+ 5. Set the bucket to public if you want models to be accessible without authentication
38
+
39
+ ### 4. Run the development server
40
+
41
+ ```bash
42
+ npm run dev
43
+ ```
44
+
45
+ Open [http://localhost:3000](http://localhost:3000) to view the application.
46
+
47
+ ## Usage
48
+
49
+ ### Via UI
50
+
51
+ 1. Enter the bucket name (e.g., "models")
52
+ 2. Enter the file path (e.g., "example.glb" or "folder/model.glb")
53
+ 3. Click "Load Model"
54
+
55
+ ### Via URL Parameters
56
+
57
+ You can directly load a model by passing URL parameters:
58
+
59
+ ```
60
+ http://localhost:3000?bucket=models&file=example.glb
61
+ ```
62
+
63
+ ## 3D Controls
64
+
65
+ - **Left Mouse**: Rotate the view
66
+ - **Right Mouse**: Pan the view
67
+ - **Scroll Wheel**: Zoom in/out
68
+
69
+ ## Build for Production
70
+
71
+ ```bash
72
+ npm run build
73
+ npm run start
74
+ ```
75
+
76
+ ## Technologies Used
77
+
78
+ - Next.js 15
79
+ - React Three Fiber (Three.js React wrapper)
80
+ - @react-three/drei (Three.js helpers)
81
+ - Supabase Client
82
+ - Tailwind CSS
83
+ - TypeScript
viewer/glb-viewer/SUPABASE_SETUP.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase Configuration Guide
2
+
3
+ ## Important: Anon Key vs Service Key
4
+
5
+ The GLB viewer runs in the browser (client-side), so it requires the **anon key** for security reasons.
6
+
7
+ **Never expose your service key in client-side code!**
8
+
9
+ ## Current Configuration
10
+
11
+ Your Supabase URL has been added to `.env.local`:
12
+ - URL: `https://qmyqfzwhwocufnplhqoe.supabase.co`
13
+ - Bucket: `instantsplat-outputs`
14
+
15
+ ## Getting Your Anon Key
16
+
17
+ 1. Go to your [Supabase Dashboard](https://supabase.com/dashboard)
18
+ 2. Select your project
19
+ 3. Navigate to **Settings** → **API**
20
+ 4. Find the **anon public** key (NOT the service_role key)
21
+ 5. Copy the anon key and add it to `.env.local`:
22
+ ```
23
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
24
+ ```
25
+
26
+ ## Bucket Configuration
27
+
28
+ Your bucket `instantsplat-outputs` needs to be configured for public access:
29
+
30
+ 1. Go to **Storage** in your Supabase dashboard
31
+ 2. Find the `instantsplat-outputs` bucket
32
+ 3. Click on the bucket settings
33
+ 4. Ensure it's set to **Public** if you want models accessible without authentication
34
+
35
+ ## Security Notes
36
+
37
+ - **Anon Key**: Safe for client-side use, provides limited access based on RLS policies
38
+ - **Service Key**: Backend only, bypasses all Row Level Security - NEVER expose this in browser code
39
+
40
+ ## Testing
41
+
42
+ After adding your anon key, restart the development server:
43
+ ```bash
44
+ npm run dev
45
+ ```
46
+
47
+ Then access a model:
48
+ ```
49
+ http://localhost:3000?bucket=instantsplat-outputs&file=your-model.glb
50
+ ```
viewer/glb-viewer/app/favicon.ico ADDED
viewer/glb-viewer/app/globals.css ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --background: #0a0a0a;
18
+ --foreground: #ededed;
19
+ }
20
+ }
21
+
22
+ body {
23
+ background: var(--background);
24
+ color: var(--foreground);
25
+ font-family: Arial, Helvetica, sans-serif;
26
+ }
viewer/glb-viewer/app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "GLB Model Viewer",
17
+ description: "View 3D GLB models from Supabase Storage",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en">
27
+ <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
+ >
30
+ {children}
31
+ </body>
32
+ </html>
33
+ );
34
+ }
viewer/glb-viewer/app/page.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import { getSupabaseClient } from '@/lib/supabase'
6
+
7
+ // Dynamically import the GLBViewer to avoid SSR issues with Three.js
8
+ const GLBViewer = dynamic(() => import('@/components/GLBViewer'), { ssr: false })
9
+ const SimpleGLBViewer = dynamic(() => import('@/components/SimpleGLBViewer'), { ssr: false })
10
+
11
+ export default function Home() {
12
+ const [bucketName, setBucketName] = useState(process.env.NEXT_PUBLIC_DEFAULT_BUCKET || 'models')
13
+ const [filePath, setFilePath] = useState('')
14
+ const [modelUrl, setModelUrl] = useState<string | null>(null)
15
+ const [loading, setLoading] = useState(false)
16
+ const [error, setError] = useState<string | null>(null)
17
+ const [useSimpleViewer, setUseSimpleViewer] = useState(true) // Use simple viewer by default
18
+
19
+ // Check for URL parameters on mount
20
+ useEffect(() => {
21
+ const params = new URLSearchParams(window.location.search)
22
+ const bucket = params.get('bucket')
23
+ const file = params.get('file')
24
+
25
+ if (bucket) setBucketName(bucket)
26
+ if (file) {
27
+ setFilePath(file)
28
+ // Auto-load if both params are present
29
+ if (bucket && file) {
30
+ loadModel(bucket, file)
31
+ }
32
+ }
33
+ }, [])
34
+
35
+ const loadModel = async (bucket: string, path: string) => {
36
+ setLoading(true)
37
+ setError(null)
38
+
39
+ try {
40
+ const supabase = getSupabaseClient()
41
+
42
+ if (!supabase) {
43
+ setError('Supabase is not configured. Please set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in your .env.local file.')
44
+ return
45
+ }
46
+
47
+ // Get public URL for the file in Supabase storage
48
+ const { data } = supabase.storage
49
+ .from(bucket)
50
+ .getPublicUrl(path)
51
+
52
+ console.log('Supabase URL generated:', data?.publicUrl)
53
+
54
+ if (data?.publicUrl) {
55
+ setModelUrl(data.publicUrl)
56
+
57
+ // Test if URL is accessible
58
+ fetch(data.publicUrl, { method: 'HEAD' })
59
+ .then(response => {
60
+ if (!response.ok) {
61
+ console.error('Model file not accessible:', response.status, response.statusText)
62
+ setError(`Model file not accessible (${response.status}). Check if the file exists and bucket is public.`)
63
+ } else {
64
+ console.log('Model file is accessible')
65
+ }
66
+ })
67
+ .catch(err => {
68
+ console.error('Failed to verify model URL:', err)
69
+ })
70
+ } else {
71
+ setError('Failed to get public URL for the model')
72
+ }
73
+ } catch (err) {
74
+ setError(`Error loading model: ${err instanceof Error ? err.message : 'Unknown error'}`)
75
+ } finally {
76
+ setLoading(false)
77
+ }
78
+ }
79
+
80
+ const handleLoad = () => {
81
+ if (bucketName && filePath) {
82
+ loadModel(bucketName, filePath)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="flex flex-col h-screen bg-zinc-900">
88
+ {/* Control Panel */}
89
+ <div className="bg-zinc-800 border-b border-zinc-700 p-4">
90
+ <div className="max-w-6xl mx-auto">
91
+ <h1 className="text-2xl font-bold text-white mb-4">GLB Model Viewer</h1>
92
+
93
+ <div className="flex flex-col sm:flex-row gap-4">
94
+ <div className="flex-1">
95
+ <label className="block text-sm font-medium text-zinc-300 mb-1">
96
+ Storage Bucket
97
+ </label>
98
+ <input
99
+ type="text"
100
+ value={bucketName}
101
+ onChange={(e) => setBucketName(e.target.value)}
102
+ placeholder="e.g., models"
103
+ className="w-full px-3 py-2 bg-zinc-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
104
+ />
105
+ </div>
106
+
107
+ <div className="flex-1">
108
+ <label className="block text-sm font-medium text-zinc-300 mb-1">
109
+ File Path
110
+ </label>
111
+ <input
112
+ type="text"
113
+ value={filePath}
114
+ onChange={(e) => setFilePath(e.target.value)}
115
+ placeholder="e.g., example.glb or folder/model.glb"
116
+ className="w-full px-3 py-2 bg-zinc-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
117
+ />
118
+ </div>
119
+
120
+ <div className="flex items-end">
121
+ <button
122
+ onClick={handleLoad}
123
+ disabled={!bucketName || !filePath || loading}
124
+ className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-zinc-600 disabled:cursor-not-allowed transition-colors"
125
+ >
126
+ {loading ? 'Loading...' : 'Load Model'}
127
+ </button>
128
+ </div>
129
+ </div>
130
+
131
+ {error && (
132
+ <div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-md text-red-200">
133
+ {error}
134
+ </div>
135
+ )}
136
+
137
+ {modelUrl && (
138
+ <div className="mt-4 p-3 bg-green-900/50 border border-green-700 rounded-md text-green-200">
139
+ Model loaded from: {modelUrl}
140
+ </div>
141
+ )}
142
+
143
+ <div className="mt-4 flex items-center gap-4">
144
+ <label className="flex items-center gap-2 text-zinc-300 cursor-pointer">
145
+ <input
146
+ type="checkbox"
147
+ checked={useSimpleViewer}
148
+ onChange={(e) => setUseSimpleViewer(e.target.checked)}
149
+ className="rounded"
150
+ />
151
+ <span>Use Simple Viewer (more stable)</span>
152
+ </label>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ {/* 3D Viewer */}
158
+ <div className="flex-1 relative">
159
+ {modelUrl ? (
160
+ useSimpleViewer ? (
161
+ <SimpleGLBViewer modelUrl={modelUrl} />
162
+ ) : (
163
+ <GLBViewer modelUrl={modelUrl} />
164
+ )
165
+ ) : (
166
+ <div className="h-full flex items-center justify-center text-zinc-400">
167
+ <div className="text-center">
168
+ <svg
169
+ className="mx-auto h-12 w-12 mb-4"
170
+ fill="none"
171
+ viewBox="0 0 24 24"
172
+ stroke="currentColor"
173
+ >
174
+ <path
175
+ strokeLinecap="round"
176
+ strokeLinejoin="round"
177
+ strokeWidth={2}
178
+ d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
179
+ />
180
+ </svg>
181
+ <p className="text-lg">Enter a bucket name and file path to load a GLB model</p>
182
+ <p className="text-sm mt-2 text-zinc-500">
183
+ You can also use URL parameters: ?bucket=models&file=example.glb
184
+ </p>
185
+ </div>
186
+ </div>
187
+ )}
188
+ </div>
189
+ </div>
190
+ )
191
+ }
viewer/glb-viewer/components/GLBViewer.tsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { Suspense, useEffect, useState } from 'react'
4
+ import { Canvas } from '@react-three/fiber'
5
+ import { OrbitControls, PerspectiveCamera, Environment, useGLTF, Center, Grid, Html } from '@react-three/drei'
6
+ import * as THREE from 'three'
7
+
8
+ interface ModelProps {
9
+ url: string
10
+ }
11
+
12
+ function Model({ url }: ModelProps) {
13
+ const gltf = useGLTF(url)
14
+
15
+ // Dispose of the model properly on unmount
16
+ useEffect(() => {
17
+ return () => {
18
+ if (gltf) {
19
+ gltf.scene.traverse((child) => {
20
+ if (child instanceof THREE.Mesh) {
21
+ child.geometry?.dispose()
22
+ if (child.material) {
23
+ if (Array.isArray(child.material)) {
24
+ child.material.forEach(material => material.dispose())
25
+ } else {
26
+ child.material.dispose()
27
+ }
28
+ }
29
+ }
30
+ })
31
+ }
32
+ }
33
+ }, [gltf])
34
+
35
+ return (
36
+ <Center>
37
+ <primitive object={gltf.scene} />
38
+ </Center>
39
+ )
40
+ }
41
+
42
+ interface GLBViewerProps {
43
+ modelUrl: string
44
+ }
45
+
46
+ function LoadingFallback() {
47
+ return (
48
+ <Html center>
49
+ <div className="text-white">Loading model...</div>
50
+ </Html>
51
+ )
52
+ }
53
+
54
+ function ErrorFallback() {
55
+ return (
56
+ <Html center>
57
+ <div className="text-red-500">Failed to load model</div>
58
+ </Html>
59
+ )
60
+ }
61
+
62
+ export default function GLBViewer({ modelUrl }: GLBViewerProps) {
63
+ const [error, setError] = useState(false)
64
+ const [contextLost, setContextLost] = useState(false)
65
+
66
+ // Handle WebGL context loss
67
+ const handleCreated = (state: any) => {
68
+ const gl = state.gl
69
+ const canvas = gl.domElement
70
+
71
+ const handleContextLost = (event: Event) => {
72
+ event.preventDefault()
73
+ setContextLost(true)
74
+ console.log('WebGL context lost - attempting to restore...')
75
+ }
76
+
77
+ const handleContextRestored = () => {
78
+ setContextLost(false)
79
+ console.log('WebGL context restored')
80
+ }
81
+
82
+ canvas.addEventListener('webglcontextlost', handleContextLost, false)
83
+ canvas.addEventListener('webglcontextrestored', handleContextRestored, false)
84
+
85
+ return () => {
86
+ canvas.removeEventListener('webglcontextlost', handleContextLost)
87
+ canvas.removeEventListener('webglcontextrestored', handleContextRestored)
88
+ }
89
+ }
90
+
91
+ if (contextLost) {
92
+ return (
93
+ <div className="w-full h-full flex items-center justify-center bg-zinc-800">
94
+ <div className="text-center text-white">
95
+ <p>WebGL context lost. Please refresh the page.</p>
96
+ <button
97
+ onClick={() => window.location.reload()}
98
+ className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700"
99
+ >
100
+ Refresh Page
101
+ </button>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ return (
108
+ <div className="w-full h-full">
109
+ <Canvas
110
+ shadows
111
+ onCreated={handleCreated}
112
+ gl={{
113
+ antialias: true,
114
+ alpha: true,
115
+ powerPreference: "high-performance",
116
+ preserveDrawingBuffer: true,
117
+ failIfMajorPerformanceCaveat: false
118
+ }}
119
+ onError={() => setError(true)}
120
+ >
121
+ <PerspectiveCamera makeDefault position={[5, 5, 5]} fov={50} />
122
+
123
+ {/* Lighting */}
124
+ <ambientLight intensity={0.5} />
125
+ <directionalLight
126
+ position={[10, 10, 5]}
127
+ intensity={1}
128
+ castShadow
129
+ shadow-mapSize-width={2048}
130
+ shadow-mapSize-height={2048}
131
+ />
132
+
133
+ {/* Environment for reflections */}
134
+ <Environment preset="city" />
135
+
136
+ {/* Grid floor */}
137
+ <Grid
138
+ args={[10, 10]}
139
+ cellSize={0.5}
140
+ cellThickness={0.5}
141
+ cellColor="#6f6f6f"
142
+ sectionSize={3}
143
+ sectionThickness={1}
144
+ sectionColor="#9d4b4b"
145
+ fadeDistance={30}
146
+ fadeStrength={1}
147
+ followCamera={false}
148
+ infiniteGrid={true}
149
+ />
150
+
151
+ {/* Model */}
152
+ <Suspense fallback={<LoadingFallback />}>
153
+ {!error ? <Model url={modelUrl} /> : <ErrorFallback />}
154
+ </Suspense>
155
+
156
+ {/* Camera Controls */}
157
+ <OrbitControls
158
+ enablePan={true}
159
+ enableZoom={true}
160
+ enableRotate={true}
161
+ autoRotate={false}
162
+ autoRotateSpeed={2}
163
+ />
164
+ </Canvas>
165
+ </div>
166
+ )
167
+ }
viewer/glb-viewer/components/SimpleGLBViewer.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { Suspense, useRef, useEffect } from 'react'
4
+ import { Canvas, useFrame } from '@react-three/fiber'
5
+ import { OrbitControls, useGLTF } from '@react-three/drei'
6
+ import * as THREE from 'three'
7
+
8
+ interface ModelProps {
9
+ url: string
10
+ }
11
+
12
+ function Model({ url }: ModelProps) {
13
+ const { scene } = useGLTF(url)
14
+ const ref = useRef<THREE.Group>(null)
15
+
16
+ // Optional: Auto-rotate the model
17
+ useFrame((state, delta) => {
18
+ if (ref.current) {
19
+ ref.current.rotation.y += delta * 0.2
20
+ }
21
+ })
22
+
23
+ // Center and scale the model
24
+ useEffect(() => {
25
+ if (scene) {
26
+ const box = new THREE.Box3().setFromObject(scene)
27
+ const center = box.getCenter(new THREE.Vector3())
28
+ const size = box.getSize(new THREE.Vector3())
29
+
30
+ const maxDim = Math.max(size.x, size.y, size.z)
31
+ const scale = 5 / maxDim
32
+
33
+ scene.scale.set(scale, scale, scale)
34
+ scene.position.sub(center.multiplyScalar(scale))
35
+ }
36
+ }, [scene])
37
+
38
+ return <primitive ref={ref} object={scene} />
39
+ }
40
+
41
+ interface SimpleGLBViewerProps {
42
+ modelUrl: string
43
+ }
44
+
45
+ export default function SimpleGLBViewer({ modelUrl }: SimpleGLBViewerProps) {
46
+ console.log('Loading model from:', modelUrl)
47
+
48
+ return (
49
+ <div className="w-full h-full bg-gradient-to-b from-zinc-800 to-zinc-900">
50
+ <Canvas
51
+ camera={{ position: [0, 0, 10], fov: 45 }}
52
+ gl={{
53
+ antialias: false, // Reduce GPU load
54
+ powerPreference: "default", // Use default power mode
55
+ failIfMajorPerformanceCaveat: false
56
+ }}
57
+ >
58
+ <ambientLight intensity={0.5} />
59
+ <pointLight position={[10, 10, 10]} intensity={1} />
60
+ <directionalLight position={[-10, 10, 5]} intensity={0.5} />
61
+
62
+ <Suspense fallback={null}>
63
+ <Model url={modelUrl} />
64
+ </Suspense>
65
+
66
+ <OrbitControls
67
+ enableDamping={true}
68
+ dampingFactor={0.05}
69
+ minDistance={3}
70
+ maxDistance={20}
71
+ />
72
+
73
+ <gridHelper args={[20, 20]} />
74
+ </Canvas>
75
+ </div>
76
+ )
77
+ }
viewer/glb-viewer/eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
viewer/glb-viewer/lib/supabase.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient, SupabaseClient } from '@supabase/supabase-js'
2
+
3
+ let supabaseClient: SupabaseClient | null = null
4
+
5
+ export function getSupabaseClient() {
6
+ if (!supabaseClient) {
7
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
8
+ const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
9
+
10
+ if (!supabaseUrl || !supabaseAnonKey) {
11
+ return null
12
+ }
13
+
14
+ try {
15
+ supabaseClient = createClient(supabaseUrl, supabaseAnonKey)
16
+ } catch (error) {
17
+ console.error('Failed to create Supabase client:', error)
18
+ return null
19
+ }
20
+ }
21
+
22
+ return supabaseClient
23
+ }
viewer/glb-viewer/next.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
viewer/glb-viewer/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
viewer/glb-viewer/package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "glb-viewer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@react-three/drei": "^10.7.7",
13
+ "@react-three/fiber": "^9.4.0",
14
+ "@supabase/supabase-js": "^2.84.0",
15
+ "next": "16.0.3",
16
+ "react": "19.2.0",
17
+ "react-dom": "19.2.0",
18
+ "three": "^0.181.2"
19
+ },
20
+ "devDependencies": {
21
+ "@tailwindcss/postcss": "^4",
22
+ "@types/node": "^20",
23
+ "@types/react": "^19",
24
+ "@types/react-dom": "^19",
25
+ "@types/three": "^0.181.0",
26
+ "eslint": "^9",
27
+ "eslint-config-next": "16.0.3",
28
+ "tailwindcss": "^4",
29
+ "typescript": "^5"
30
+ }
31
+ }
viewer/glb-viewer/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
viewer/glb-viewer/public/file.svg ADDED
viewer/glb-viewer/public/globe.svg ADDED
viewer/glb-viewer/public/next.svg ADDED
viewer/glb-viewer/public/vercel.svg ADDED
viewer/glb-viewer/public/window.svg ADDED
viewer/glb-viewer/tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }