class ThreeJSManager { constructor(canvas) { this.canvas = canvas; this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.isInitialized = false; this.animationId = null; } // Initialize Three.js initialize() { if (this.isInitialized) return; console.log('Initializing ThreeJS...'); // Create scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0xf5f5f5); // Get the 3D canvas container const container = document.getElementById('3dViewer'); const threeCanvas = document.getElementById('threeCanvas'); if (!container || !threeCanvas) { console.error('3D container or canvas not found'); return; } // Create camera with proper settings const aspect = container.offsetWidth / container.offsetHeight; this.camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); // Create renderer with proper settings this.renderer = new THREE.WebGLRenderer({ canvas: threeCanvas, antialias: true, alpha: false, preserveDrawingBuffer: true }); this.renderer.setSize(container.offsetWidth, container.offsetHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.outputColorSpace = THREE.SRGBColorSpace; // Set initial camera position this.camera.position.set(15, 15, 15); this.camera.lookAt(0, 0, 0); // Setup proper camera controls this.setupOrbitControls(); // Add lighting this.setupLighting(); console.log('ThreeJS initialized successfully'); this.isInitialized = true; } // Setup enhanced OrbitControls following Three.js best practices setupOrbitControls() { // Enhanced orbit controls with full Three.js compatibility this.controls = { // Target and camera settings target: new THREE.Vector3(0, 0, 0), minDistance: 1, maxDistance: 200, // Rotation settings enableRotate: true, rotateSpeed: 1.0, minPolarAngle: 0, // radians maxPolarAngle: Math.PI, // radians minAzimuthAngle: -Infinity, // radians maxAzimuthAngle: Infinity, // radians // Zoom settings enableZoom: true, zoomSpeed: 1.0, minZoom: 0, maxZoom: Infinity, // Pan settings enablePan: true, panSpeed: 1.0, screenSpacePanning: true, // if false, pan orthogonal to world-space direction camera.up keyPanSpeed: 7.0, // pixels moved per arrow key push // Damping settings enableDamping: true, dampingFactor: 0.05, // Auto rotation autoRotate: false, autoRotateSpeed: 2.0, // 30 seconds per orbit when fps is 60 // Keys for keyboard controls keys: { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' }, // Mouse buttons mouseButtons: { LEFT: 0, // ROTATE MIDDLE: 1, // DOLLY RIGHT: 2 // PAN }, // Touch settings touches: { ONE: 0, // ROTATE TWO: 2 // DOLLY_PAN }, // Internal state spherical: new THREE.Spherical(), sphericalDelta: new THREE.Spherical(), scale: 1, panOffset: new THREE.Vector3(), zoomChanged: false, // Mouse/touch state rotateStart: new THREE.Vector2(), rotateEnd: new THREE.Vector2(), rotateDelta: new THREE.Vector2(), panStart: new THREE.Vector2(), panEnd: new THREE.Vector2(), panDelta: new THREE.Vector2(), dollyStart: new THREE.Vector2(), dollyEnd: new THREE.Vector2(), dollyDelta: new THREE.Vector2(), // Current state state: 'NONE', // Performance optimization EPS: 0.000001, // Change event changeEvent: { type: 'change' }, startEvent: { type: 'start' }, endEvent: { type: 'end' } }; // Set initial spherical coordinates from camera position this.controls.spherical.setFromVector3(this.camera.position.clone().sub(this.controls.target)); const domElement = this.renderer.domElement; // Bind context for event handlers this.onControlsMouseDown = this.onControlsMouseDown.bind(this); this.onControlsMouseMove = this.onControlsMouseMove.bind(this); this.onControlsMouseUp = this.onControlsMouseUp.bind(this); this.onControlsWheel = this.onControlsWheel.bind(this); this.onControlsKeyDown = this.onControlsKeyDown.bind(this); this.onControlsTouchStart = this.onControlsTouchStart.bind(this); this.onControlsTouchMove = this.onControlsTouchMove.bind(this); this.onControlsTouchEnd = this.onControlsTouchEnd.bind(this); this.onContextMenu = this.onContextMenu.bind(this); // Mouse event handlers domElement.addEventListener('mousedown', this.onControlsMouseDown); domElement.addEventListener('wheel', this.onControlsWheel, { passive: false }); domElement.addEventListener('contextmenu', this.onContextMenu); // Touch event handlers domElement.addEventListener('touchstart', this.onControlsTouchStart, { passive: false }); domElement.addEventListener('touchend', this.onControlsTouchEnd); domElement.addEventListener('touchmove', this.onControlsTouchMove, { passive: false }); // Keyboard event handlers window.addEventListener('keydown', this.onControlsKeyDown); // Global mouse handlers (for when mouse leaves canvas) document.addEventListener('mousemove', this.onControlsMouseMove); document.addEventListener('mouseup', this.onControlsMouseUp); domElement.addEventListener('wheel', this.onControlsWheel, { passive: false }); domElement.addEventListener('contextmenu', this.onContextMenu); // Touch events domElement.addEventListener('touchstart', this.onControlsTouchStart, { passive: false }); domElement.addEventListener('touchmove', this.onControlsTouchMove, { passive: false }); domElement.addEventListener('touchend', this.onControlsTouchEnd); console.log('Enhanced OrbitControls setup complete'); } // Context menu handler onContextMenu(event) { event.preventDefault(); } // Enhanced mouse event handlers following Three.js patterns onControlsMouseDown(event) { if (!this.controls.enableRotate && !this.controls.enablePan && !this.controls.enableZoom) return; event.preventDefault(); switch (event.button) { case this.controls.mouseButtons.LEFT: if (event.ctrlKey || event.metaKey || event.shiftKey) { if (!this.controls.enablePan) return; this.handleMouseDownPan(event); this.controls.state = 'PAN'; } else { if (!this.controls.enableRotate) return; this.handleMouseDownRotate(event); this.controls.state = 'ROTATE'; } break; case this.controls.mouseButtons.MIDDLE: if (!this.controls.enableZoom) return; this.handleMouseDownDolly(event); this.controls.state = 'DOLLY'; break; case this.controls.mouseButtons.RIGHT: if (!this.controls.enablePan) return; this.handleMouseDownPan(event); this.controls.state = 'PAN'; break; } if (this.controls.state !== 'NONE') { this.dispatchEvent(this.controls.startEvent); } } onControlsMouseMove(event) { if (!this.controls.enableRotate && !this.controls.enablePan && !this.controls.enableZoom) return; event.preventDefault(); switch (this.controls.state) { case 'ROTATE': if (!this.controls.enableRotate) return; this.handleMouseMoveRotate(event); break; case 'DOLLY': if (!this.controls.enableZoom) return; this.handleMouseMoveDolly(event); break; case 'PAN': if (!this.controls.enablePan) return; this.handleMouseMovePan(event); break; } } onControlsMouseUp(event) { this.controls.state = 'NONE'; this.dispatchEvent(this.controls.endEvent); } onControlsWheel(event) { if (!this.controls.enableZoom || (this.controls.state !== 'NONE' && this.controls.state !== 'ROTATE')) return; event.preventDefault(); this.dispatchEvent(this.controls.startEvent); this.handleMouseWheel(event); this.dispatchEvent(this.controls.endEvent); } onControlsKeyDown(event) { if (!this.controls.enablePan) return; let needsUpdate = false; switch (event.code) { case this.controls.keys.UP: this.pan(0, this.controls.keyPanSpeed); needsUpdate = true; break; case this.controls.keys.BOTTOM: this.pan(0, -this.controls.keyPanSpeed); needsUpdate = true; break; case this.controls.keys.LEFT: this.pan(this.controls.keyPanSpeed, 0); needsUpdate = true; break; case this.controls.keys.RIGHT: this.pan(-this.controls.keyPanSpeed, 0); needsUpdate = true; break; } if (needsUpdate) { event.preventDefault(); this.updateControls(); } } // Touch event handlers onControlsTouchStart(event) { if (!this.controls.enableRotate && !this.controls.enablePan && !this.controls.enableZoom) return; switch (event.touches.length) { case 1: switch (this.controls.touches.ONE) { case 0: // ROTATE if (!this.controls.enableRotate) return; this.handleTouchStartRotate(event); this.controls.state = 'TOUCH_ROTATE'; break; case 1: // PAN if (!this.controls.enablePan) return; this.handleTouchStartPan(event); this.controls.state = 'TOUCH_PAN'; break; default: this.controls.state = 'NONE'; } break; case 2: switch (this.controls.touches.TWO) { case 2: // DOLLY_PAN if (!this.controls.enableZoom && !this.controls.enablePan) return; this.handleTouchStartDollyPan(event); this.controls.state = 'TOUCH_DOLLY_PAN'; break; default: this.controls.state = 'NONE'; } break; default: this.controls.state = 'NONE'; } if (this.controls.state !== 'NONE') { this.dispatchEvent(this.controls.startEvent); } } onControlsTouchMove(event) { if (!this.controls.enableRotate && !this.controls.enablePan && !this.controls.enableZoom) return; event.preventDefault(); switch (this.controls.state) { case 'TOUCH_ROTATE': if (!this.controls.enableRotate) return; this.handleTouchMoveRotate(event); this.updateControls(); break; case 'TOUCH_PAN': if (!this.controls.enablePan) return; this.handleTouchMovePan(event); this.updateControls(); break; case 'TOUCH_DOLLY_PAN': if (!this.controls.enableZoom && !this.controls.enablePan) return; this.handleTouchMoveDollyPan(event); this.updateControls(); break; default: this.controls.state = 'NONE'; } } onControlsTouchEnd(event) { this.controls.state = 'NONE'; this.dispatchEvent(this.controls.endEvent); } // Mouse interaction handlers handleMouseDownRotate(event) { this.controls.rotateStart.set(event.clientX, event.clientY); } handleMouseDownDolly(event) { this.controls.dollyStart.set(event.clientX, event.clientY); } handleMouseDownPan(event) { this.controls.panStart.set(event.clientX, event.clientY); } handleMouseMoveRotate(event) { this.controls.rotateEnd.set(event.clientX, event.clientY); this.controls.rotateDelta.subVectors(this.controls.rotateEnd, this.controls.rotateStart).multiplyScalar(this.controls.rotateSpeed); const element = this.renderer.domElement; this.rotateLeft(2 * Math.PI * this.controls.rotateDelta.x / element.clientHeight); this.rotateUp(2 * Math.PI * this.controls.rotateDelta.y / element.clientHeight); this.controls.rotateStart.copy(this.controls.rotateEnd); this.updateControls(); } handleMouseMoveDolly(event) { this.controls.dollyEnd.set(event.clientX, event.clientY); this.controls.dollyDelta.subVectors(this.controls.dollyEnd, this.controls.dollyStart); if (this.controls.dollyDelta.y > 0) { this.dollyOut(this.getZoomScale()); } else if (this.controls.dollyDelta.y < 0) { this.dollyIn(this.getZoomScale()); } this.controls.dollyStart.copy(this.controls.dollyEnd); this.updateControls(); } handleMouseMovePan(event) { this.controls.panEnd.set(event.clientX, event.clientY); this.controls.panDelta.subVectors(this.controls.panEnd, this.controls.panStart).multiplyScalar(this.controls.panSpeed); this.pan(this.controls.panDelta.x, this.controls.panDelta.y); this.controls.panStart.copy(this.controls.panEnd); this.updateControls(); } handleMouseWheel(event) { if (event.deltaY < 0) { this.dollyIn(this.getZoomScale()); } else if (event.deltaY > 0) { this.dollyOut(this.getZoomScale()); } this.updateControls(); } // Touch interaction handlers handleTouchStartRotate(event) { if (event.touches.length === 1) { this.controls.rotateStart.set(event.touches[0].pageX, event.touches[0].pageY); } else { const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); this.controls.rotateStart.set(x, y); } } handleTouchStartPan(event) { if (event.touches.length === 1) { this.controls.panStart.set(event.touches[0].pageX, event.touches[0].pageY); } else { const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); this.controls.panStart.set(x, y); } } handleTouchStartDollyPan(event) { if (this.controls.enableZoom) this.handleTouchStartDolly(event); if (this.controls.enablePan) this.handleTouchStartPan(event); } handleTouchStartDolly(event) { const dx = event.touches[0].pageX - event.touches[1].pageX; const dy = event.touches[0].pageY - event.touches[1].pageY; const distance = Math.sqrt(dx * dx + dy * dy); this.controls.dollyStart.set(0, distance); } handleTouchMoveRotate(event) { if (event.touches.length === 1) { this.controls.rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY); } else { const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); this.controls.rotateEnd.set(x, y); } this.controls.rotateDelta.subVectors(this.controls.rotateEnd, this.controls.rotateStart).multiplyScalar(this.controls.rotateSpeed); const element = this.renderer.domElement; this.rotateLeft(2 * Math.PI * this.controls.rotateDelta.x / element.clientHeight); this.rotateUp(2 * Math.PI * this.controls.rotateDelta.y / element.clientHeight); this.controls.rotateStart.copy(this.controls.rotateEnd); } handleTouchMovePan(event) { if (event.touches.length === 1) { this.controls.panEnd.set(event.touches[0].pageX, event.touches[0].pageY); } else { const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); this.controls.panEnd.set(x, y); } this.controls.panDelta.subVectors(this.controls.panEnd, this.controls.panStart).multiplyScalar(this.controls.panSpeed); this.pan(this.controls.panDelta.x, this.controls.panDelta.y); this.controls.panStart.copy(this.controls.panEnd); } handleTouchMoveDollyPan(event) { if (this.controls.enableZoom) this.handleTouchMoveDolly(event); if (this.controls.enablePan) this.handleTouchMovePan(event); } handleTouchMoveDolly(event) { const dx = event.touches[0].pageX - event.touches[1].pageX; const dy = event.touches[0].pageY - event.touches[1].pageY; const distance = Math.sqrt(dx * dx + dy * dy); this.controls.dollyEnd.set(0, distance); this.controls.dollyDelta.set(0, Math.pow(this.controls.dollyEnd.y / this.controls.dollyStart.y, this.controls.zoomSpeed)); this.dollyOut(this.controls.dollyDelta.y); this.controls.dollyStart.copy(this.controls.dollyEnd); } // Setup proper lighting for interior design setupLighting() { // Remove existing lights const lightsToRemove = []; this.scene.traverse((child) => { if (child instanceof THREE.Light) { lightsToRemove.push(child); } }); lightsToRemove.forEach(light => this.scene.remove(light)); // Ambient light for overall illumination const ambientLight = new THREE.AmbientLight(0x404040, 0.4); this.scene.add(ambientLight); // Main directional light (sun) const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(10, 10, 5); directionalLight.castShadow = true; directionalLight.shadow.mapSize.width = 2048; directionalLight.shadow.mapSize.height = 2048; directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 50; directionalLight.shadow.camera.left = -10; directionalLight.shadow.camera.right = 10; directionalLight.shadow.camera.top = 10; directionalLight.shadow.camera.bottom = -10; this.scene.add(directionalLight); // Fill light from opposite direction const fillLight = new THREE.DirectionalLight(0xffffff, 0.3); fillLight.position.set(-5, 5, -5); this.scene.add(fillLight); // Point light for interior spaces const pointLight = new THREE.PointLight(0xffffff, 0.5, 100); pointLight.position.set(0, 8, 0); this.scene.add(pointLight); console.log('Lighting setup complete'); } // OrbitControls mouse event handlers onControlsMouseDown(event) { event.preventDefault(); if (event.button === 0) { this.controls.state = 'ROTATE'; this.controls.rotateStart.set(event.clientX, event.clientY); } else if (event.button === 2) { this.controls.state = 'PAN'; this.controls.panStart.set(event.clientX, event.clientY); } this.renderer.domElement.style.cursor = this.controls.state === 'ROTATE' ? 'grabbing' : 'move'; } onControlsMouseMove(event) { if (this.controls.state === 'NONE') return; event.preventDefault(); if (this.controls.state === 'ROTATE') { this.controls.rotateEnd.set(event.clientX, event.clientY); this.controls.rotateDelta.subVectors(this.controls.rotateEnd, this.controls.rotateStart); const element = this.renderer.domElement; // Rotate around Y axis (horizontal movement) this.rotateLeft(2 * Math.PI * this.controls.rotateDelta.x / element.clientHeight); // Rotate around X axis (vertical movement) this.rotateUp(2 * Math.PI * this.controls.rotateDelta.y / element.clientHeight); this.controls.rotateStart.copy(this.controls.rotateEnd); } else if (this.controls.state === 'PAN') { this.controls.panEnd.set(event.clientX, event.clientY); this.controls.panDelta.subVectors(this.controls.panEnd, this.controls.panStart); this.pan(this.controls.panDelta.x, this.controls.panDelta.y); this.controls.panStart.copy(this.controls.panEnd); } this.updateControls(); } onControlsMouseUp(event) { this.controls.state = 'NONE'; this.renderer.domElement.style.cursor = 'default'; } onControlsWheel(event) { event.preventDefault(); if (event.deltaY < 0) { this.dollyIn(this.getZoomScale()); } else if (event.deltaY > 0) { this.dollyOut(this.getZoomScale()); } this.updateControls(); } // Touch event handlers onControlsTouchStart(event) { event.preventDefault(); if (event.touches.length === 1) { this.controls.state = 'ROTATE'; this.controls.rotateStart.set(event.touches[0].pageX, event.touches[0].pageY); } } onControlsTouchMove(event) { event.preventDefault(); if (event.touches.length === 1 && this.controls.state === 'ROTATE') { this.controls.rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY); this.controls.rotateDelta.subVectors(this.controls.rotateEnd, this.controls.rotateStart); const element = this.renderer.domElement; this.rotateLeft(2 * Math.PI * this.controls.rotateDelta.x / element.clientHeight); this.rotateUp(2 * Math.PI * this.controls.rotateDelta.y / element.clientHeight); this.controls.rotateStart.copy(this.controls.rotateEnd); this.updateControls(); } } onControlsTouchEnd(event) { this.controls.state = 'NONE'; } // Enhanced OrbitControls core methods rotateLeft(angle) { this.controls.sphericalDelta.theta -= angle; } rotateUp(angle) { this.controls.sphericalDelta.phi -= angle; } pan(deltaX, deltaY) { const element = this.renderer.domElement; if (this.camera.isPerspectiveCamera) { // Perspective camera panning const position = this.camera.position; const offset = position.clone().sub(this.controls.target); let targetDistance = offset.length(); // Half of the fov is center to top of screen targetDistance *= Math.tan((this.camera.fov / 2) * Math.PI / 180.0); // We actually don't use screenWidth, since perspective camera is fixed to screen height this.panLeft(2 * deltaX * targetDistance / element.clientHeight, this.camera.matrix); this.panUp(2 * deltaY * targetDistance / element.clientHeight, this.camera.matrix); } else if (this.camera.isOrthographicCamera) { // Orthographic camera panning this.panLeft(deltaX * (this.camera.right - this.camera.left) / this.camera.zoom / element.clientWidth, this.camera.matrix); this.panUp(deltaY * (this.camera.top - this.camera.bottom) / this.camera.zoom / element.clientHeight, this.camera.matrix); } } panLeft(distance, objectMatrix) { const v = new THREE.Vector3(); v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix v.multiplyScalar(-distance); this.controls.panOffset.add(v); } panUp(distance, objectMatrix) { const v = new THREE.Vector3(); if (this.controls.screenSpacePanning === true) { v.setFromMatrixColumn(objectMatrix, 1); } else { v.setFromMatrixColumn(objectMatrix, 0); v.crossVectors(this.camera.up, v); } v.multiplyScalar(distance); this.controls.panOffset.add(v); } dollyOut(dollyScale) { if (this.camera.isPerspectiveCamera || this.camera.isOrthographicCamera) { this.controls.scale /= dollyScale; } else { console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.'); this.controls.enableZoom = false; } } dollyIn(dollyScale) { if (this.camera.isPerspectiveCamera || this.camera.isOrthographicCamera) { this.controls.scale *= dollyScale; } else { console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.'); this.controls.enableZoom = false; } } getZoomScale() { return Math.pow(0.95, this.controls.zoomSpeed); } getAutoRotationAngle() { return 2 * Math.PI / 60 / 60 * this.controls.autoRotateSpeed; } getPolarAngle() { return this.controls.spherical.phi; } getAzimuthalAngle() { return this.controls.spherical.theta; } getDistance() { return this.camera.position.distanceTo(this.controls.target); } // Event dispatcher methods dispatchEvent(event) { // Simple event dispatching - could be enhanced with proper EventDispatcher if (event.type === 'change') { // Camera has changed, trigger any change listeners } } // Save and restore state saveState() { this.controls.target0 = this.controls.target.clone(); this.controls.position0 = this.camera.position.clone(); this.controls.zoom0 = this.camera.zoom; } reset() { this.controls.target.copy(this.controls.target0); this.camera.position.copy(this.controls.position0); this.camera.zoom = this.controls.zoom0; this.camera.updateProjectionMatrix(); this.dispatchEvent(this.controls.changeEvent); this.updateControls(); this.controls.state = 'NONE'; } // Enhanced update method following Three.js OrbitControls patterns updateControls() { const offset = new THREE.Vector3(); // So camera.up is the orbit axis const quat = new THREE.Quaternion().setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 1, 0)); const quatInverse = quat.clone().invert(); const lastPosition = new THREE.Vector3(); const lastQuaternion = new THREE.Quaternion(); const position = this.camera.position; offset.copy(position).sub(this.controls.target); // Rotate offset to "y-axis-is-up" space offset.applyQuaternion(quat); // Angle from z-axis around y-axis this.controls.spherical.setFromVector3(offset); if (this.controls.autoRotate && this.controls.state === 'NONE') { this.rotateLeft(this.getAutoRotationAngle()); } if (this.controls.enableDamping === true) { this.controls.spherical.theta += this.controls.sphericalDelta.theta * this.controls.dampingFactor; this.controls.spherical.phi += this.controls.sphericalDelta.phi * this.controls.dampingFactor; } else { this.controls.spherical.theta += this.controls.sphericalDelta.theta; this.controls.spherical.phi += this.controls.sphericalDelta.phi; } // Restrict theta to be between desired limits let min = this.controls.minAzimuthAngle; let max = this.controls.maxAzimuthAngle; if (isFinite(min) && isFinite(max)) { if (min < -Math.PI) min += 2 * Math.PI; else if (min > Math.PI) min -= 2 * Math.PI; if (max < -Math.PI) max += 2 * Math.PI; else if (max > Math.PI) max -= 2 * Math.PI; if (min <= max) { this.controls.spherical.theta = Math.max(min, Math.min(max, this.controls.spherical.theta)); } else { this.controls.spherical.theta = (this.controls.spherical.theta > (min + max) / 2) ? Math.max(min, this.controls.spherical.theta) : Math.min(max, this.controls.spherical.theta); } } // Restrict phi to be between desired limits this.controls.spherical.phi = Math.max(this.controls.minPolarAngle, Math.min(this.controls.maxPolarAngle, this.controls.spherical.phi)); this.controls.spherical.makeSafe(); this.controls.spherical.radius *= this.controls.scale; // Restrict radius to be between desired limits this.controls.spherical.radius = Math.max(this.controls.minDistance, Math.min(this.controls.maxDistance, this.controls.spherical.radius)); // Move target to panned location if (this.controls.enableDamping === true) { this.controls.target.addScaledVector(this.controls.panOffset, this.controls.dampingFactor); } else { this.controls.target.add(this.controls.panOffset); } offset.setFromSpherical(this.controls.spherical); // Rotate offset back to "camera-up-vector-is-up" space offset.applyQuaternion(quatInverse); position.copy(this.controls.target).add(offset); this.camera.lookAt(this.controls.target); if (this.controls.enableDamping === true) { this.controls.sphericalDelta.theta *= (1 - this.controls.dampingFactor); this.controls.sphericalDelta.phi *= (1 - this.controls.dampingFactor); this.controls.panOffset.multiplyScalar(1 - this.controls.dampingFactor); } else { this.controls.sphericalDelta.set(0, 0, 0); this.controls.panOffset.set(0, 0, 0); } this.controls.scale = 1; // Update condition is: // min(camera displacement, camera rotation in radians)^2 > EPS // using small-angle approximation cos(x/2) = 1 - x^2 / 8 if (this.controls.zoomChanged || lastPosition.distanceToSquared(this.camera.position) > this.controls.EPS || 8 * (1 - lastQuaternion.dot(this.camera.quaternion)) > this.controls.EPS) { this.dispatchEvent(this.controls.changeEvent); lastPosition.copy(this.camera.position); lastQuaternion.copy(this.camera.quaternion); this.controls.zoomChanged = false; return true; } return false; } // Enhanced camera control methods for UI buttons resetCamera() { this.controls.target.set(0, 0, 0); this.camera.position.set(15, 15, 15); this.controls.spherical.setFromVector3(this.camera.position.clone().sub(this.controls.target)); this.controls.panOffset.set(0, 0, 0); this.controls.scale = 1; this.updateControls(); } setCameraView(view) { const currentDistance = this.camera.position.distanceTo(this.controls.target); const distance = Math.max(currentDistance, 10); // Ensure minimum distance let position = new THREE.Vector3(); switch(view) { case 'top': position.set(0, distance, 0); break; case 'front': position.set(0, 0, distance); break; case 'side': position.set(distance, 0, 0); break; case 'isometric': default: position.set(distance * 0.7, distance * 0.7, distance * 0.7); break; } this.camera.position.copy(this.controls.target).add(position); this.controls.spherical.setFromVector3(position); this.updateControls(); } zoomIn() { this.dollyIn(this.getZoomScale()); this.updateControls(); } zoomOut() { this.dollyOut(this.getZoomScale()); this.updateControls(); } panCamera(direction, amount = 1) { const distance = this.camera.position.distanceTo(this.controls.target); const panSpeed = Math.max(distance * 0.05, 0.5) * amount; // Create movement vector based on camera orientation const moveVector = new THREE.Vector3(); switch(direction) { case 'left': moveVector.setFromMatrixColumn(this.camera.matrix, 0).multiplyScalar(-panSpeed); break; case 'right': moveVector.setFromMatrixColumn(this.camera.matrix, 0).multiplyScalar(panSpeed); break; case 'up': moveVector.setFromMatrixColumn(this.camera.matrix, 1).multiplyScalar(panSpeed); break; case 'down': moveVector.setFromMatrixColumn(this.camera.matrix, 1).multiplyScalar(-panSpeed); break; } this.controls.target.add(moveVector); this.updateControls(); } // Additional control methods setTarget(x, y, z) { this.controls.target.set(x, y, z); this.updateControls(); } setDistance(distance) { const direction = this.camera.position.clone().sub(this.controls.target).normalize(); this.camera.position.copy(this.controls.target).add(direction.multiplyScalar(distance)); this.controls.spherical.setFromVector3(this.camera.position.clone().sub(this.controls.target)); this.updateControls(); } enableAutoRotate(enable = true) { this.controls.autoRotate = enable; } setAutoRotateSpeed(speed) { this.controls.autoRotateSpeed = speed; } setDampingFactor(factor) { this.controls.dampingFactor = Math.max(0, Math.min(1, factor)); } setZoomSpeed(speed) { this.controls.zoomSpeed = speed; } setRotateSpeed(speed) { this.controls.rotateSpeed = speed; } setPanSpeed(speed) { this.controls.panSpeed = speed; } // Convert 2D elements to 3D convertTo3D(designElements) { console.log('Converting to 3D with elements:', designElements); if (!this.isInitialized) { this.initialize(); } if (!this.scene || !this.camera || !this.renderer) { console.error('ThreeJS not properly initialized'); return; } // Clear 3D scene (keep lights) const objectsToRemove = []; this.scene.traverse((child) => { if (child !== this.scene && !(child instanceof THREE.Light) && !(child instanceof THREE.AxesHelper)) { objectsToRemove.push(child); } }); objectsToRemove.forEach(obj => this.scene.remove(obj)); // Add a ground plane for reference const groundGeometry = new THREE.PlaneGeometry(50, 50); const groundMaterial = new THREE.MeshLambertMaterial({ color: 0xf0f0f0, transparent: true, opacity: 0.5 }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.1; ground.receiveShadow = true; this.scene.add(ground); // Add coordinate axes for debugging const axesHelper = new THREE.AxesHelper(10); this.scene.add(axesHelper); if (designElements.length === 0) { // Add a test cube if no elements console.log('No design elements, adding test cube'); const geometry = new THREE.BoxGeometry(2, 2, 2); const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 }); const cube = new THREE.Mesh(geometry, material); cube.position.set(0, 1, 0); cube.castShadow = true; cube.receiveShadow = true; this.scene.add(cube); // Set camera position directly for test this.camera.position.set(10, 10, 10); this.camera.lookAt(0, 0, 0); this.controls.target.set(0, 0, 0); this.controls.spherical.setFromVector3(this.camera.position.clone().sub(this.controls.target)); console.log('Test cube added at:', cube.position); console.log('Camera positioned at:', this.camera.position); console.log('Camera looking at:', this.controls.target); } else { // Separate elements by type for proper rendering order const walls = designElements.filter(el => el.type === 'wall'); const rooms = designElements.filter(el => el.type === 'room'); const floors = designElements.filter(el => el.type === 'floor'); const doors = designElements.filter(el => el.type === 'door'); const windows = designElements.filter(el => el.type === 'window'); console.log('Processing elements:', { walls: walls.length, rooms: rooms.length, floors: floors.length, doors: doors.length, windows: windows.length }); // Render in order: rooms (floors), floors, walls, doors, windows rooms.forEach(element => this.create3DRoom(element)); floors.forEach(element => this.create3DFloor(element)); walls.forEach(element => this.create3DWall(element, doors, windows)); doors.forEach(element => this.create3DDoor(element)); windows.forEach(element => this.create3DWindow(element)); // Set up camera for better view of design this.setupCameraForDesign(designElements); } this.startAnimation(); } // Create 3D room (floor) create3DRoom(element) { console.log('Creating 3D room:', element); const width = Math.abs(element.endX - element.startX) / 50; const depth = Math.abs(element.endY - element.startY) / 50; // Y becomes depth in 3D const thickness = 0.1; // Create floor const geometry = new THREE.BoxGeometry(width, thickness, depth); const material = new THREE.MeshLambertMaterial({ color: element.color || '#f0f0f0', transparent: true, opacity: 0.8 }); const floor = new THREE.Mesh(geometry, material); // Position floor (Y becomes Z, and we center it) const centerX = (element.startX + element.endX) / 100; const centerZ = (element.startY + element.endY) / 100; floor.position.set(centerX, -thickness/2, centerZ); console.log('Floor positioned at:', floor.position); // Add room label in 3D if (element.name) { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = 256; canvas.height = 64; context.fillStyle = 'rgba(0,0,0,0.7)'; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = '#ffffff'; context.font = 'bold 24px Arial'; context.textAlign = 'center'; context.fillText(element.name, canvas.width / 2, canvas.height / 2 + 8); const texture = new THREE.CanvasTexture(canvas); const labelMaterial = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide }); const labelGeometry = new THREE.PlaneGeometry(width * 0.6, 0.5); const label = new THREE.Mesh(labelGeometry, labelMaterial); label.position.set(centerX, 0.1, centerZ); label.rotation.x = -Math.PI / 2; // Lay flat on floor this.scene.add(label); } this.scene.add(floor); } // Create 3D floor (standalone floor element) create3DFloor(element) { console.log('Creating 3D floor:', element); const width = element.width / 50; const depth = element.height / 50; const thickness = 0.1; // Create floor geometry const geometry = new THREE.BoxGeometry(width, thickness, depth); // Create material based on floor material type let materialColor = element.color || '#D2B48C'; const material = new THREE.MeshLambertMaterial({ color: materialColor, transparent: true, opacity: element.opacity || 0.7 }); const floor = new THREE.Mesh(geometry, material); // Position floor (convert 2D coordinates to 3D) const centerX = (element.startX + element.width / 2) / 50; const centerZ = (element.startY + element.height / 2) / 50; floor.position.set(centerX, -thickness/2, centerZ); // Add texture pattern based on material if (element.material === 'wood' || element.material === 'tile') { // Add a subtle grid pattern const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = 256; canvas.height = 256; // Fill with base color context.fillStyle = materialColor; context.fillRect(0, 0, canvas.width, canvas.height); // Draw grid pattern context.strokeStyle = this.darkenColor(materialColor, 20); context.lineWidth = 2; const spacing = element.material === 'wood' ? 64 : 32; for (let i = 0; i < canvas.width; i += spacing) { context.beginPath(); context.moveTo(i, 0); context.lineTo(i, canvas.height); context.stroke(); if (element.material === 'tile') { context.beginPath(); context.moveTo(0, i); context.lineTo(canvas.width, i); context.stroke(); } } const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(width / 2, depth / 2); floor.material.map = texture; floor.material.needsUpdate = true; } console.log('Floor positioned at:', floor.position); this.scene.add(floor); } // Create 3D wall with openings for doors and windows create3DWall(element, doors, windows) { const length = Math.sqrt( Math.pow(element.endX - element.startX, 2) + Math.pow(element.endY - element.startY, 2) ) / 50; const height = 2.5; // Standard wall height const thickness = element.thickness ? element.thickness / 50 : 0.15; // Find doors and windows attached to this wall const attachedDoors = doors.filter(door => door.attachedToWall === element.id); const attachedWindows = windows.filter(window => window.attachedToWall === element.id); // Create wall with openings const wallGroup = new THREE.Group(); if (attachedDoors.length === 0 && attachedWindows.length === 0) { // Simple solid wall - correct orientation for X-Z plane const geometry = new THREE.BoxGeometry(length, height, thickness); const material = new THREE.MeshLambertMaterial({ color: element.color || '#cccccc', side: THREE.DoubleSide }); const wall = new THREE.Mesh(geometry, material); wall.position.set(0, height / 2, 0); // Y is height, not Z wallGroup.add(wall); } else { // Wall with openings - create segments this.createWallWithOpenings(wallGroup, length, thickness, height, attachedDoors, attachedWindows, element.color); } // Position and rotate the wall group correctly const angle = Math.atan2(element.endY - element.startY, element.endX - element.startX); // Convert 2D coordinates to 3D world coordinates const centerX = (element.startX + element.endX) / 100; const centerZ = (element.startY + element.endY) / 100; // Y becomes Z in 3D // Rotate around Y-axis (vertical axis) instead of Z-axis wallGroup.rotation.y = angle; wallGroup.position.set(centerX, 0, centerZ); this.scene.add(wallGroup); } // Create wall segments with openings createWallWithOpenings(wallGroup, length, thickness, height, doors, windows, color) { const openings = []; // Add door openings doors.forEach(door => { const doorWidth = 0.8; // Standard door width in 3D units const doorHeight = 2.1; // Standard door height const position = door.wallPosition * length - length/2; openings.push({ type: 'door', start: position - doorWidth/2, end: position + doorWidth/2, height: doorHeight, bottomOffset: 0 }); }); // Add window openings windows.forEach(window => { const windowWidth = 1.2; // Standard window width const windowHeight = 1.2; // Standard window height const windowSill = 0.9; // Height from floor to window sill const position = window.wallPosition * length - length/2; openings.push({ type: 'window', start: position - windowWidth/2, end: position + windowWidth/2, height: windowHeight, bottomOffset: windowSill }); }); // Sort openings by position openings.sort((a, b) => a.start - b.start); // Create wall segments between openings let currentPos = -length/2; const material = new THREE.MeshLambertMaterial({ color: color || '#cccccc', side: THREE.DoubleSide }); openings.forEach(opening => { // Wall segment before opening - correct orientation (length, height, thickness) if (opening.start > currentPos) { const segmentLength = opening.start - currentPos; const geometry = new THREE.BoxGeometry(segmentLength, height, thickness); const segment = new THREE.Mesh(geometry, material); segment.position.set(currentPos + segmentLength/2, height/2, 0); wallGroup.add(segment); } // Wall segments above and below window openings if (opening.type === 'window') { // Below window (sill) - correct orientation if (opening.bottomOffset > 0) { const sillGeometry = new THREE.BoxGeometry( opening.end - opening.start, opening.bottomOffset, thickness ); const sill = new THREE.Mesh(sillGeometry, material); sill.position.set( (opening.start + opening.end) / 2, opening.bottomOffset / 2, 0 ); wallGroup.add(sill); } // Above window (header) - correct orientation const headerHeight = height - (opening.bottomOffset + opening.height); if (headerHeight > 0) { const headerGeometry = new THREE.BoxGeometry( opening.end - opening.start, headerHeight, thickness ); const header = new THREE.Mesh(headerGeometry, material); header.position.set( (opening.start + opening.end) / 2, opening.bottomOffset + opening.height + headerHeight / 2, 0 ); wallGroup.add(header); } } currentPos = opening.end; }); // Final wall segment after last opening - correct orientation if (currentPos < length/2) { const segmentLength = length/2 - currentPos; const geometry = new THREE.BoxGeometry(segmentLength, height, thickness); const segment = new THREE.Mesh(geometry, material); segment.position.set(currentPos + segmentLength/2, height/2, 0); wallGroup.add(segment); } } // Create 3D door create3DDoor(element) { const doorGroup = new THREE.Group(); // Door frame const frameThickness = 0.05; const frameWidth = 0.8; const frameHeight = 2.1; const frameDepth = 0.15; const frameMaterial = new THREE.MeshLambertMaterial({ color: '#8B4513' }); // Door panel - correct orientation (width, height, depth) const panelGeometry = new THREE.BoxGeometry(frameWidth - 0.1, frameHeight - 0.1, 0.05); const panelMaterial = new THREE.MeshLambertMaterial({ color: element.color || '#8B4513' }); const panel = new THREE.Mesh(panelGeometry, panelMaterial); panel.position.set(0, frameHeight/2, frameDepth/2); doorGroup.add(panel); // Door handle - correct positioning const handleGeometry = new THREE.SphereGeometry(0.03); const handleMaterial = new THREE.MeshLambertMaterial({ color: '#FFD700' }); const handle = new THREE.Mesh(handleGeometry, handleMaterial); handle.position.set(frameWidth/2 - 0.1, frameHeight/2, frameDepth/2 + 0.03); doorGroup.add(handle); // Position door with correct coordinate conversion const centerX = (element.startX + element.endX) / 100; const centerZ = (element.startY + element.endY) / 100; // Y becomes Z doorGroup.position.set(centerX, 0, centerZ); this.scene.add(doorGroup); } // Create 3D window create3DWindow(element) { const windowGroup = new THREE.Group(); const windowWidth = 1.2; const windowHeight = 1.2; const frameThickness = 0.05; const glassThickness = 0.01; // Window frame const frameMaterial = new THREE.MeshLambertMaterial({ color: '#FFFFFF' }); // Horizontal frame pieces - correct orientation const hFrameGeometry = new THREE.BoxGeometry(windowWidth, frameThickness, frameThickness); const topFrame = new THREE.Mesh(hFrameGeometry, frameMaterial); topFrame.position.set(0, windowHeight/2, 0); const bottomFrame = new THREE.Mesh(hFrameGeometry, frameMaterial); bottomFrame.position.set(0, -windowHeight/2, 0); windowGroup.add(topFrame, bottomFrame); // Vertical frame pieces - correct orientation const vFrameGeometry = new THREE.BoxGeometry(frameThickness, windowHeight, frameThickness); const leftFrame = new THREE.Mesh(vFrameGeometry, frameMaterial); leftFrame.position.set(-windowWidth/2, 0, 0); const rightFrame = new THREE.Mesh(vFrameGeometry, frameMaterial); rightFrame.position.set(windowWidth/2, 0, 0); windowGroup.add(leftFrame, rightFrame); // Glass panes - correct orientation const glassGeometry = new THREE.BoxGeometry( windowWidth - frameThickness, windowHeight - frameThickness, glassThickness ); const glassMaterial = new THREE.MeshLambertMaterial({ color: '#87CEEB', transparent: true, opacity: 0.3 }); const glass = new THREE.Mesh(glassGeometry, glassMaterial); windowGroup.add(glass); // Position window with correct coordinate conversion const centerX = (element.startX + element.endX) / 100; const centerZ = (element.startY + element.endY) / 100; // Y becomes Z windowGroup.position.set(centerX, 0.9 + windowHeight/2, centerZ); // Y is height this.scene.add(windowGroup); } // Setup camera for optimal viewing of design setupCameraForDesign(designElements) { if (!this.controls || designElements.length === 0) return; // Calculate bounding box of all elements let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; designElements.forEach(element => { minX = Math.min(minX, element.startX, element.endX); maxX = Math.max(maxX, element.startX, element.endX); minY = Math.min(minY, element.startY, element.endY); maxY = Math.max(maxY, element.startY, element.endY); }); // Convert 2D coordinates to 3D world coordinates (scale down significantly) const centerX = (minX + maxX) / 200; // Scale down more const centerZ = (minY + maxY) / 200; // Y becomes Z in 3D const sizeX = (maxX - minX) / 100; const sizeZ = (maxY - minY) / 100; const maxSize = Math.max(sizeX, sizeZ, 5); // Minimum size // Update camera controls to focus on design this.controls.target.set(centerX, 1, centerZ); // Slightly elevated target this.controls.spherical.set(maxSize * 3, Math.PI / 3, Math.PI / 4); // Good isometric view this.controls.panOffset.set(0, 0, 0); // Clamp distance to reasonable bounds this.controls.spherical.radius = Math.max( this.controls.minDistance, Math.min(this.controls.maxDistance, this.controls.spherical.radius) ); this.updateControls(); console.log('Camera focused on:', this.controls.target); console.log('Camera distance:', this.controls.spherical.radius); } // Animation loop animate() { if (!this.isInitialized || !this.scene || !this.camera || !this.renderer) { console.warn('Animation stopped - missing components:', { isInitialized: this.isInitialized, scene: !!this.scene, camera: !!this.camera, renderer: !!this.renderer }); return; } this.animationId = requestAnimationFrame(() => this.animate()); // Update controls with damping if (this.controls && this.controls.enableDamping) { this.updateControls(); } // Debug: Log first few renders if (!this.renderCount) this.renderCount = 0; if (this.renderCount < 5) { console.log(`Render ${this.renderCount}: Camera at`, this.camera.position, 'looking at', this.controls?.target); this.renderCount++; } this.renderer.render(this.scene, this.camera); } // Start animation loop startAnimation() { if (this.animationId) { cancelAnimationFrame(this.animationId); } this.animate(); } // Stop animation loop stopAnimation() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } // Show/hide 3D viewer show3DViewer() { console.log('Showing 3D viewer'); document.getElementById('designCanvas').classList.add('hidden'); document.getElementById('3dViewer').classList.remove('hidden'); // Update view toggle buttons this.updateViewToggleButtons('3d'); // Update instruction text if (window.app && window.app.uiManager) { window.app.uiManager.update3DInstructionText(); } // Initialize if needed and resize renderer setTimeout(() => { if (!this.isInitialized) { this.initialize(); } this.resizeRenderer(); this.startAnimation(); }, 100); } hide3DViewer() { console.log('Hiding 3D viewer'); document.getElementById('designCanvas').classList.remove('hidden'); document.getElementById('3dViewer').classList.add('hidden'); // Stop animation to save resources this.stopAnimation(); // Update view toggle buttons this.updateViewToggleButtons('2d'); // Update instruction text back to current tool if (window.app && window.app.uiManager) { window.app.uiManager.update2DInstructionText(window.app.currentTool); } } // Toggle between 2D and 3D views toggleView(designElements) { const is3DVisible = !document.getElementById('3dViewer').classList.contains('hidden'); if (is3DVisible) { this.hide3DViewer(); } else { this.show3DViewer(); this.convertTo3D(designElements || []); } } // Update view toggle button states updateViewToggleButtons(activeView) { // Update sidebar view buttons const view2DBtn = document.getElementById('view2DBtn'); const view3DBtn = document.getElementById('view3DBtn'); if (view2DBtn && view3DBtn) { if (activeView === '2d') { view2DBtn.classList.add('bg-blue-500', 'text-white'); view2DBtn.classList.remove('text-gray-600', 'hover:text-gray-800'); view3DBtn.classList.remove('bg-blue-500', 'text-white'); view3DBtn.classList.add('text-gray-600', 'hover:text-gray-800'); } else { view3DBtn.classList.add('bg-blue-500', 'text-white'); view3DBtn.classList.remove('text-gray-600', 'hover:text-gray-800'); view2DBtn.classList.remove('bg-blue-500', 'text-white'); view2DBtn.classList.add('text-gray-600', 'hover:text-gray-800'); } } // Update top toolbar view buttons (dropdown) const view2DBtnTop = document.getElementById('view2DBtnTop'); const view3DBtnTop = document.getElementById('view3DBtnTop'); if (view2DBtnTop && view3DBtnTop) { if (activeView === '2d') { view2DBtnTop.classList.add('bg-blue-500', 'text-white'); view2DBtnTop.classList.remove('hover:bg-gray-200'); view3DBtnTop.classList.remove('bg-blue-500', 'text-white'); view3DBtnTop.classList.add('hover:bg-gray-200'); } else { view3DBtnTop.classList.add('bg-blue-500', 'text-white'); view3DBtnTop.classList.remove('hover:bg-gray-200'); view2DBtnTop.classList.remove('bg-blue-500', 'text-white'); view2DBtnTop.classList.add('hover:bg-gray-200'); } } // Update quick access view buttons (always visible) const view2DBtnQuick = document.getElementById('view2DBtnQuick'); const view3DBtnQuick = document.getElementById('view3DBtnQuick'); if (view2DBtnQuick && view3DBtnQuick) { if (activeView === '2d') { view2DBtnQuick.classList.add('bg-blue-500', 'text-white'); view2DBtnQuick.classList.remove('hover:bg-gray-200'); view3DBtnQuick.classList.remove('bg-blue-500', 'text-white'); view3DBtnQuick.classList.add('hover:bg-gray-200'); } else { view3DBtnQuick.classList.add('bg-blue-500', 'text-white'); view3DBtnQuick.classList.remove('hover:bg-gray-200'); view2DBtnQuick.classList.remove('bg-blue-500', 'text-white'); view2DBtnQuick.classList.add('hover:bg-gray-200'); } } } // Check if currently in 3D view is3DViewActive() { return !document.getElementById('3dViewer').classList.contains('hidden'); } // Resize renderer resizeRenderer() { if (this.renderer && this.camera) { const container = document.getElementById('3dViewer'); if (container) { const width = container.offsetWidth; const height = container.offsetHeight; this.renderer.setSize(width, height); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); console.log('Renderer resized to:', width, 'x', height); } } } // Clean up resources and event listeners dispose() { if (this.renderer) { const domElement = this.renderer.domElement; // Remove event listeners domElement.removeEventListener('mousedown', this.onControlsMouseDown); domElement.removeEventListener('wheel', this.onControlsWheel); domElement.removeEventListener('contextmenu', this.onContextMenu); domElement.removeEventListener('touchstart', this.onControlsTouchStart); domElement.removeEventListener('touchend', this.onControlsTouchEnd); domElement.removeEventListener('touchmove', this.onControlsTouchMove); document.removeEventListener('mousemove', this.onControlsMouseMove); document.removeEventListener('mouseup', this.onControlsMouseUp); window.removeEventListener('keydown', this.onControlsKeyDown); this.renderer.dispose(); } this.stopAnimation(); this.isInitialized = false; console.log('ThreeJS resources disposed'); } // Helper function to darken a color darkenColor(color, percent) { // Convert hex to RGB let r = parseInt(color.slice(1, 3), 16); let g = parseInt(color.slice(3, 5), 16); let b = parseInt(color.slice(5, 7), 16); // Darken r = Math.max(0, r - percent); g = Math.max(0, g - percent); b = Math.max(0, b - percent); // Convert back to hex return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0'); } }