/** * Visualization Renderer * Manages Three.js scene, camera, lights, and particle systems */ class VisualizationRenderer { constructor(canvasElement) { this.canvas = canvasElement; this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.orbitals = new Map(); // Map of orbital ID to particle system this.animationId = null; // Axis helpers this.axisHelpers = { x: null, y: null, z: null }; // Auto-rotation settings this.autoRotateEnabled = false; this.rotationAxis = 'y'; this.rotationSpeed = 0.2; // Particle animation settings this.animationEnabled = false; // Animation off by default this.animationSpeed = 1.0; // Clipping plane settings this.clippingEnabled = false; this.clippingPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); this.clippingPlaneHelper = null; // Performance monitoring this.frameCount = 0; this.lastTime = performance.now(); this.fps = 60; } /** * Initialize Three.js scene, camera, renderer */ initialize() { // Create scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x000000); // Create camera const aspect = window.innerWidth / window.innerHeight; this.camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); this.camera.position.set(8, 8, 8); this.camera.lookAt(0, 0, 0); // Detect GPU and create renderer with hardware acceleration const canvas = this.canvas; const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); if (gl) { const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); if (debugInfo) { const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); console.log('GPU Detected:', renderer); } } // Create renderer with GPU acceleration settings this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, alpha: true, powerPreference: 'high-performance', // Request high-performance GPU precision: 'highp', // High precision for better quality stencil: false, // Disable stencil buffer for better performance depth: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Cap at 2x for performance // Enable GPU-accelerated features this.renderer.sortObjects = false; // Disable sorting for better performance with particles // Enable local clipping this.renderer.localClippingEnabled = true; console.log('WebGL Renderer initialized with GPU acceleration'); console.log('Renderer capabilities:', this.renderer.capabilities); // Add lights this.setupLights(); // Add coordinate axes this.setupAxes(); // Setup clipping plane helper this.setupClippingPlane(); // Add orbit controls this.setupControls(); // Handle window resize window.addEventListener('resize', () => this.onWindowResize()); return true; } /** * Set up scene lighting */ setupLights() { // Ambient light for overall illumination const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambientLight); // Directional light for depth const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4); directionalLight.position.set(10, 10, 10); this.scene.add(directionalLight); // Note: Removed point light at origin as it was causing visual artifacts // (white line appearing from center for elements with Z >= 5) } /** * Set up coordinate axes */ setupAxes() { const axisLength = 15; const axisWidth = 2; // Create materials for each axis const xMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: axisWidth }); // Red for X const yMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: axisWidth }); // Green for Y const zMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff, linewidth: axisWidth }); // Blue for Z // X-axis const xGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(-axisLength, 0, 0), new THREE.Vector3(axisLength, 0, 0) ]); this.axisHelpers.x = new THREE.Line(xGeometry, xMaterial); this.axisHelpers.x.name = 'x-axis'; this.scene.add(this.axisHelpers.x); // Y-axis const yGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, -axisLength, 0), new THREE.Vector3(0, axisLength, 0) ]); this.axisHelpers.y = new THREE.Line(yGeometry, yMaterial); this.axisHelpers.y.name = 'y-axis'; this.scene.add(this.axisHelpers.y); // Z-axis const zGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, -axisLength), new THREE.Vector3(0, 0, axisLength) ]); this.axisHelpers.z = new THREE.Line(zGeometry, zMaterial); this.axisHelpers.z.name = 'z-axis'; this.scene.add(this.axisHelpers.z); console.log('Coordinate axes added to scene'); } /** * Set up clipping planes for cross-sectional views */ setupClippingPlane() { // Create three clipping planes (one for each axis) this.clippingPlanes = { x: new THREE.Plane(new THREE.Vector3(-1, 0, 0), 0), y: new THREE.Plane(new THREE.Vector3(0, -1, 0), 0), z: new THREE.Plane(new THREE.Vector3(0, 0, -1), 0) }; console.log('Clipping planes created for X, Y, Z axes'); } /** * Set axis visibility * @param {string} axis - 'x', 'y', or 'z' * @param {boolean} visible - visibility state */ setAxisVisibility(axis, visible) { if (this.axisHelpers[axis]) { this.axisHelpers[axis].visible = visible; } } /** * Set up orbit controls */ setupControls() { this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; this.controls.minDistance = 5; this.controls.maxDistance = 300; // Increased from 100 to 300 for larger orbitals this.controls.maxPolarAngle = Math.PI; } /** * Create particle system for an orbital * @param {string} orbitalId - Unique orbital identifier * @param {Array} particleData - Array of {position, probability} * @param {string} orbitalType - Orbital type (s, p, d, f) * @param {number} scale - Scale factor (default 1.0) * @param {Object} customColor - Custom color (null to use default) * @param {boolean} hueGradientEnabled - Enable hue gradient based on distance * @param {number} hueGradientIntensity - Hue shift amount in degrees (0-360) * @returns {THREE.Points} */ createOrbitalParticles(orbitalId, particleData, orbitalType, scale = 1.0, customColor = null, hueGradientEnabled = false, hueGradientIntensity = 120) { console.log(`Renderer: Creating particle system for ${orbitalId} (${orbitalType}) with ${particleData.length} particles, scale: ${scale}, hueGradient: ${hueGradientEnabled}, intensity: ${hueGradientIntensity}`); const particleCount = particleData.length; // Validate particle count if (particleCount === 0) { console.warn(`Renderer: No particles to render for ${orbitalId}`); return null; } // Create geometry const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); const sizes = new Float32Array(particleCount); const velocities = new Float32Array(particleCount * 3); // Get base color: use custom color if provided, otherwise use default type color const baseColor = customColor || CONSTANTS.ORBITAL_COLORS[orbitalType] || CONSTANTS.ORBITAL_COLORS.s; console.log(`Renderer: Using color for ${orbitalType}:`, baseColor); // Calculate max distance for normalization (for hue gradient) let maxDistance = 0; if (hueGradientEnabled) { for (let i = 0; i < particleCount; i++) { const particle = particleData[i]; const distance = Math.sqrt( particle.position.x * particle.position.x + particle.position.y * particle.position.y + particle.position.z * particle.position.z ); if (distance > maxDistance) maxDistance = distance; } } // Fill arrays with particle data for (let i = 0; i < particleCount; i++) { const particle = particleData[i]; // Position positions[i * 3] = particle.position.x; positions[i * 3 + 1] = particle.position.y; positions[i * 3 + 2] = particle.position.z; // Color calculation let finalColor = baseColor; if (hueGradientEnabled) { // Calculate distance from center for hue gradient const distance = Math.sqrt( particle.position.x * particle.position.x + particle.position.y * particle.position.y + particle.position.z * particle.position.z ); const normalizedDistance = maxDistance > 0 ? distance / maxDistance : 0; // Convert base RGB to HSL const hsl = this.rgbToHsl(baseColor.r, baseColor.g, baseColor.b); // Shift hue based on distance using the intensity parameter // Closer to center = base hue, farther = shifted hue const hueShift = normalizedDistance * hueGradientIntensity; const newHue = (hsl.h + hueShift) % 360; // Convert back to RGB finalColor = this.hslToRgb(newHue, hsl.s, hsl.l); } // Apply intensity based on probability (brighter = higher probability) const intensity = 0.3 + 0.7 * particle.probability; colors[i * 3] = finalColor.r * intensity; colors[i * 3 + 1] = finalColor.g * intensity; colors[i * 3 + 2] = finalColor.b * intensity; // Size based on probability sizes[i] = CONSTANTS.PARTICLE_SIZE * (0.5 + 0.5 * particle.probability); // Random velocity for air stream effect (reduced for shape preservation) const speed = (0.005 + Math.random() * 0.01); // Reduced speed const theta = Math.random() * Math.PI * 2; const phi = Math.random() * Math.PI * 2; velocities[i * 3] = Math.sin(theta) * Math.cos(phi) * speed; velocities[i * 3 + 1] = Math.sin(theta) * Math.sin(phi) * speed; velocities[i * 3 + 2] = Math.cos(theta) * speed; } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); geometry.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3)); // IMPORTANT: Compute bounding sphere to prevent rendering artifacts geometry.computeBoundingSphere(); // Create material const material = new THREE.PointsMaterial({ size: CONSTANTS.PARTICLE_SIZE, vertexColors: true, transparent: true, opacity: CONSTANTS.PARTICLE_OPACITY, sizeAttenuation: true, blending: THREE.AdditiveBlending, depthWrite: false // clippingPlanes will be set dynamically when clipping is enabled }); // Create particle system (THREE.Points, NOT Line or LineSegments) const particleSystem = new THREE.Points(geometry, material); particleSystem.name = orbitalId; // Apply scale particleSystem.scale.set(scale, scale, scale); // Store original positions for boundary checking particleSystem.userData.originalPositions = new Float32Array(positions); particleSystem.userData.maxDistance = this.calculateMaxDistance(positions); // Add to scene and store reference this.scene.add(particleSystem); this.orbitals.set(orbitalId, particleSystem); console.log(`Renderer: Particle system added to scene. Total orbitals in scene: ${this.orbitals.size}`); console.log(`Renderer: Scene now has ${this.scene.children.length} children`); return particleSystem; } /** * Calculate maximum distance from origin for boundary checking * @param {Float32Array} positions * @returns {number} */ calculateMaxDistance(positions) { let maxDist = 0; for (let i = 0; i < positions.length; i += 3) { const x = positions[i]; const y = positions[i + 1]; const z = positions[i + 2]; const dist = Math.sqrt(x * x + y * y + z * z); if (dist > maxDist) maxDist = dist; } return maxDist * 1.2; // Add 20% buffer } /** * Update particles for an existing orbital * @param {string} orbitalId * @param {Array} particleData * @param {string} orbitalType */ updateOrbitalParticles(orbitalId, particleData, orbitalType) { this.removeOrbital(orbitalId); this.createOrbitalParticles(orbitalId, particleData, orbitalType); } /** * Remove orbital from scene * @param {string} orbitalId */ removeOrbital(orbitalId) { const particleSystem = this.orbitals.get(orbitalId); if (particleSystem) { this.scene.remove(particleSystem); particleSystem.geometry.dispose(); particleSystem.material.dispose(); this.orbitals.delete(orbitalId); } } /** * Clear all orbitals from scene */ clearAllOrbitals() { this.orbitals.forEach((particleSystem, orbitalId) => { this.removeOrbital(orbitalId); }); this.orbitals.clear(); } /** * Animation loop */ animate() { this.animationId = requestAnimationFrame(() => this.animate()); // Update controls if (this.controls) { this.controls.update(); } // Animate particles (air stream effect) this.animateParticles(); // Handle custom auto-rotation if (this.autoRotateEnabled) { const rotationAmount = this.rotationSpeed * 0.01; switch (this.rotationAxis) { case 'x': this.scene.rotation.x += rotationAmount; break; case 'y': this.scene.rotation.y += rotationAmount; break; case 'z': this.scene.rotation.z += rotationAmount; break; } } // Update performance monitoring this.updatePerformance(); // Render scene this.renderer.render(this.scene, this.camera); } /** * Animate particles to create air stream effect */ animateParticles() { if (!this.animationEnabled || this.orbitals.size === 0) return; this.orbitals.forEach((particleSystem) => { const positions = particleSystem.geometry.attributes.position; const velocities = particleSystem.geometry.attributes.velocity; if (!positions || !velocities) { console.warn('Missing position or velocity attributes'); return; } const posArray = positions.array; const velArray = velocities.array; const originalPositions = particleSystem.userData.originalPositions; const maxDistance = particleSystem.userData.maxDistance; // Update each particle position for (let i = 0; i < posArray.length; i += 3) { // Apply velocity with animation speed multiplier posArray[i] += velArray[i] * this.animationSpeed; posArray[i + 1] += velArray[i + 1] * this.animationSpeed; posArray[i + 2] += velArray[i + 2] * this.animationSpeed; // Calculate distance from original position const dx = posArray[i] - originalPositions[i]; const dy = posArray[i + 1] - originalPositions[i + 1]; const dz = posArray[i + 2] - originalPositions[i + 2]; const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); // If particle drifts too far, reset to original position // Reduced threshold to keep shape intact (10% instead of 30%) if (distance > maxDistance * 0.1) { posArray[i] = originalPositions[i]; posArray[i + 1] = originalPositions[i + 1]; posArray[i + 2] = originalPositions[i + 2]; // Randomize velocity slightly const speed = (0.005 + Math.random() * 0.01); const theta = Math.random() * Math.PI * 2; const phi = Math.random() * Math.PI * 2; velArray[i] = Math.sin(theta) * Math.cos(phi) * speed; velArray[i + 1] = Math.sin(theta) * Math.sin(phi) * speed; velArray[i + 2] = Math.cos(theta) * speed; } } // Mark position attribute as needing update positions.needsUpdate = true; }); } /** * Set animation speed * @param {number} speed - Animation speed multiplier */ setAnimationSpeed(speed) { this.animationSpeed = speed; console.log(`Animation speed set to: ${speed}`); } /** * Enable/disable particle animation * @param {boolean} enabled - Animation enabled state */ setAnimationEnabled(enabled) { this.animationEnabled = enabled; console.log(`Particle animation ${enabled ? 'enabled' : 'disabled'}`); } /** * Enable/disable clipping planes * @param {boolean} enabled - Clipping enabled state */ setClippingEnabled(enabled) { this.clippingEnabled = enabled; // Update all particle materials this.orbitals.forEach((particleSystem) => { if (enabled) { // Apply all three clipping planes particleSystem.material.clippingPlanes = [ this.clippingPlanes.x, this.clippingPlanes.y, this.clippingPlanes.z ]; } else { particleSystem.material.clippingPlanes = []; } particleSystem.material.needsUpdate = true; }); console.log(`Clipping planes ${enabled ? 'enabled' : 'disabled'}`); } /** * Set clipping position for a specific axis * @param {string} axis - 'x', 'y', or 'z' * @param {number} position - Position along the axis (-10 to 10) */ setClipPosition(axis, position) { if (!this.clippingPlanes[axis]) { console.error(`Invalid clipping axis: ${axis}`); return; } // In Three.js, the clipping plane equation is: normal · point + constant = 0 // Points where normal · point + constant < 0 are clipped // So we need to negate the position to get the correct clipping direction this.clippingPlanes[axis].constant = -position; // Force update of materials if clipping is enabled if (this.clippingEnabled) { this.orbitals.forEach((particleSystem) => { if (particleSystem.material.clippingPlanes && particleSystem.material.clippingPlanes.length > 0) { particleSystem.material.needsUpdate = true; } }); } console.log(`${axis.toUpperCase()}-axis clipping position set to: ${position}`); } /** * Set auto-rotate * @param {boolean} enabled * @param {string} axis - 'x', 'y', or 'z' */ setAutoRotate(enabled, axis = 'y') { this.autoRotateEnabled = enabled; this.rotationAxis = axis; // Reset scene rotation when disabling if (!enabled) { this.scene.rotation.set(0, 0, 0); } } /** * Set rotation speed * @param {number} speed */ setRotationSpeed(speed) { this.rotationSpeed = speed; } /** * Update performance monitoring */ updatePerformance() { this.frameCount++; const currentTime = performance.now(); const elapsed = currentTime - this.lastTime; if (elapsed >= 1000) { this.fps = (this.frameCount * 1000) / elapsed; this.frameCount = 0; this.lastTime = currentTime; // Log performance warnings if (this.fps < CONSTANTS.MIN_FPS) { console.warn(`Low FPS detected: ${this.fps.toFixed(1)}`); } } } /** * Handle window resize */ onWindowResize() { const width = window.innerWidth; const height = window.innerHeight; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); } /** * Get camera for external access * @returns {THREE.Camera} */ getCamera() { return this.camera; } /** * Get current FPS * @returns {number} */ getFPS() { return this.fps; } /** * Stop animation loop */ stop() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } /** * Dispose of renderer resources */ dispose() { this.stop(); this.clearAllOrbitals(); if (this.renderer) { this.renderer.dispose(); } } /** * Convert RGB to HSL * @param {number} r - Red (0-1) * @param {number} g - Green (0-1) * @param {number} b - Blue (0-1) * @returns {Object} - {h: 0-360, s: 0-1, l: 0-1} */ rgbToHsl(r, g, b) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } return { h: h * 360, s: s, l: l }; } /** * Convert HSL to RGB * @param {number} h - Hue (0-360) * @param {number} s - Saturation (0-1) * @param {number} l - Lightness (0-1) * @returns {Object} - {r: 0-1, g: 0-1, b: 0-1} */ hslToRgb(h, s, l) { h = h / 360; // Convert to 0-1 range let r, g, b; if (s === 0) { r = g = b = l; // achromatic } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return { r: r, g: g, b: b }; } } // Make class available globally window.VisualizationRenderer = VisualizationRenderer;