electron_cloud_visualizer / js /visualization-renderer.js
AK51's picture
Upload 20 files
cdfc1f1 verified
/**
* 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;