Merge branch 'feat/AEB-XX-3d-model-viewer' into 'main'
feat / AEB-XX Реализован базовый 3D просмотрщик моделей с поддержкой GLTF See merge request wedeving/aerbim-www!1
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -90,3 +90,6 @@ media/
|
||||
docker-compose.override.yml
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Cached mesh data files
|
||||
frontend/data/*.json
|
||||
42
frontend/app/api/cache-mesh-data/route.ts
Normal file
42
frontend/app/api/cache-mesh-data/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
console.log('API: Received request with fileName:', body.fileName)
|
||||
|
||||
const { fileName, data } = body
|
||||
|
||||
const dataDir = join(process.cwd(), 'data')
|
||||
const filePath = join(dataDir, fileName)
|
||||
|
||||
console.log('API: Writing to:', filePath)
|
||||
|
||||
try {
|
||||
await mkdir(dataDir, { recursive: true })
|
||||
console.log('API: Data directory created/verified')
|
||||
} catch (error) {
|
||||
console.log('API: Data directory already exists')
|
||||
}
|
||||
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8')
|
||||
console.log('API: File written successfully')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Mesh data cached successfully',
|
||||
fileName
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('API: Error caching mesh data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to cache mesh data: ${error}`
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
|
||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Aerbim - 3D Building Sensor Dashboard",
|
||||
description: "Aerbim является дашбордом для визуализации показаний датчиков в 3D модели здания",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,103 +1,60 @@
|
||||
import Image from "next/image";
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import ModelViewer from '../components/ModelViewer'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
const [modelInfo, setModelInfo] = useState<{
|
||||
meshes: unknown[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
} | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleModelLoaded = (data: {
|
||||
meshes: unknown[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => {
|
||||
setModelInfo(data)
|
||||
setError(null)
|
||||
console.log('Model loaded successfully:', data)
|
||||
}
|
||||
|
||||
const handleError = (errorMessage: string) => {
|
||||
setError(errorMessage)
|
||||
setModelInfo(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen relative">
|
||||
<ModelViewer
|
||||
modelPath="/models/EXPO_АР_PostRecon_level.gltf"
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleError}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-4 left-4 right-4 md:left-4 md:right-auto md:w-80 bg-red-600/90 text-white p-4 rounded-lg z-50 text-sm">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelInfo && (
|
||||
<div className="absolute top-4 right-4 bg-black/80 text-white p-4 rounded-lg z-50 text-sm max-w-xs">
|
||||
<h3 className="text-base font-semibold mb-3">EXPO Building Model</h3>
|
||||
|
||||
<div className="text-xs text-gray-300 space-y-1">
|
||||
<div>🖱️ Left click + drag: Rotate</div>
|
||||
<div>🖱️ Right click + drag: Pan</div>
|
||||
<div>🖱️ Scroll: Zoom in/out</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
206
frontend/components/ModelViewer.tsx
Normal file
206
frontend/components/ModelViewer.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import {
|
||||
Engine,
|
||||
Scene,
|
||||
Vector3,
|
||||
HemisphericLight,
|
||||
ArcRotateCamera,
|
||||
MeshBuilder,
|
||||
StandardMaterial,
|
||||
Color3,
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Mesh,
|
||||
Nullable,
|
||||
SceneLoader
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache'
|
||||
|
||||
interface ModelViewerProps {
|
||||
modelPath: string
|
||||
onModelLoaded?: (modelData: {
|
||||
meshes: AbstractMesh[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
modelPath,
|
||||
onModelLoaded,
|
||||
onError
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isDisposedRef.current = false
|
||||
isInitializedRef.current = false
|
||||
return () => {
|
||||
isDisposedRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || isInitializedRef.current) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const engine = new Engine(canvas, true)
|
||||
engineRef.current = engine
|
||||
|
||||
const scene = new Scene(engine)
|
||||
sceneRef.current = scene
|
||||
|
||||
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
||||
|
||||
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
||||
camera.attachControl(canvas, true)
|
||||
camera.lowerRadiusLimit = 2
|
||||
camera.upperRadiusLimit = 200
|
||||
camera.wheelDeltaPercentage = 0.01
|
||||
camera.panningSensibility = 50
|
||||
camera.angularSensibilityX = 1000
|
||||
camera.angularSensibilityY = 1000
|
||||
|
||||
const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene)
|
||||
ambientLight.intensity = 0.4
|
||||
ambientLight.diffuse = new Color3(0.7, 0.7, 0.8)
|
||||
ambientLight.specular = new Color3(0.2, 0.2, 0.3)
|
||||
ambientLight.groundColor = new Color3(0.3, 0.3, 0.4)
|
||||
|
||||
const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene)
|
||||
keyLight.intensity = 0.6
|
||||
keyLight.diffuse = new Color3(1, 1, 0.9)
|
||||
keyLight.specular = new Color3(1, 1, 0.9)
|
||||
|
||||
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
|
||||
fillLight.intensity = 0.3
|
||||
fillLight.diffuse = new Color3(0.8, 0.8, 1)
|
||||
|
||||
engine.runRenderLoop(() => {
|
||||
if (!isDisposedRef.current) {
|
||||
scene.render()
|
||||
}
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
if (!isDisposedRef.current) {
|
||||
engine.resize()
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
isInitializedRef.current = true
|
||||
|
||||
return () => {
|
||||
isDisposedRef.current = true
|
||||
isInitializedRef.current = false
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (engineRef.current) {
|
||||
engineRef.current.dispose()
|
||||
engineRef.current = null
|
||||
}
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !modelPath || isDisposedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!sceneRef.current || isDisposedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
console.log('🚀 Loading GLTF model:', modelPath)
|
||||
|
||||
try {
|
||||
const cacheFileName = getCacheFileName(modelPath)
|
||||
const cachedData = await loadCachedData(cacheFileName)
|
||||
|
||||
if (cachedData) {
|
||||
console.log('📦 Using cached mesh data for analysis')
|
||||
} else {
|
||||
console.log('🔄 No cached data found, parsing scene...')
|
||||
}
|
||||
|
||||
const result = await SceneLoader.ImportMeshAsync(
|
||||
'',
|
||||
modelPath,
|
||||
'',
|
||||
sceneRef.current
|
||||
)
|
||||
|
||||
console.log('✅ GLTF Model loaded successfully!')
|
||||
console.log('📊 Loaded Meshes:', result.meshes.length)
|
||||
|
||||
if (result.meshes.length > 0) {
|
||||
|
||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||
const size = boundingBox.max.subtract(boundingBox.min)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
|
||||
const camera = sceneRef.current!.activeCamera as ArcRotateCamera
|
||||
camera.radius = maxDimension * 2
|
||||
camera.target = result.meshes[0].position
|
||||
|
||||
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
|
||||
|
||||
onModelLoaded?.({
|
||||
meshes: result.meshes,
|
||||
boundingBox: {
|
||||
min: boundingBox.min,
|
||||
max: boundingBox.max
|
||||
}
|
||||
})
|
||||
|
||||
console.log('🎉 Model ready for viewing!')
|
||||
} else {
|
||||
console.warn('⚠️ No meshes found in model')
|
||||
onError?.('No geometry found in model')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading GLTF model:', error)
|
||||
onError?.(`Failed to load model: ${error}`)
|
||||
} finally {
|
||||
if (!isDisposedRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadModel()
|
||||
}, [modelPath])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full outline-none block"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/80 text-white px-8 py-5 rounded-xl z-50 flex items-center gap-3 text-base font-medium">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Loading Model...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^6.44.0",
|
||||
"@babylonjs/loaders": "^6.49.0",
|
||||
"next": "15.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
@@ -51,6 +53,22 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babylonjs/core": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-6.44.0.tgz",
|
||||
"integrity": "sha512-wSLNutd8qADzPJaKSe2JGN0CGG+bPaayttZIIv9U/ziTnw9EL9KmtU4u26CJMtuV5X8zVvVxih+wNipkHpzgug==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@babylonjs/loaders": {
|
||||
"version": "6.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@babylonjs/loaders/-/loaders-6.49.0.tgz",
|
||||
"integrity": "sha512-Cy5t20wnYDFmKgVvgMWQpxo/eq+gND60hWxtDT/HwXB0FMeVMlNRpqOWpFuGcVdM4tYCP9eYrhQxvwAJZC/dlA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@babylonjs/core": "^6.0.0",
|
||||
"babylonjs-gltf2interface": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
|
||||
@@ -2170,6 +2188,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/babylonjs-gltf2interface": {
|
||||
"version": "6.49.0",
|
||||
"resolved": "https://registry.npmjs.org/babylonjs-gltf2interface/-/babylonjs-gltf2interface-6.49.0.tgz",
|
||||
"integrity": "sha512-4qzKCgEayti/YUaeMgAAZxZJlx/kLqXxoC+G8gODYz9wOV9UjnHF09wREZ5cuELzzY/rjSJMqkgDfYbUQIQ6/A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
|
||||
@@ -9,19 +9,21 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^6.44.0",
|
||||
"@babylonjs/loaders": "^6.49.0",
|
||||
"next": "15.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.4.3"
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.3",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
47800
frontend/public/models/EXPO_АР_PostRecon_level.gltf
Normal file
47800
frontend/public/models/EXPO_АР_PostRecon_level.gltf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/public/models/result.bin
Normal file
BIN
frontend/public/models/result.bin
Normal file
Binary file not shown.
168
frontend/utils/meshCache.ts
Normal file
168
frontend/utils/meshCache.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { AbstractMesh } from '@babylonjs/core'
|
||||
|
||||
export interface ParsedMeshData {
|
||||
meshes: unknown[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
metadata: {
|
||||
modelPath: string
|
||||
parsedAt: string
|
||||
totalVertices: number
|
||||
totalIndices: number
|
||||
}
|
||||
}
|
||||
|
||||
export const getCacheFileName = (modelPath: string) => {
|
||||
const fileName = modelPath.split('/').pop()?.replace('.gltf', '') || 'model'
|
||||
return `${fileName}_parsed.json`
|
||||
}
|
||||
|
||||
export const loadCachedData = async (cacheFileName: string): Promise<ParsedMeshData | null> => {
|
||||
try {
|
||||
const response = await fetch(`/data/${cacheFileName}`)
|
||||
if (response.ok) {
|
||||
const cachedData = await response.json()
|
||||
console.log('📦 Loaded cached mesh data:', cachedData.metadata)
|
||||
return cachedData
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ No cached data found, will parse scene')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const extractAllMeshData = (mesh: AbstractMesh) => {
|
||||
const meshData: Record<string, unknown> = {}
|
||||
|
||||
const safeStringify = (obj: unknown): unknown => {
|
||||
try {
|
||||
JSON.stringify(obj)
|
||||
return obj
|
||||
} catch {
|
||||
return '[Circular Reference]'
|
||||
}
|
||||
}
|
||||
|
||||
const extractValue = (value: unknown): unknown => {
|
||||
if (value === null || value === undefined) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return '[Function]'
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const obj = value as Record<string, unknown>
|
||||
|
||||
if (obj.constructor.name === 'Vector3') {
|
||||
return { x: obj.x, y: obj.y, z: obj.z }
|
||||
} else if (obj.constructor.name === 'Quaternion') {
|
||||
return { x: obj.x, y: obj.y, z: obj.z, w: obj.w }
|
||||
} else if (obj.constructor.name === 'Color3') {
|
||||
return { r: obj.r, g: obj.g, b: obj.b }
|
||||
} else if (obj.constructor.name === 'Color4') {
|
||||
return { r: obj.r, g: obj.g, b: obj.b, a: obj.a }
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.map(item => extractValue(item))
|
||||
} else {
|
||||
return safeStringify(value)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
for (const key in mesh) {
|
||||
try {
|
||||
const value = (mesh as unknown as Record<string, unknown>)[key]
|
||||
meshData[key] = extractValue(value)
|
||||
} catch (error) {
|
||||
meshData[key] = '[Error accessing property]'
|
||||
}
|
||||
}
|
||||
|
||||
meshData.geometry = {
|
||||
vertices: mesh.getVerticesData('position') ? Array.from(mesh.getVerticesData('position')!) : [],
|
||||
indices: mesh.getIndices() ? Array.from(mesh.getIndices()!) : [],
|
||||
normals: mesh.getVerticesData('normal') ? Array.from(mesh.getVerticesData('normal')!) : [],
|
||||
uvs: mesh.getVerticesData('uv') ? Array.from(mesh.getVerticesData('uv')!) : [],
|
||||
colors: mesh.getVerticesData('color') ? Array.from(mesh.getVerticesData('color')!) : [],
|
||||
tangents: mesh.getVerticesData('tangent') ? Array.from(mesh.getVerticesData('tangent')!) : [],
|
||||
matricesWeights: mesh.getVerticesData('matricesWeights') ? Array.from(mesh.getVerticesData('matricesWeights')!) : [],
|
||||
matricesIndices: mesh.getVerticesData('matricesIndices') ? Array.from(mesh.getVerticesData('matricesIndices')!) : [],
|
||||
totalVertices: mesh.getTotalVertices(),
|
||||
totalIndices: mesh.getIndices() ? mesh.getIndices()!.length : 0
|
||||
}
|
||||
|
||||
return meshData
|
||||
}
|
||||
|
||||
export const parseAndCacheScene = async (meshes: AbstractMesh[], cacheFileName: string, modelPath: string): Promise<ParsedMeshData> => {
|
||||
const parsedMeshes = meshes.map(mesh => extractAllMeshData(mesh))
|
||||
|
||||
const boundingBox = meshes[0].getHierarchyBoundingVectors()
|
||||
const totalVertices = parsedMeshes.reduce((sum, mesh) => {
|
||||
const meshData = mesh as Record<string, unknown>
|
||||
const geometry = meshData.geometry as Record<string, unknown>
|
||||
const vertices = geometry.vertices as number[]
|
||||
return sum + vertices.length / 3
|
||||
}, 0)
|
||||
const totalIndices = parsedMeshes.reduce((sum, mesh) => {
|
||||
const meshData = mesh as Record<string, unknown>
|
||||
const geometry = meshData.geometry as Record<string, unknown>
|
||||
const indices = geometry.indices as number[]
|
||||
return sum + indices.length
|
||||
}, 0)
|
||||
|
||||
const parsedData: ParsedMeshData = {
|
||||
meshes: parsedMeshes,
|
||||
boundingBox: {
|
||||
min: boundingBox.min,
|
||||
max: boundingBox.max
|
||||
},
|
||||
metadata: {
|
||||
modelPath,
|
||||
parsedAt: new Date().toISOString(),
|
||||
totalVertices: Math.floor(totalVertices),
|
||||
totalIndices
|
||||
}
|
||||
}
|
||||
|
||||
console.log('💾 Caching parsed mesh data:', parsedData.metadata)
|
||||
|
||||
try {
|
||||
console.log('💾 Attempting to cache mesh data...')
|
||||
console.log('💾 Cache file name:', cacheFileName)
|
||||
console.log('💾 Data size:', JSON.stringify(parsedData).length, 'bytes')
|
||||
|
||||
const response = await fetch('/api/cache-mesh-data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: cacheFileName,
|
||||
data: parsedData
|
||||
})
|
||||
})
|
||||
|
||||
console.log('💾 Response status:', response.status)
|
||||
console.log('💾 Response ok:', response.ok)
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
console.log('✅ Mesh data cached successfully:', result)
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
console.warn('⚠️ Failed to cache mesh data:', response.status, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not cache mesh data:', error)
|
||||
console.error('💾 Full error details:', error)
|
||||
}
|
||||
|
||||
return parsedData
|
||||
}
|
||||
Reference in New Issue
Block a user