Spaces:
Running
Running
| /** | |
| * 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; | |