/** * SceneManager — Three.js scene setup, lighting, camera, OrbitControls. * Loaded as a plain script; class is defined on the global scope. */ class SceneManager { constructor() { this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.detectorMarker = null; // Default camera position and target for reset — top-down view this.defaultCameraPosition = { x: 0, y: 700, z: 0 }; this.defaultCameraTarget = { x: 0, y: 0, z: 0 }; } /** * Initialise the Three.js scene inside the given container element. * @param {HTMLElement} container * @param {object} [options] * @param {number} [options.detectorCol] - detector column x-index * @param {number} [options.nx] - grid width in cells * @param {number} [options.ny] - grid height in cells */ init(container, options) { var opts = options || {}; var nx = opts.nx || 300; var ny = opts.ny || 200; var detectorCol = opts.detectorCol != null ? opts.detectorCol : Math.floor(nx * 0.85); // Scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x2a2a3e); // Camera — adjust distance based on aspect ratio so the full plane is visible var w = Math.max(container.clientWidth, 1); var h = Math.max(container.clientHeight, 1); var aspect = w / h; // On narrow/portrait screens, zoom out more to see the full plane var zoomFactor = aspect < 0.8 ? 1.8 : (aspect < 1.2 ? 1.5 : (aspect < 1.6 ? 1.15 : 1.0)); this._zoomFactor = zoomFactor; this.defaultTopPosition = { x: 0, y: 700 * zoomFactor, z: 0 }; this.camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 3000); this.camera.position.set( this.defaultCameraPosition.x, this.defaultCameraPosition.y * zoomFactor, this.defaultCameraPosition.z ); this.camera.lookAt( this.defaultCameraTarget.x, this.defaultCameraTarget.y, this.defaultCameraTarget.z ); // Renderer — wrapped in try/catch for WebGL unavailability try { this.renderer = new THREE.WebGLRenderer({ antialias: true }); } catch (e) { var msg = document.createElement('p'); msg.textContent = 'WebGL is not supported in your browser. Please use a modern browser with WebGL support.'; msg.style.cssText = 'color:#e0e0e0;padding:2rem;text-align:center;'; container.appendChild(msg); return; } this.renderer.setSize(container.clientWidth, Math.max(container.clientHeight, 1)); this.renderer.setPixelRatio(window.devicePixelRatio || 1); container.appendChild(this.renderer.domElement); // OrbitControls — rotate (left-drag), zoom (scroll), pan (right-drag) this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); this.controls.target.set( this.defaultCameraTarget.x, this.defaultCameraTarget.y, this.defaultCameraTarget.z ); this.controls.enableDamping = true; this.controls.dampingFactor = 0.1; this.controls.update(); // Lighting — multiple sources for vivid colors var dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(100, 400, 200); this.scene.add(dirLight); var dirLight2 = new THREE.DirectionalLight(0xccddff, 0.5); dirLight2.position.set(-200, 300, -100); this.scene.add(dirLight2); var ambientLight = new THREE.AmbientLight(0x8888aa, 0.8); this.scene.add(ambientLight); // Hemisphere light for natural diffused illumination var hemiLight = new THREE.HemisphereLight(0xccccff, 0x444466, 0.6); this.scene.add(hemiLight); // Detector marker — vertical line at the detector column position this._addDetectorMarker(detectorCol, ny, nx); } /** * Add a semi-transparent screen at the detector column. * The wave surface is centered at origin, so we convert grid coords * to world coords: worldX = -surfaceWidth/2 + detectorCol * dx. * @param {number} detectorCol - x position in grid coordinates * @param {number} ny - grid height * @private */ _addDetectorMarker(detectorCol, ny, nx) { nx = nx || 800; var dx = 1.0; this._nx = nx; this._ny = ny; var surfaceWidth = (nx - 1) * dx; var surfaceDepth = (ny - 1) * dx; // Convert grid column to world X (surface is centered at origin) var worldX = -surfaceWidth / 2 + detectorCol * dx; // Semi-transparent plane spanning the full depth and a reasonable height var screenHeight = this._elementHeight || 30; var geometry = new THREE.PlaneGeometry(surfaceDepth, screenHeight); var material = new THREE.MeshBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.15, side: THREE.DoubleSide, depthWrite: false }); this.detectorMarker = new THREE.Mesh(geometry, material); // Rotate so the plane faces along the X axis (stands upright in XZ plane) this.detectorMarker.rotation.y = Math.PI / 2; this.detectorMarker.position.set(worldX, screenHeight / 2 - 5, 0); this.scene.add(this.detectorMarker); } /** * Update the height of the detector marker. * @param {number} height */ updateDetectorHeight(height) { if (!this.detectorMarker || !this.scene) return; var oldPos = this.detectorMarker.position; this.scene.remove(this.detectorMarker); if (this.detectorMarker.geometry) this.detectorMarker.geometry.dispose(); var nx = this._nx || 800; var surfaceDepth = ((this._ny || 600) - 1) * 1.0; var geometry = new THREE.PlaneGeometry(surfaceDepth, height); this.detectorMarker.geometry = geometry; this.detectorMarker.position.set(oldPos.x, height / 2 - 5, 0); this.scene.add(this.detectorMarker); } /** Restore the camera to its default position and lookAt target. */ resetCamera() { if (!this.camera || !this.controls) return; this.camera.position.set( this.defaultCameraPosition.x, this.defaultCameraPosition.y, this.defaultCameraPosition.z ); this.controls.target.set( this.defaultCameraTarget.x, this.defaultCameraTarget.y, this.defaultCameraTarget.z ); this.camera.lookAt( this.defaultCameraTarget.x, this.defaultCameraTarget.y, this.defaultCameraTarget.z ); this.controls.update(); } /** * Switch the bottom interactive camera to a specific view. * @param {'top'|'angle'} mode */ setBottomView(mode) { if (!this.camera || !this.controls) return; var wasDamping = this.controls.enableDamping; this.controls.enableDamping = false; var zf = this._zoomFactor || 1.0; if (mode === 'top') { this.camera.position.set(0, 700 * zf, 1); this.controls.target.set(0, 0, 0); } else if (mode === '135') { this.camera.position.set(300 * zf, 400 * zf, -350 * zf); this.controls.target.set(0, 0, 0); } else { this.camera.position.set(-300 * zf, 400 * zf, 400 * zf); this.controls.target.set(0, 0, 0); } this.camera.lookAt(0, 0, 0); this.controls.update(); this.controls.enableDamping = wasDamping; } /** * Update renderer size and camera aspect ratio. * @param {number} width * @param {number} height */ resize(width, height) { var w = Math.max(width, 1); var h = Math.max(height, 1); if (this.camera) { this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); } if (this.renderer) { this.renderer.setSize(w, h); } this.containerWidth = w; this.containerHeight = h; } render() { if (!this.renderer || !this.scene || !this.camera) return; if (this.controls) this.controls.update(); this.renderer.render(this.scene, this.camera); } } window.SceneManager = SceneManager;