From 118b67e1d380eb3eaa8aaa0e9f2066bb497e11e2 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Sat, 21 Feb 2026 21:46:27 -0500 Subject: [PATCH] Optimize --- js/component/card/three_d.js | 138 ++++++++++++++++++++--- js/component/scene.js | 176 +++++++++++++++++++++++++++++- js/component/scene/interaction.js | 15 ++- js/component/scene/weather.js | 2 +- 4 files changed, 305 insertions(+), 26 deletions(-) diff --git a/js/component/card/three_d.js b/js/component/card/three_d.js index 44ada58..6550954 100644 --- a/js/component/card/three_d.js +++ b/js/component/card/three_d.js @@ -4,6 +4,25 @@ beestat.component.card.three_d = function() { const self = this; + if ( + beestat.component.card.three_d.active_instance_ !== undefined && + beestat.component.card.three_d.active_instance_ !== null && + beestat.component.card.three_d.active_instance_ !== this + ) { + beestat.component.card.three_d.active_instance_.force_dispose_stale_instance_(); + } + beestat.component.card.three_d.active_instance_ = this; + + this.disposed_ = false; + + this.handle_scene_settings_change_ = function() { + if (self.disposed_ === true || self.scene_ === undefined) { + return; + } + self.update_scene_(); + self.update_hud_(); + }; + // Things that update the scene that don't require a rerender. // TODO these probably need moved to the layer instead of here beestat.dispatcher.addEventListener( @@ -14,20 +33,27 @@ beestat.component.card.three_d = function() { 'setting.visualize.heat_map_static.temperature.max', 'setting.visualize.heat_map_static.occupancy.min', 'setting.visualize.heat_map_static.occupancy.max' - ], function() { - self.update_scene_(); - self.update_hud_(); - }); + ], + this.handle_scene_settings_change_ + ); - // Rerender the scene when the floor plan changes. - beestat.dispatcher.addEventListener('cache.floor_plan', function() { + this.handle_floor_plan_cache_change_ = function() { + if (self.disposed_ === true || self.scene_ === undefined) { + return; + } self.scene_.rerender(); self.apply_layer_visibility_(); self.update_scene_(); self.update_hud_(); - }); + }; - const change_function = beestat.debounce(function() { + // Rerender the scene when the floor plan changes. + beestat.dispatcher.addEventListener('cache.floor_plan', this.handle_floor_plan_cache_change_); + + this.handle_runtime_data_change_ = beestat.debounce(function() { + if (self.disposed_ === true || self.scene_ === undefined) { + return; + } self.state_.scene_camera_state = self.scene_.get_camera_state(); self.rerender(); }, 10); @@ -37,7 +63,7 @@ beestat.component.card.three_d = function() { 'cache.data.three_d__runtime_sensor', 'cache.data.three_d__runtime_thermostat' ], - change_function + this.handle_runtime_data_change_ ); this.scene_settings_menu_open_ = false; @@ -396,6 +422,18 @@ beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(paren beestat.setting('visualize.floor_plan_id'), this.get_data_() ); + + const initial_width = parent.getBoundingClientRect().width; + if (this.state_.width === undefined && initial_width > 0) { + this.state_.width = initial_width; + } + if (this.state_.width !== undefined && this.state_.width > 0) { + this.scene_.set_initial_width(this.state_.width); + } + if (this.state_.scene_camera_state !== undefined) { + this.scene_.set_initial_camera_state(this.state_.scene_camera_state); + } + this.scene_.set_scene_settings(this.scene_settings_values_, { 'rerender': false }); @@ -483,10 +521,6 @@ beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(paren this.scene_.set_width(this.state_.width); } - if (this.state_.scene_camera_state !== undefined) { - this.scene_.set_camera_state(this.state_.scene_camera_state); - } - beestat.dispatcher.removeEventListener('resize.three_d'); beestat.dispatcher.addEventListener('resize.three_d', function() { self.state_.width = parent.getBoundingClientRect().width; @@ -545,7 +579,7 @@ beestat.component.card.three_d.prototype.get_weather_settings_from_mode_ = funct switch (weather_mode) { case 'cloudy': return { - 'cloud_density': 1, + 'cloud_density': 0.5, 'rain_density': 0, 'snow_density': 0 }; @@ -2011,8 +2045,82 @@ beestat.component.card.three_d.prototype.get_most_recent_time_with_data_ = funct return null; }; -beestat.component.card.three_d.prototype.dispose = function() { +/** + * Remove global listeners registered by this card instance. + */ +beestat.component.card.three_d.prototype.remove_global_listeners_ = function() { + beestat.dispatcher.removeEventListener( + 'setting.visualize.data_type', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'setting.visualize.heat_map_values', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'setting.visualize.heat_map_static.temperature.min', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'setting.visualize.heat_map_static.temperature.max', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'setting.visualize.heat_map_static.occupancy.min', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'setting.visualize.heat_map_static.occupancy.max', + this.handle_scene_settings_change_ + ); + beestat.dispatcher.removeEventListener( + 'cache.floor_plan', + this.handle_floor_plan_cache_change_ + ); + beestat.dispatcher.removeEventListener( + 'cache.data.three_d__runtime_sensor', + this.handle_runtime_data_change_ + ); + beestat.dispatcher.removeEventListener( + 'cache.data.three_d__runtime_thermostat', + this.handle_runtime_data_change_ + ); + beestat.dispatcher.removeEventListener('resize.three_d'); +}; + +/** + * Force teardown for stale card instances that were not formally disposed. + */ +beestat.component.card.three_d.prototype.force_dispose_stale_instance_ = function() { + if (this.disposed_ === true) { + return; + } + + this.disposed_ = true; window.clearInterval(this.fps_interval_); delete this.fps_interval_; + this.remove_global_listeners_(); + + if (this.scene_ !== undefined) { + this.scene_.dispose(); + delete this.scene_; + } +}; + +beestat.component.card.three_d.prototype.dispose = function() { + this.disposed_ = true; + + window.clearInterval(this.fps_interval_); + delete this.fps_interval_; + this.remove_global_listeners_(); + + if (this.scene_ !== undefined) { + this.scene_.dispose(); + delete this.scene_; + } + if (beestat.component.card.three_d.active_instance_ === this) { + delete beestat.component.card.three_d.active_instance_; + } + beestat.component.card.prototype.dispose.apply(this, arguments); }; diff --git a/js/component/scene.js b/js/component/scene.js index bc76bb1..9a254fb 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -402,7 +402,11 @@ beestat.component.scene.prototype.with_random_seed_ = function(seed, callback) { */ beestat.component.scene.prototype.rerender = function() { this.reset_celestial_lights_for_rerender_(); - this.scene_.remove(this.main_group_); + if (this.main_group_ !== undefined) { + this.dispose_object3d_resources_(this.main_group_); + this.scene_.remove(this.main_group_); + } + this.reset_runtime_scene_references_for_rerender_(); this.with_seeded_random_(function() { this.add_main_group_(); this.add_floor_plan_(); @@ -415,6 +419,91 @@ beestat.component.scene.prototype.rerender = function() { } }; +/** + * Dispose geometries/materials/textures under an Object3D subtree. + * + * @param {THREE.Object3D} root + */ +beestat.component.scene.prototype.dispose_object3d_resources_ = function(root) { + if (root === undefined || root === null || typeof root.traverse !== 'function') { + return; + } + + const disposed_textures = new Set(); + const disposed_materials = new Set(); + const dispose_texture = function(texture) { + if ( + texture !== undefined && + texture !== null && + texture.isTexture === true && + disposed_textures.has(texture) !== true + ) { + disposed_textures.add(texture); + texture.dispose(); + } + }; + const dispose_material = function(material) { + if (material === undefined || material === null) { + return; + } + if (disposed_materials.has(material) === true) { + return; + } + disposed_materials.add(material); + + for (const key in material) { + if (Object.prototype.hasOwnProperty.call(material, key) !== true) { + continue; + } + const value = material[key]; + if (value !== undefined && value !== null && value.isTexture === true) { + dispose_texture(value); + } + } + material.dispose(); + }; + + root.traverse(function(object) { + if (object.geometry !== undefined && object.geometry !== null) { + object.geometry.dispose(); + } + if (object.material !== undefined && object.material !== null) { + if (Array.isArray(object.material) === true) { + object.material.forEach(function(material) { + dispose_material(material); + }); + } else { + dispose_material(object.material); + } + } + }); +}; + +/** + * Clear references that are recreated each rerender. + */ +beestat.component.scene.prototype.reset_runtime_scene_references_for_rerender_ = function() { + this.meshes_ = {}; + this.layers_ = {}; + this.light_sources_ = []; + this.tree_foliage_meshes_ = []; + this.tree_branch_groups_ = []; + + delete this.floor_plan_group_; + delete this.environment_group_; + delete this.environment_surface_group_; + delete this.weather_group_; + delete this.rain_particles_; + delete this.snow_particles_; + delete this.cloud_sprites_; + delete this.cloud_motion_; + delete this.weather_profile_target_; + delete this.weather_transition_start_profile_; + delete this.active_mesh_; + delete this.intersected_mesh_; + delete this.tree_ground_contact_material_; +}; + /** * Reset celestial objects so rerender can rebuild stars/lights from settings. */ @@ -567,13 +656,16 @@ beestat.component.scene.prototype.decorate_ = function(parent) { }; this.room_interaction_enabled_ = true; - this.width_ = this.state_.scene_width || 800; + this.width_ = this.initial_width_ || this.state_.scene_width || 800; this.height_ = 500; this.add_scene_(parent); this.add_renderer_(parent); this.add_camera_(); this.add_controls_(parent); + if (this.initial_camera_state_ !== undefined) { + this.set_camera_state(this.initial_camera_state_); + } this.add_raycaster_(parent); this.add_skybox_(parent); this.add_static_lights_(); @@ -606,6 +698,34 @@ beestat.component.scene.prototype.decorate_ = function(parent) { animate(); }; +/** + * Set width to use when scene first decorates/renders. + * + * @param {number} width + * + * @return {beestat.component.scene} + */ +beestat.component.scene.prototype.set_initial_width = function(width) { + if (Number.isFinite(width) === true && width > 0) { + this.initial_width_ = width; + } + return this; +}; + +/** + * Set camera state to apply before first rendered frame. + * + * @param {object} camera_state + * + * @return {beestat.component.scene} + */ +beestat.component.scene.prototype.set_initial_camera_state = function(camera_state) { + if (camera_state !== undefined) { + this.initial_camera_state_ = camera_state; + } + return this; +}; + /** * Add the scene. Everything gets added to the scene. * @@ -1246,7 +1366,13 @@ beestat.component.scene.prototype.get_fps = function() { * @return {object} */ beestat.component.scene.prototype.get_camera_state = function() { - return this.camera_.matrix.toArray(); + const state = { + 'matrix': this.camera_.matrix.toArray() + }; + if (this.controls_ !== undefined && this.controls_.target !== undefined) { + state.target = this.controls_.target.toArray(); + } + return state; }; /** @@ -1255,12 +1381,40 @@ beestat.component.scene.prototype.get_camera_state = function() { * @param {object} camera_state */ beestat.component.scene.prototype.set_camera_state = function(camera_state) { - this.camera_.matrix.fromArray(camera_state); + let matrix_state = camera_state; + let target_state; + if ( + camera_state !== undefined && + camera_state !== null && + Array.isArray(camera_state) !== true + ) { + matrix_state = camera_state.matrix; + target_state = camera_state.target; + } + + if (Array.isArray(matrix_state) !== true) { + return; + } + + this.camera_.matrix.fromArray(matrix_state); this.camera_.matrix.decompose( this.camera_.position, this.camera_.quaternion, this.camera_.scale ); + + if ( + Array.isArray(target_state) === true && + target_state.length >= 3 && + this.controls_ !== undefined + ) { + this.controls_.target.set( + Number(target_state[0]) || 0, + Number(target_state[1]) || 0, + Number(target_state[2]) || 0 + ); + this.controls_.update(); + } }; /** @@ -1438,6 +1592,20 @@ beestat.component.scene.prototype.dispose = function() { if (this.light_source_glow_geometry_ !== undefined) { this.light_source_glow_geometry_.dispose(); } + if (this.raycaster_document_mousemove_handler_ !== undefined) { + document.removeEventListener('mousemove', this.raycaster_document_mousemove_handler_); + delete this.raycaster_document_mousemove_handler_; + } + if ( + this.raycaster_dom_element_ !== undefined && + this.raycaster_dom_mousedown_handler_ !== undefined + ) { + this.raycaster_dom_element_.removeEventListener('mousedown', this.raycaster_dom_mousedown_handler_); + this.raycaster_dom_element_.removeEventListener('touchstart', this.raycaster_dom_touchstart_handler_); + delete this.raycaster_dom_mousedown_handler_; + delete this.raycaster_dom_touchstart_handler_; + delete this.raycaster_dom_element_; + } // Clean up THREE.js scene resources if (this.scene_ !== undefined) { diff --git a/js/component/scene/interaction.js b/js/component/scene/interaction.js index 5070f1c..ab32e66 100644 --- a/js/component/scene/interaction.js +++ b/js/component/scene/interaction.js @@ -67,15 +67,18 @@ beestat.component.scene.prototype.add_raycaster_ = function() { */ this.raycaster_pointer_ = new THREE.Vector2(10000, 10000); - // TODO remove event listener on dispose - document.addEventListener('mousemove', function(e) { + this.raycaster_document_mousemove_handler_ = function(e) { const rect = self.renderer_.domElement.getBoundingClientRect(); self.raycaster_pointer_.x = ( ( e.clientX - rect.left ) / ( rect.right - rect.left ) ) * 2 - 1; self.raycaster_pointer_.y = - ( ( e.clientY - rect.top ) / ( rect.bottom - rect.top) ) * 2 + 1; - }); - // TODO remove event listener on dispose - this.renderer_.domElement.addEventListener('mousedown', this.mousedown_handler_.bind(this)); - this.renderer_.domElement.addEventListener('touchstart', this.mousedown_handler_.bind(this)); + }; + document.addEventListener('mousemove', this.raycaster_document_mousemove_handler_); + + this.raycaster_dom_element_ = this.renderer_.domElement; + this.raycaster_dom_mousedown_handler_ = this.mousedown_handler_.bind(this); + this.raycaster_dom_touchstart_handler_ = this.mousedown_handler_.bind(this); + this.raycaster_dom_element_.addEventListener('mousedown', this.raycaster_dom_mousedown_handler_); + this.raycaster_dom_element_.addEventListener('touchstart', this.raycaster_dom_touchstart_handler_); }; diff --git a/js/component/scene/weather.js b/js/component/scene/weather.js index 35bd7b7..1a1d8b3 100644 --- a/js/component/scene/weather.js +++ b/js/component/scene/weather.js @@ -36,7 +36,7 @@ beestat.component.scene.prototype.set_weather = function(weather) { break; case 'cloudy': weather_settings = { - 'cloud_density': 1, + 'cloud_density': 0.5, 'rain_density': 0, 'snow_density': 0 };