import * as THREE from 'three' import * as CANNON from 'cannon-es' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import type { ModelGeometry, PhysicsObject } from './types' export class ModelLoader { private loader: GLTFLoader private modelGeometry: ModelGeometry | null = null constructor() { this.loader = new GLTFLoader() } private extractModelGeometry(gltf: any): ModelGeometry | null { const geometries: THREE.BufferGeometry[] = [] const boundingBox = new THREE.Box3() // Update world matrices first gltf.scene.updateMatrixWorld(true) // Traverse the model to find all mesh geometries gltf.scene.traverse((child: any) => { if (child instanceof THREE.Mesh && child.geometry) { // Apply the mesh's transform to the geometry const geometry = child.geometry.clone() as THREE.BufferGeometry // Only apply matrix if it's not identity if (!child.matrixWorld.equals(new THREE.Matrix4())) { geometry.applyMatrix4(child.matrixWorld) } geometries.push(geometry) // Update bounding box const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute if (positionAttr) { const geometryBoundingBox = new THREE.Box3().setFromBufferAttribute(positionAttr) boundingBox.union(geometryBoundingBox) } } }) if (geometries.length === 0) { console.warn('No geometries found in model') return null } console.log(`Found ${geometries.length} geometries to merge`) // Merge all geometries const combinedGeometry = this.mergeBufferGeometries(geometries) // Extract vertices and faces const positionAttribute = combinedGeometry.getAttribute('position') as THREE.BufferAttribute if (!positionAttribute) { console.error('No position attribute found in combined geometry') return null } const vertices: number[] = Array.from(positionAttribute.array as Float32Array) let indices: number[] = [] if (combinedGeometry.index) { indices = Array.from(combinedGeometry.index.array) } else { // Generate indices if none exist for (let i = 0; i < vertices.length / 3; i++) { indices.push(i) } } console.log(`Extracted ${vertices.length / 3} vertices and ${indices.length / 3} triangles`) combinedGeometry.dispose() return { vertices, indices, boundingBox } } private mergeBufferGeometries(geometries: THREE.BufferGeometry[]): THREE.BufferGeometry { const merged = new THREE.BufferGeometry() const attributes: { [key: string]: THREE.BufferAttribute[] } = {} let indexOffset = 0 const mergedIndices: number[] = [] // Collect all attributes geometries.forEach(geometry => { const attributeNames = Object.keys(geometry.attributes) attributeNames.forEach(name => { if (!attributes[name]) attributes[name] = [] const attr = geometry.attributes[name] if (attr instanceof THREE.BufferAttribute) { attributes[name].push(attr) } }) // Handle indices if (geometry.index) { const indices = Array.from(geometry.index.array) indices.forEach(index => mergedIndices.push(index + indexOffset)) indexOffset += geometry.attributes.position.count } }) // Merge attributes Object.keys(attributes).forEach(name => { const attributeArrays = attributes[name] const itemSize = attributeArrays[0].itemSize const normalized = attributeArrays[0].normalized let totalCount = 0 attributeArrays.forEach(attr => totalCount += attr.count) const mergedArray = new Float32Array(totalCount * itemSize) let offset = 0 attributeArrays.forEach(attr => { mergedArray.set(attr.array as Float32Array, offset) offset += attr.array.length }) merged.setAttribute(name, new THREE.BufferAttribute(mergedArray, itemSize, normalized)) }) if (mergedIndices.length > 0) { merged.setIndex(mergedIndices) } return merged } private downsampleGeometry(geometry: ModelGeometry, factor: number = 0.3): ModelGeometry { // Simple downsampling by taking every Nth vertex const step = Math.max(1, Math.floor(1 / factor)) const downsampledVertices: number[] = [] const downsampledIndices: number[] = [] const vertexMap = new Map() // Downsample vertices for (let i = 0; i < geometry.vertices.length; i += step * 3) { if (i + 2 < geometry.vertices.length) { const originalIndex = i / 3 const newIndex = downsampledVertices.length / 3 vertexMap.set(originalIndex, newIndex) downsampledVertices.push( geometry.vertices[i], geometry.vertices[i + 1], geometry.vertices[i + 2] ) } } // Downsample indices, keeping only triangles where all vertices are kept for (let i = 0; i < geometry.indices.length; i += 3) { const v1 = geometry.indices[i] const v2 = geometry.indices[i + 1] const v3 = geometry.indices[i + 2] if (vertexMap.has(v1) && vertexMap.has(v2) && vertexMap.has(v3)) { downsampledIndices.push( vertexMap.get(v1)!, vertexMap.get(v2)!, vertexMap.get(v3)! ) } } return { vertices: downsampledVertices, indices: downsampledIndices, boundingBox: geometry.boundingBox } } private createCollisionShape(geometry: ModelGeometry, scale: number = 1): CANNON.Shape { try { // For performance, use a simplified approach // If the geometry is too complex, fall back to a hull or sphere const vertexCount = geometry.vertices.length / 3 if (vertexCount > 100) { // For complex meshes, use bounding box approximation const size = geometry.boundingBox.getSize(new THREE.Vector3()) const scaledSize = size.multiplyScalar(scale) return new CANNON.Box(new CANNON.Vec3( scaledSize.x * 0.5, scaledSize.y * 0.5, scaledSize.z * 0.5 )) } // For simpler meshes, try convex hull const scaledVertices = geometry.vertices.map(v => v * scale) const vertices: CANNON.Vec3[] = [] for (let i = 0; i < scaledVertices.length; i += 3) { vertices.push(new CANNON.Vec3( scaledVertices[i], scaledVertices[i + 1], scaledVertices[i + 2] )) } // Limit faces to prevent performance issues const faces: number[][] = [] const maxFaces = Math.min(geometry.indices.length / 3, 50) for (let i = 0; i < maxFaces * 3; i += 3) { const v1 = geometry.indices[i] const v2 = geometry.indices[i + 1] const v3 = geometry.indices[i + 2] if (v1 < vertices.length && v2 < vertices.length && v3 < vertices.length) { faces.push([v1, v2, v3]) } } if (faces.length > 0) { const shape = new CANNON.ConvexPolyhedron({ vertices, faces }) return shape } else { throw new Error('No valid faces') } } catch (error) { console.warn('Failed to create complex collision shape, falling back to sphere:', error) // Fallback to sphere using bounding box const size = geometry.boundingBox.getSize(new THREE.Vector3()) const radius = Math.max(size.x, size.y, size.z) * 0.5 * scale return new CANNON.Sphere(Math.max(0.3, radius)) } } async loadAndCreateObjects( scene: THREE.Scene, world: CANNON.World, physicsObjects: PhysicsObject[] ): Promise { try { const gltf = await this.loader.loadAsync('/models/main_model.glb') // Extract and downsample the geometry for collision detection const originalGeometry = this.extractModelGeometry(gltf) if (originalGeometry) { this.modelGeometry = this.downsampleGeometry(originalGeometry, 0.3) // 30% of original detail } else { this.modelGeometry = null } // Create multiple instances of the loaded model const numInstances = 20 gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.castShadow = true child.receiveShadow = true } }) for (let i = 0; i < numInstances; i++) { // Clone the model const modelClone = gltf.scene.clone() // Start objects close to the attraction point for immediate clustering const x = (Math.random() - 0.5) * 6 // Reduced from 25 to 6 const y = (Math.random() - 0.5) * 6 // Reduced from 25 to 6 const z = (Math.random() - 0.5) * 6 // Reduced from 25 to 6 modelClone.position.set(x, y, z) // Random scale variation (40% of current size) const scale = (0.5 + Math.random() * 0.5) // Scaled down to 40% modelClone.scale.setScalar(scale) // Create physics body with custom collision shape or fallback to sphere let shape: CANNON.Shape if (this.modelGeometry) { shape = this.createCollisionShape(this.modelGeometry, scale) } else { // Fallback to sphere if geometry extraction failed const radius = Math.max(0.3, 0.8 * scale) shape = new CANNON.Sphere(radius) } const body = new CANNON.Body({ mass: 1, material: world.defaultMaterial, type: CANNON.Body.DYNAMIC }) body.addShape(shape) body.position.set(x, y, z) // Enable rotational dynamics body.angularDamping = 0.1 // Small damping for realistic rotation body.linearDamping = 0.05 // Small linear damping // Prevent objects from sleeping during collisions body.sleepSpeedLimit = 0.1 body.sleepTimeLimit = 1 // Add collision event listener with rotational effects body.addEventListener('collide', (event: any) => { // Add some spin on collision for more dynamic movement const impactStrength = event.contact?.getImpactVelocityAlongNormal() || 1 const spinForce = Math.min(impactStrength * 0.5, 2) // Cap the spin force body.angularVelocity.set( body.angularVelocity.x + (Math.random() - 0.5) * spinForce, body.angularVelocity.y + (Math.random() - 0.5) * spinForce, body.angularVelocity.z + (Math.random() - 0.5) * spinForce ) }) // Add gentle initial velocity and rotation body.velocity.set( (Math.random() - 0.5) * 1, // Reduced from 3 to 1 (Math.random() - 0.5) * 1, // Reduced from 3 to 1 (Math.random() - 0.5) * 1 // Reduced from 3 to 1 ) // Add initial angular velocity for natural rotation body.angularVelocity.set( (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2 ) scene.add(modelClone) world.addBody(body) physicsObjects.push({ mesh: modelClone, body }) } console.log('Created', numInstances, 'instances of main_model with custom collision shapes') console.log('Total physics objects:', physicsObjects.length) } catch (error) { console.error('Failed to load main_model.glb:', error) console.log('Falling back to placeholder objects...') this.createFallbackObjects(scene, world, physicsObjects) } } createFallbackObjects( scene: THREE.Scene, world: CANNON.World, physicsObjects: PhysicsObject[] ): void { // Fallback geometric objects if model loading fails const geometries = [ new THREE.BoxGeometry(0.5, 0.5, 0.5), new THREE.SphereGeometry(0.3, 16, 16), new THREE.CylinderGeometry(0.2, 0.2, 0.6, 12), ] const materials = [ new THREE.MeshStandardMaterial({ color: 0x4a9eff, metalness: 0.7, roughness: 0.3 }), new THREE.MeshStandardMaterial({ color: 0xff6b4a, metalness: 0.5, roughness: 0.4 }), new THREE.MeshStandardMaterial({ color: 0x4aff6b, metalness: 0.8, roughness: 0.2 }), ] for (let i = 0; i < 15; i++) { const geometry = geometries[i % geometries.length] const material = materials[i % materials.length] const mesh = new THREE.Mesh(geometry, material) mesh.castShadow = true mesh.receiveShadow = true // Start objects close to the attraction point for immediate clustering const x = (Math.random() - 0.5) * 6 // Reduced from 20 to 6 const y = (Math.random() - 0.5) * 6 // Reduced from 20 to 6 const z = (Math.random() - 0.5) * 6 // Reduced from 20 to 6 mesh.position.set(x, y, z) // Create physics body with rotation enabled const shape = new CANNON.Sphere(0.3) const body = new CANNON.Body({ mass: 1, material: world.defaultMaterial, type: CANNON.Body.DYNAMIC }) body.addShape(shape) body.position.set(x, y, z) // Enable rotational dynamics body.angularDamping = 0.1 body.linearDamping = 0.05 body.velocity.set( (Math.random() - 0.5) * 1, // Reduced from 2 to 1 (Math.random() - 0.5) * 1, // Reduced from 2 to 1 (Math.random() - 0.5) * 1 // Reduced from 2 to 1 ) // Add initial angular velocity body.angularVelocity.set( (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2 ) scene.add(mesh) world.addBody(body) physicsObjects.push({ mesh, body }) } } }