From 5aa2210b8ae91b69fb98d41efc20dfdd92875e0a Mon Sep 17 00:00:00 2001 From: Azeem Fidahusein Date: Mon, 28 Jul 2025 22:53:58 +0100 Subject: [PATCH] refactor --- app/node_modules/.vite/deps/_metadata.json | 22 +- app/src/README.md | 99 ++++ app/src/event-manager.ts | 52 ++ app/src/lighting.ts | 42 ++ app/src/main.ts | 600 +-------------------- app/src/model-loader.ts | 417 ++++++++++++++ app/src/physics.ts | 115 ++++ app/src/types.ts | 13 + 8 files changed, 778 insertions(+), 582 deletions(-) create mode 100644 app/src/README.md create mode 100644 app/src/event-manager.ts create mode 100644 app/src/lighting.ts create mode 100644 app/src/model-loader.ts create mode 100644 app/src/physics.ts create mode 100644 app/src/types.ts diff --git a/app/node_modules/.vite/deps/_metadata.json b/app/node_modules/.vite/deps/_metadata.json index dbd26d5..64ea189 100644 --- a/app/node_modules/.vite/deps/_metadata.json +++ b/app/node_modules/.vite/deps/_metadata.json @@ -1,25 +1,25 @@ { - "hash": "fe2b1553", - "configHash": "cba10d12", + "hash": "4305885f", + "configHash": "3b37792f", "lockfileHash": "0d3c4966", - "browserHash": "5cb401b4", + "browserHash": "b77d7025", "optimized": { - "three": { - "src": "../../three/build/three.module.js", - "file": "three.js", - "fileHash": "3c7c8ebb", - "needsInterop": false - }, "cannon-es": { "src": "../../cannon-es/dist/cannon-es.js", "file": "cannon-es.js", - "fileHash": "8efd3187", + "fileHash": "55d87002", + "needsInterop": false + }, + "three": { + "src": "../../three/build/three.module.js", + "file": "three.js", + "fileHash": "c1c85acc", "needsInterop": false }, "three/examples/jsm/loaders/GLTFLoader.js": { "src": "../../three/examples/jsm/loaders/GLTFLoader.js", "file": "three_examples_jsm_loaders_GLTFLoader__js.js", - "fileHash": "6222a00f", + "fileHash": "4e20fb05", "needsInterop": false } }, diff --git a/app/src/README.md b/app/src/README.md new file mode 100644 index 0000000..655900a --- /dev/null +++ b/app/src/README.md @@ -0,0 +1,99 @@ +# AAF Systems Homepage - Code Structure + +## Overview +The main.ts file has been refactored into a modular structure for better maintainability and organization. + +## File Structure + +### `src/main.ts` (133 lines โ†’ from 678 lines) +**Main application class and initialization** +- `AAFHomepage` class - Main application orchestrator +- Scene, camera, and renderer setup +- Animation loop coordination +- Application initialization + +### `src/types.ts` (11 lines) +**Type definitions and interfaces** +- `PhysicsObject` interface - Defines mesh and physics body pairing +- `ModelGeometry` interface - Defines extracted model geometry data structure + +### `src/lighting.ts` (42 lines) +**Lighting management** +- `LightingManager` class with static methods +- Studio lighting setup with multiple light types: + - Ambient light for general illumination + - Directional lights (key, fill, rim) + - Point lights for atmosphere + +### `src/model-loader.ts` (311 lines) +**3D model loading and processing** +- `ModelLoader` class - Handles GLTF model operations +- Geometry extraction and processing +- Geometry downsampling for performance optimization +- Collision shape generation for physics +- Fallback object creation when model loading fails +- Model instancing with physics bodies + +### `src/physics.ts` (98 lines) +**Physics simulation management** +- `PhysicsManager` class with static methods +- Physics world configuration and setup +- Force application (attraction, repulsion, damping) +- Physics stepping and object synchronization +- Mouse interaction physics + +### `src/event-manager.ts` (40 lines) +**Event handling and user input** +- `EventManager` class - Manages user interactions +- Mouse movement tracking and world position calculation +- Window resize handling +- Raycasting for mouse interaction + +## Benefits of This Structure + +### ๐Ÿ”ง **Maintainability** +- Each file has a single responsibility +- Easier to locate and modify specific functionality +- Reduced file size makes navigation simpler + +### ๐Ÿ”„ **Reusability** +- Modules can be easily reused in other projects +- Static utility classes provide clean interfaces +- Type definitions are centralized and shared + +### ๐Ÿงช **Testability** +- Individual modules can be unit tested in isolation +- Dependencies are clearly defined through imports +- Mock objects can be easily substituted + +### ๐Ÿ‘ฅ **Team Collaboration** +- Multiple developers can work on different aspects simultaneously +- Clear separation of concerns reduces merge conflicts +- Code reviews are more focused and manageable + +### ๐Ÿ“ฆ **Performance** +- Tree-shaking can remove unused code more effectively +- Smaller bundles through modular imports +- Better browser caching of individual modules + +## Usage + +The refactored code maintains the same external API. Simply import and use: + +```typescript +import app from './main' +// The application initializes automatically +``` + +## Module Dependencies + +``` +main.ts +โ”œโ”€โ”€ types.ts +โ”œโ”€โ”€ lighting.ts +โ”œโ”€โ”€ model-loader.ts (depends on types.ts) +โ”œโ”€โ”€ physics.ts +โ””โ”€โ”€ event-manager.ts +``` + +All modules are designed to be stateless utilities or encapsulated classes, promoting clean architecture principles. diff --git a/app/src/event-manager.ts b/app/src/event-manager.ts new file mode 100644 index 0000000..009182a --- /dev/null +++ b/app/src/event-manager.ts @@ -0,0 +1,52 @@ +import * as THREE from 'three' + +export class EventManager { + private mouse: THREE.Vector2 + private raycaster: THREE.Raycaster + private mouseWorldPosition: THREE.Vector3 + private camera: THREE.PerspectiveCamera + private renderer: THREE.WebGLRenderer + + constructor( + mouse: THREE.Vector2, + raycaster: THREE.Raycaster, + mouseWorldPosition: THREE.Vector3, + camera: THREE.PerspectiveCamera, + renderer: THREE.WebGLRenderer + ) { + this.mouse = mouse + this.raycaster = raycaster + this.mouseWorldPosition = mouseWorldPosition + this.camera = camera + this.renderer = renderer + } + + setupEventListeners(): void { + // Mouse move event + window.addEventListener('mousemove', (event) => { + this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1 + this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 + + // Update raycaster + this.raycaster.setFromCamera(this.mouse, this.camera) + + // Calculate mouse position in world space + const distance = 10 // Distance from camera + const direction = this.raycaster.ray.direction.clone() + direction.multiplyScalar(distance) + + // Update the mouse world position by setting its components directly + this.mouseWorldPosition.copy(this.camera.position).add(direction) + + // Debug log to verify mouse interaction is working + console.log('Mouse world position:', this.mouseWorldPosition.x.toFixed(2), this.mouseWorldPosition.y.toFixed(2), this.mouseWorldPosition.z.toFixed(2)) + }) + + // Window resize event + window.addEventListener('resize', () => { + this.camera.aspect = window.innerWidth / window.innerHeight + this.camera.updateProjectionMatrix() + this.renderer.setSize(window.innerWidth, window.innerHeight) + }) + } +} diff --git a/app/src/lighting.ts b/app/src/lighting.ts new file mode 100644 index 0000000..f12cca2 --- /dev/null +++ b/app/src/lighting.ts @@ -0,0 +1,42 @@ +import * as THREE from 'three' + +export class LightingManager { + static setupLighting(scene: THREE.Scene): void { + // Ambient light for general illumination + const ambientLight = new THREE.AmbientLight(0x404040, 0.3) + scene.add(ambientLight) + + // Key light (main studio light) + const keyLight = new THREE.DirectionalLight(0xffffff, 1.2) + keyLight.position.set(10, 10, 5) + keyLight.castShadow = true + keyLight.shadow.mapSize.width = 2048 + keyLight.shadow.mapSize.height = 2048 + keyLight.shadow.camera.near = 0.1 + keyLight.shadow.camera.far = 50 + keyLight.shadow.camera.left = -10 + keyLight.shadow.camera.right = 10 + keyLight.shadow.camera.top = 10 + keyLight.shadow.camera.bottom = -10 + scene.add(keyLight) + + // Fill light (softer, opposite side) + const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4) + fillLight.position.set(-8, 5, 3) + scene.add(fillLight) + + // Rim light (for edge definition) + const rimLight = new THREE.DirectionalLight(0xffaa88, 0.6) + rimLight.position.set(0, -10, -5) + scene.add(rimLight) + + // Point lights for additional atmosphere + const pointLight1 = new THREE.PointLight(0x4444ff, 0.5, 30) + pointLight1.position.set(-5, 5, 10) + scene.add(pointLight1) + + const pointLight2 = new THREE.PointLight(0xff4444, 0.3, 25) + pointLight2.position.set(5, -5, -8) + scene.add(pointLight2) + } +} diff --git a/app/src/main.ts b/app/src/main.ts index 936d0ad..12483c9 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -1,18 +1,11 @@ import './style.css' import * as THREE from 'three' import * as CANNON from 'cannon-es' -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' - -interface PhysicsObject { - mesh: THREE.Object3D - body: CANNON.Body -} - -interface ModelGeometry { - vertices: number[] - indices: number[] - boundingBox: THREE.Box3 -} +import type { PhysicsObject } from './types' +import { LightingManager } from './lighting' +import { ModelLoader } from './model-loader' +import { PhysicsManager } from './physics' +import { EventManager } from './event-manager' class AAFHomepage { private scene: THREE.Scene @@ -23,10 +16,10 @@ class AAFHomepage { private mouse: THREE.Vector2 private raycaster: THREE.Raycaster private attractionPoint: THREE.Vector3 - private loader: GLTFLoader private mouseWorldPosition: THREE.Vector3 private lastTime: number = 0 - private modelGeometry: ModelGeometry | null = null + private modelLoader: ModelLoader + private eventManager: EventManager constructor() { console.log('AAFHomepage constructor started') @@ -47,12 +40,21 @@ class AAFHomepage { antialias: true, alpha: true }) - this.world = new CANNON.World() + this.world = PhysicsManager.setupPhysicsWorld() this.mouse = new THREE.Vector2() this.raycaster = new THREE.Raycaster() this.attractionPoint = new THREE.Vector3(0, 0, 0) - this.loader = new GLTFLoader() this.mouseWorldPosition = new THREE.Vector3() + this.modelLoader = new ModelLoader() + + // Initialize event manager + this.eventManager = new EventManager( + this.mouse, + this.raycaster, + this.mouseWorldPosition, + this.camera, + this.renderer + ) // Initialize the scene this.init() @@ -78,32 +80,10 @@ class AAFHomepage { console.log('Camera positioned') - // Setup physics world - this.world.gravity.set(0, 0, 0) - - // Use SAPBroadphase for better collision detection with many objects - this.world.broadphase = new CANNON.SAPBroadphase(this.world) - - // Enable collision detection and response - this.world.allowSleep = false // Prevent objects from sleeping - - // Configure contact material for better collisions - const defaultMaterial = new CANNON.Material('default') - const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial, defaultMaterial, { - friction: 0.4, - restitution: 0.3, - contactEquationStiffness: 1e8, - contactEquationRelaxation: 3, - frictionEquationStiffness: 1e8, - frictionEquationRelaxation: 3 - }) - this.world.addContactMaterial(defaultContactMaterial) - this.world.defaultMaterial = defaultMaterial - console.log('Physics world setup') // Setup lighting - this.setupLighting() + LightingManager.setupLighting(this.scene) console.log('Lighting setup complete') @@ -113,7 +93,7 @@ class AAFHomepage { console.log('Model loading initiated') // Setup event listeners - this.setupEventListeners() + this.eventManager.setupEventListeners() console.log('Event listeners setup') @@ -123,537 +103,8 @@ class AAFHomepage { console.log('Animation loop started') } - private setupLighting(): void { - // Ambient light for general illumination - const ambientLight = new THREE.AmbientLight(0x404040, 0.3) - this.scene.add(ambientLight) - - // Key light (main studio light) - const keyLight = new THREE.DirectionalLight(0xffffff, 1.2) - keyLight.position.set(10, 10, 5) - keyLight.castShadow = true - keyLight.shadow.mapSize.width = 2048 - keyLight.shadow.mapSize.height = 2048 - keyLight.shadow.camera.near = 0.1 - keyLight.shadow.camera.far = 50 - keyLight.shadow.camera.left = -10 - keyLight.shadow.camera.right = 10 - keyLight.shadow.camera.top = 10 - keyLight.shadow.camera.bottom = -10 - this.scene.add(keyLight) - - // Fill light (softer, opposite side) - const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4) - fillLight.position.set(-8, 5, 3) - this.scene.add(fillLight) - - // Rim light (for edge definition) - const rimLight = new THREE.DirectionalLight(0xffaa88, 0.6) - rimLight.position.set(0, -10, -5) - this.scene.add(rimLight) - - // Point lights for additional atmosphere - const pointLight1 = new THREE.PointLight(0x4444ff, 0.5, 30) - pointLight1.position.set(-5, 5, 10) - this.scene.add(pointLight1) - - const pointLight2 = new THREE.PointLight(0xff4444, 0.3, 25) - pointLight2.position.set(5, -5, -8) - this.scene.add(pointLight2) - } - - 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)) - } - } - private async loadAndCreateObjects(): Promise { - try { - console.log('Loading main_model.glb...') - const gltf = await this.loader.loadAsync('/models/main_model.glb') - console.log('Model loaded successfully:', gltf) - - // Extract and downsample the geometry for collision detection - const originalGeometry = this.extractModelGeometry(gltf) - if (originalGeometry) { - console.log('Extracted geometry with', originalGeometry.vertices.length / 3, 'vertices') - this.modelGeometry = this.downsampleGeometry(originalGeometry, 0.3) // 30% of original detail - console.log('Downsampled to', this.modelGeometry.vertices.length / 3, 'vertices for collision') - } else { - console.warn('Failed to extract geometry, will use simple collision shapes') - 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: this.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) => { - console.log('Collision detected!') - - // 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 - ) - - this.scene.add(modelClone) - this.world.addBody(body) - - this.physicsObjects.push({ mesh: modelClone, body }) - } - - console.log('Created', numInstances, 'instances of main_model with custom collision shapes') - - } catch (error) { - console.error('Failed to load main_model.glb:', error) - console.log('Falling back to placeholder objects...') - this.createFallbackObjects() - } - } - - private createFallbackObjects(): 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: this.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 - ) - - this.scene.add(mesh) - this.world.addBody(body) - - this.physicsObjects.push({ mesh, body }) - } - } - - private setupEventListeners(): void { - // Mouse move event - window.addEventListener('mousemove', (event) => { - this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1 - this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 - - // Update raycaster - this.raycaster.setFromCamera(this.mouse, this.camera) - - // Calculate mouse position in world space - const distance = 10 // Distance from camera - this.mouseWorldPosition.copy(this.raycaster.ray.direction) - this.mouseWorldPosition.multiplyScalar(distance) - this.mouseWorldPosition.add(this.camera.position) - }) - - // Window resize event - window.addEventListener('resize', () => { - this.camera.aspect = window.innerWidth / window.innerHeight - this.camera.updateProjectionMatrix() - this.renderer.setSize(window.innerWidth, window.innerHeight) - }) - } - - private updatePhysics(deltaTime: number): void { - // Use a smaller, more stable timestep for better collision detection - const fixedTimeStep = 1/60 // 60 Hz - more stable than 120 Hz - const maxSubSteps = 5 // Increased substeps for better collision accuracy - - this.world.step(fixedTimeStep, deltaTime, maxSubSteps) - - // Apply forces to objects - this.physicsObjects.forEach((obj) => { - // Attraction to center point - const attractionForce = new CANNON.Vec3() - attractionForce.x = this.attractionPoint.x - obj.body.position.x - attractionForce.y = this.attractionPoint.y - obj.body.position.y - attractionForce.z = this.attractionPoint.z - obj.body.position.z - - const distance = Math.sqrt( - attractionForce.x * attractionForce.x + - attractionForce.y * attractionForce.y + - attractionForce.z * attractionForce.z - ) - - if (distance > 0) { - const strength = 16.0 / (distance * distance + 1) // Reduced from 8.0 to prevent objects moving too fast - attractionForce.scale(strength, attractionForce) - obj.body.force.set( - obj.body.force.x + attractionForce.x, - obj.body.force.y + attractionForce.y, - obj.body.force.z + attractionForce.z - ) - } - - // Mouse repulsion - const repulsionForce = new CANNON.Vec3() - repulsionForce.x = obj.body.position.x - this.mouseWorldPosition.x - repulsionForce.y = obj.body.position.y - this.mouseWorldPosition.y - repulsionForce.z = obj.body.position.z - this.mouseWorldPosition.z - - const mouseDistance = Math.sqrt( - repulsionForce.x * repulsionForce.x + - repulsionForce.y * repulsionForce.y + - repulsionForce.z * repulsionForce.z - ) - - if (mouseDistance < 3 && mouseDistance > 0) { - const repulsionStrength = 5.0 / (mouseDistance * mouseDistance + 0.1) - repulsionForce.scale(repulsionStrength, repulsionForce) - obj.body.force.set( - obj.body.force.x + repulsionForce.x, - obj.body.force.y + repulsionForce.y, - obj.body.force.z + repulsionForce.z - ) - - // Add rotational torque from mouse interaction - const torque = new CANNON.Vec3( - (Math.random() - 0.5) * repulsionStrength * 0.1, - (Math.random() - 0.5) * repulsionStrength * 0.1, - (Math.random() - 0.5) * repulsionStrength * 0.1 - ) - obj.body.torque.set( - obj.body.torque.x + torque.x, - obj.body.torque.y + torque.y, - obj.body.torque.z + torque.z - ) - } - - // Apply damping - obj.body.velocity.scale(0.99, obj.body.velocity) - - // Update mesh position to match physics body - obj.mesh.position.copy(obj.body.position as any) - obj.mesh.quaternion.copy(obj.body.quaternion as any) - }) + await this.modelLoader.loadAndCreateObjects(this.scene, this.world, this.physicsObjects) } private animate(currentTime: number = 0): void { @@ -666,7 +117,14 @@ class AAFHomepage { // Clamp delta time to prevent large jumps const clampedDeltaTime = Math.min(deltaTime, 1/30) - this.updatePhysics(clampedDeltaTime) + PhysicsManager.updatePhysics( + this.world, + this.physicsObjects, + this.attractionPoint, + this.mouseWorldPosition, + clampedDeltaTime + ) + this.renderer.render(this.scene, this.camera) } } diff --git a/app/src/model-loader.ts b/app/src/model-loader.ts new file mode 100644 index 0000000..024e9db --- /dev/null +++ b/app/src/model-loader.ts @@ -0,0 +1,417 @@ +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 { + console.log('Loading main_model.glb...') + const gltf = await this.loader.loadAsync('/models/main_model.glb') + console.log('Model loaded successfully:', gltf) + + // Extract and downsample the geometry for collision detection + const originalGeometry = this.extractModelGeometry(gltf) + if (originalGeometry) { + console.log('Extracted geometry with', originalGeometry.vertices.length / 3, 'vertices') + this.modelGeometry = this.downsampleGeometry(originalGeometry, 0.3) // 30% of original detail + console.log('Downsampled to', this.modelGeometry.vertices.length / 3, 'vertices for collision') + } else { + console.warn('Failed to extract geometry, will use simple collision shapes') + 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) => { + console.log('Collision detected!') + + // 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') + + } 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 }) + } + } +} diff --git a/app/src/physics.ts b/app/src/physics.ts new file mode 100644 index 0000000..42967ca --- /dev/null +++ b/app/src/physics.ts @@ -0,0 +1,115 @@ +import * as THREE from 'three' +import * as CANNON from 'cannon-es' + +export class PhysicsManager { + static setupPhysicsWorld(): CANNON.World { + const world = new CANNON.World() + + // Setup physics world + world.gravity.set(0, 0, 0) + + // Use SAPBroadphase for better collision detection with many objects + world.broadphase = new CANNON.SAPBroadphase(world) + + // Enable collision detection and response + world.allowSleep = false // Prevent objects from sleeping + + // Configure contact material for better collisions + const defaultMaterial = new CANNON.Material('default') + const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial, defaultMaterial, { + friction: 0.4, + restitution: 0.3, + contactEquationStiffness: 1e8, + contactEquationRelaxation: 3, + frictionEquationStiffness: 1e8, + frictionEquationRelaxation: 3 + }) + world.addContactMaterial(defaultContactMaterial) + world.defaultMaterial = defaultMaterial + + return world + } + + static updatePhysics( + world: CANNON.World, + physicsObjects: Array<{ mesh: THREE.Object3D; body: CANNON.Body }>, + attractionPoint: THREE.Vector3, + mouseWorldPosition: THREE.Vector3, + deltaTime: number + ): void { + // Use a smaller, more stable timestep for better collision detection + const fixedTimeStep = 1/60 // 60 Hz - more stable than 120 Hz + const maxSubSteps = 5 // Increased substeps for better collision accuracy + + world.step(fixedTimeStep, deltaTime, maxSubSteps) + + // Apply forces to objects + physicsObjects.forEach((obj) => { + // Attraction to center point + const attractionForce = new CANNON.Vec3() + attractionForce.x = attractionPoint.x - obj.body.position.x + attractionForce.y = attractionPoint.y - obj.body.position.y + attractionForce.z = attractionPoint.z - obj.body.position.z + + const distance = Math.sqrt( + attractionForce.x * attractionForce.x + + attractionForce.y * attractionForce.y + + attractionForce.z * attractionForce.z + ) + + if (distance > 0) { + const strength = 16.0 / (distance * distance + 1) // Reduced from 8.0 to prevent objects moving too fast + attractionForce.scale(strength, attractionForce) + obj.body.force.set( + obj.body.force.x + attractionForce.x, + obj.body.force.y + attractionForce.y, + obj.body.force.z + attractionForce.z + ) + } + + // Mouse repulsion + const repulsionForce = new CANNON.Vec3() + repulsionForce.x = obj.body.position.x - mouseWorldPosition.x + repulsionForce.y = obj.body.position.y - mouseWorldPosition.y + repulsionForce.z = obj.body.position.z - mouseWorldPosition.z + + const mouseDistance = Math.sqrt( + repulsionForce.x * repulsionForce.x + + repulsionForce.y * repulsionForce.y + + repulsionForce.z * repulsionForce.z + ) + + if (mouseDistance < 3 && mouseDistance > 0) { + const repulsionStrength = 5.0 / (mouseDistance * mouseDistance + 0.1) + repulsionForce.scale(repulsionStrength, repulsionForce) + obj.body.force.set( + obj.body.force.x + repulsionForce.x, + obj.body.force.y + repulsionForce.y, + obj.body.force.z + repulsionForce.z + ) + + // Debug log to verify mouse repulsion is working + console.log('Mouse repulsion applied, distance:', mouseDistance.toFixed(2), 'strength:', repulsionStrength.toFixed(2)) + + // Add rotational torque from mouse interaction + const torque = new CANNON.Vec3( + (Math.random() - 0.5) * repulsionStrength * 0.1, + (Math.random() - 0.5) * repulsionStrength * 0.1, + (Math.random() - 0.5) * repulsionStrength * 0.1 + ) + obj.body.torque.set( + obj.body.torque.x + torque.x, + obj.body.torque.y + torque.y, + obj.body.torque.z + torque.z + ) + } + + // Apply damping + obj.body.velocity.scale(0.99, obj.body.velocity) + + // Update mesh position to match physics body + obj.mesh.position.copy(obj.body.position as any) + obj.mesh.quaternion.copy(obj.body.quaternion as any) + }) + } +} diff --git a/app/src/types.ts b/app/src/types.ts new file mode 100644 index 0000000..f22a5a2 --- /dev/null +++ b/app/src/types.ts @@ -0,0 +1,13 @@ +import * as THREE from 'three' +import * as CANNON from 'cannon-es' + +export interface PhysicsObject { + mesh: THREE.Object3D + body: CANNON.Body +} + +export interface ModelGeometry { + vertices: number[] + indices: number[] + boundingBox: THREE.Box3 +}