// Ideas
// Surfaces (Sidewalk, Mulch, etc)
// Trees
// When dragging across a DST boundary change the time so the sun doesn't jump


/**
 * Home Scene
 *
 * @param {number} floor_plan_id The floor plan to render.
 * @param {object} data Sensor data.
 */
beestat.component.scene = function(floor_plan_id, data) {
  this.floor_plan_id_ = floor_plan_id;
  this.data_ = data;

  this.label_material_memo_ = [];

  beestat.component.apply(this, arguments);
};
beestat.extend(beestat.component.scene, beestat.component);

/**
 * Render layer index for standard visible meshes.
 *
 * @type {number}
 */
beestat.component.scene.layer_visible = 0;

/**
 * Render layer index for hidden meshes.
 *
 * @type {number}
 */
beestat.component.scene.layer_hidden = 1;

/**
 * Render layer index for room outlines.
 *
 * @type {number}
 */
beestat.component.scene.layer_outline = 2;

/**
 * Roof rise-over-run pitch ratio (0.5 = 6:12 pitch).
 *
 * @type {number}
 */
beestat.component.scene.roof_pitch = 0.5;

/**
 * Roof overhang beyond wall exterior in model units (inches).
 *
 * @type {number}
 */
beestat.component.scene.roof_overhang = 12;

/**
 * Exterior wall thickness in model units (inches).
 *
 * @type {number}
 */
beestat.component.scene.wall_thickness = 4;

/**
 * Extra padding added around the floor plan for environment meshes.
 *
 * @type {number}
 */
beestat.component.scene.environment_padding = 400;

/**
 * Maximum rain particle count at full rain intensity.
 *
 * @type {number}
 */
beestat.component.scene.weather_rain_max_count = 1100;

/**
 * Maximum snow particle count at full snow intensity.
 *
 * @type {number}
 */
beestat.component.scene.weather_snow_max_count = 1500;

/**
 * Maximum cloud sprite count at full cloud intensity.
 *
 * @type {number}
 */
beestat.component.scene.weather_cloud_max_count = 140;

/**
 * Maximum low-altitude fog volume sprite count at full fog intensity.
 *
 * @type {number}
 */
beestat.component.scene.weather_fog_max_count = 18;

/**
 * Time in seconds for weather effects to fully transition to a new mode.
 *
 * @type {number}
 */
beestat.component.scene.weather_transition_seconds = 2;

/**
 * Spacing between Christmas roofline bulbs (model units).
 *
 * @type {number}
 */
beestat.component.scene.christmas_light_spacing = 28;

/**
 * Radius of Christmas roofline bulb meshes (model units).
 *
 * @type {number}
 */
beestat.component.scene.christmas_light_size = 4.2;

/**
 * Repeating color palette for Christmas roofline bulbs.
 *
 * @type {Array<number>}
 */
beestat.component.scene.christmas_light_colors = [
  0xff2b2b,
  0x34d44e,
  0x3f7bff,
  0xffd93a,
  0xff48bf,
  0x42e3ff
];

/**
 * Default room floor slab thickness in model units (inches).
 *
 * @type {number}
 */
beestat.component.scene.room_floor_thickness = 6;

/**
 * Vertical lift (inches) applied to surfaces so they sit slightly above their
 * base plane and avoid z-fighting.
 *
 * @type {number}
 */
beestat.component.scene.surface_z_lift = 0.75;

/**
 * Round/oval primary branch density in branches per height unit.
 *
 * @type {number}
 */
beestat.component.scene.round_tree_branches_per_height = 0.15;

/**
 * Fixed recursive branch depth for round/oval tree generation.
 *
 * @type {number}
 */
beestat.component.scene.fixed_tree_branch_depth = 1;

/**
 * Seasonal foliage colors for round/oval trees.
 *
 * @type {{summer: number, fall_early: number, fall_late: number, winter: number}}
 */
beestat.component.scene.tree_foliage_colors = {
  'summer': 0x4f9f2f,
  'fall_early': 0x9a7b2f,
  'fall_late': 0x8b4f1f,
  'winter': 0x6f5f3a
};

/**
 * Inset used when building wall geometry to avoid z-fighting seams.
 *
 * @type {number}
 */
beestat.component.scene.room_wall_inset = 1.5;

/**
 * Default appearance values for floor plans.
 *
 * @type {{rotation: number, roof_color: string, roof_style: string, siding_color: string, ground_color: string, weather: string}}
 */
beestat.component.scene.default_appearance = {
  'rotation': 0,
  'roof_color': '#3a3a3a',
  'roof_style': 'hip',
  'siding_color': '#889aaa',
  'ground_color': '#4a7c3f',
  'weather': 'sunny'
};
/**
 * Snow cover tint used to blend roof/ground surfaces during snowfall.
 *
 * @type {string}
 */
beestat.component.scene.snow_surface_color = '#f0f0f0';

/**
 * Ambient light intensity for constant scene fill.
 *
 * @type {number}
 */
beestat.component.scene.ambient_light_intensity = 0.25;

/**
 * Directional fill light intensity for static key/fill/rim lights.
 *
 * @type {number}
 */
beestat.component.scene.directional_light_intensity = 0.1;

/**
 * Peak directional sunlight intensity.
 *
 * @type {number}
 */
beestat.component.scene.sun_light_intensity = 0.6;

/**
 * Peak directional moonlight intensity before phase scaling.
 *
 * @type {number}
 */
beestat.component.scene.moon_light_intensity = 0.13125;

/**
 * Minimum star sprite size.
 *
 * @type {number}
 */
beestat.component.scene.star_min_size = 8;

/**
 * Maximum star sprite size.
 *
 * @type {number}
 */
beestat.component.scene.star_max_size = 34;

/**
 * Sidereal day duration in seconds used for starfield drift.
 *
 * @type {number}
 */
beestat.component.scene.sidereal_day_seconds = 86164.0905;

/**
 * Visual multiplier for subtle star drift (1 = full sidereal motion).
 *
 * @type {number}
 */
beestat.component.scene.star_drift_visual_factor = 0.12;

/**
 * Runtime scene settings used by environment rendering.
 *
 * @type {{
 *   cloud_density: number,
 *   cloud_darkness: number,
 *   fog_density: number,
 *   rain_density: number,
 *   snow_density: number,
 *   lightning_frequency: number,
 *   wind_speed: number,
 *   wind_direction: number,
 *   fog_color: string,
 *   tree_wobble: boolean,
 *   tree_enabled: boolean,
 *   star_density: number,
 *   light_user_enabled: boolean,
 *   light_user_cast_shadows: boolean,
 *   random_seed: number
 * }}
 */
beestat.component.scene.default_settings = {
  'cloud_density': 1,
  'cloud_darkness': 0,
  'fog_density': 0,
  'rain_density': 1,
  'snow_density': 1,
  'lightning_frequency': 0,
  'wind_speed': 0.4,
  'wind_direction': 0,
  'fog_color': '#d6dde8',
  'tree_wobble': true,
  'tree_enabled': true,
  'star_density': 1,
  'light_user_enabled': true,
  'light_user_cast_shadows': true,
  'random_seed': 1
};

/**
 * Normalization area used to convert weather density to particle counts.
 *
 * @type {number}
 */
beestat.component.scene.weather_density_unit_area = 2500000;

/**
 * Build deterministic PRNG from a numeric seed.
 *
 * @param {number} seed
 *
 * @return {function(): number}
 */
beestat.component.scene.prototype.create_seeded_random_generator_ = function(seed) {
  let state = (seed >>> 0);
  if (state === 0) {
    state = 0x6d2b79f5;
  }

  return function() {
    state += 0x6d2b79f5;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
};

/**
 * Reset random generator for this scene from current seed setting.
 */
beestat.component.scene.prototype.reset_random_generator_ = function() {
  const raw_seed = Number(this.get_scene_setting_('random_seed'));
  const normalized_seed = Number.isFinite(raw_seed)
    ? Math.max(1, Math.round(raw_seed))
    : 1;
  this.random_seed_ = normalized_seed;
  this.random_generator_ = this.create_seeded_random_generator_(normalized_seed);
};

/**
 * Get deterministic random number in [0, 1).
 *
 * @return {number}
 */
beestat.component.scene.prototype.random_ = function() {
  if (typeof this.random_generator_ !== 'function') {
    this.reset_random_generator_();
  }
  return this.random_generator_();
};

/**
 * Run scene-generation logic using deterministic Math.random.
 *
 * @param {function()} callback
 */
beestat.component.scene.prototype.with_seeded_random_ = function(callback) {
  const original_random = Math.random;
  this.reset_random_generator_();
  Math.random = this.random_.bind(this);
  try {
    callback();
  } finally {
    Math.random = original_random;
  }
};

/**
 * Build deterministic unsigned seed from string parts.
 *
 * @param {Array<*>} parts
 *
 * @return {number}
 */
beestat.component.scene.prototype.get_seed_from_parts_ = function(parts) {
  const input = parts.map((part) => String(part)).join('|');
  let hash = 2166136261;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  hash >>>= 0;
  return hash === 0 ? 1 : hash;
};

/**
 * Run callback with a temporary deterministic random source.
 *
 * @param {number} seed
 * @param {function()} callback
 *
 * @return {*}
 */
beestat.component.scene.prototype.with_random_seed_ = function(seed, callback) {
  const normalized_seed = Math.max(1, Number(seed || 1) >>> 0);
  const original_random = Math.random;
  const local_random = this.create_seeded_random_generator_(normalized_seed);
  Math.random = local_random;
  try {
    return callback();
  } finally {
    Math.random = original_random;
  }
};

/**
 * Rerender the scene by removing the primary group, then re-adding it and the
 * floor plan. This avoids having to reconstruct everything and then also
 * having to manually save camera info etc.
 */
beestat.component.scene.prototype.rerender = function() {
  this.reset_celestial_lights_for_rerender_();
  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_();
  }.bind(this));

  // Ensure everything gets updated with the latest info.
  if (this.rendered_ === true) {
    this.update_();
  }
};

/**
 * 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_ = [];
  this.tree_wind_meshes_ = [];

  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.fog_sprites_;
  delete this.fog_motion_;
  delete this.fog_bounds_;
  delete this.current_cloud_darkness_;
  delete this.current_fog_count_;
  delete this.current_fog_density_;
  delete this.weather_profile_target_;
  delete this.weather_transition_start_profile_;
  delete this.lightning_flash_light_;
  delete this.lightning_next_strike_ms_;
  delete this.lightning_flash_remaining_s_;
  delete this.lightning_flash_duration_s_;
  delete this.lightning_flash_peak_intensity_;
  delete this.lightning_next_pulse_ms_;
  delete this.lightning_cluster_pulses_remaining_;
  delete this.lightning_cluster_total_pulses_;
  delete this.lightning_cluster_anchor_;
  delete this.lightning_cluster_frequency_;
  delete this.lightning_cluster_peak_scale_;
  delete this.christmas_lights_group_;
  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.
 */
beestat.component.scene.prototype.reset_celestial_lights_for_rerender_ = function() {
  if (this.sun_light_ !== undefined && this.sun_light_.target !== undefined && this.sun_light_.target.parent !== null) {
    this.sun_light_.target.parent.remove(this.sun_light_.target);
  }
  if (this.moon_light_ !== undefined && this.moon_light_.target !== undefined && this.moon_light_.target.parent !== null) {
    this.moon_light_.target.parent.remove(this.moon_light_.target);
  }
  if (this.celestial_light_group_ !== undefined && this.celestial_light_group_.parent !== null) {
    this.celestial_light_group_.parent.remove(this.celestial_light_group_);
  }

  delete this.celestial_light_group_;
  delete this.sun_light_;
  delete this.moon_light_;
  delete this.sun_path_line_;
  delete this.sun_visual_group_;
  delete this.sun_core_mesh_;
  delete this.sun_glow_sprite_;
  delete this.moon_visual_group_;
  delete this.moon_sprite_;
  delete this.star_group_;
  delete this.stars_;
};

/**
 * Get an appearance value with fallback to default if not set.
 *
 * @param {string} key The appearance key to retrieve.
 *
 * @return {*} The appearance value.
 */
beestat.component.scene.prototype.get_appearance_value_ = function(key) {
  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
  if (floor_plan.data.appearance && floor_plan.data.appearance[key] !== undefined) {
    return floor_plan.data.appearance[key];
  }
  return beestat.component.scene.default_appearance[key];
};

/**
 * Get a scene setting value with default fallback.
 *
 * @param {string} key
 *
 * @return {*}
 */
beestat.component.scene.prototype.get_scene_setting_ = function(key) {
  if (this.scene_settings_ !== undefined && this.scene_settings_[key] !== undefined) {
    return this.scene_settings_[key];
  }
  return beestat.component.scene.default_settings[key];
};

/**
 * Update scene settings.
 *
 * @param {object} scene_settings
 * @param {object=} options
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_scene_settings = function(scene_settings, options) {
  if (scene_settings === undefined || scene_settings === null) {
    return this;
  }

  const previous_lightning_frequency = Number(this.get_scene_setting_('lightning_frequency') || 0);
  const previous_user_light_cast_shadows = this.get_scene_setting_('light_user_cast_shadows') === true;

  if (this.scene_settings_ === undefined) {
    this.scene_settings_ = {};
  }
  Object.assign(this.scene_settings_, scene_settings);
  const current_lightning_frequency = Number(this.get_scene_setting_('lightning_frequency') || 0);
  const current_user_light_cast_shadows = this.get_scene_setting_('light_user_cast_shadows') === true;
  const lightning_frequency_changed = Math.abs(
    current_lightning_frequency - previous_lightning_frequency
  ) > 0.0001;
  const user_light_cast_shadows_changed =
    current_user_light_cast_shadows !== previous_user_light_cast_shadows;

  const rerender = options !== undefined && options.rerender === true;
  if (this.rendered_ === true) {
    if (rerender === true) {
      this.rerender();
    } else {
      this.update_weather_targets_();
      this.update_tree_foliage_season_();
      this.update_weather_();
      if (
        lightning_frequency_changed === true &&
        typeof this.sync_lightning_schedule_for_frequency_change_ === 'function'
      ) {
        this.sync_lightning_schedule_for_frequency_change_(
          previous_lightning_frequency,
          current_lightning_frequency
        );
      }
      if (
        user_light_cast_shadows_changed === true &&
        typeof this.update_user_light_shadow_settings_ === 'function'
      ) {
        this.update_user_light_shadow_settings_();
      }
    }
  }

  return this;
};

/**
 * Set the width of this component.
 *
 * @param {number} width
 */
beestat.component.scene.prototype.set_width = function(width) {
  this.width_ = width;

  this.camera_.aspect = this.width_ / this.height_;
  this.camera_.updateProjectionMatrix();

  this.renderer_.setSize(this.width_, this.height_);
};

/**
 * Decorate
 *
 * @param {rocket.Elements} parent
 */
beestat.component.scene.prototype.decorate_ = function(parent) {
  const self = this;

  // Prevent re-initialization if already decorated
  if (this.scene_ !== undefined) {
    return;
  }

  // Dark background to help reduce apparant flicker when resizing
  parent.style('background', '#202a30');

  if (this.scene_settings_ === undefined) {
    this.scene_settings_ = {};
  }

  this.room_interaction_enabled_ = true;

  this.width_ = this.initial_width_ || this.state_.scene_width || 800;
  this.height_ = 500;

  this.add_scene_();
  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_();

  this.with_seeded_random_(function() {
    this.add_main_group_();
    this.add_floor_plan_();
  }.bind(this));

  this.fps_ = 0;
  this.fps_frame_count_ = 0;
  this.fps_last_sample_ms_ = window.performance.now();

  const animate = function() {
    self.animation_frame_ = window.requestAnimationFrame(animate);
    self.fps_frame_count_++;
    const now_ms = window.performance.now();
    const elapsed_ms = now_ms - self.fps_last_sample_ms_;
    if (elapsed_ms >= 500) {
      self.fps_ = Math.round((self.fps_frame_count_ * 1000) / elapsed_ms);
      self.fps_frame_count_ = 0;
      self.fps_last_sample_ms_ = now_ms;
    }
    self.controls_.update();
    self.update_raycaster_();
    self.update_celestial_light_intensities_();
    self.update_tree_wind_();
    self.update_weather_();
    self.renderer_.render(self.scene_, self.camera_);
  };
  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.
 */
beestat.component.scene.prototype.add_scene_ = function() {
  this.scene_ = new THREE.Scene();
};

/**
 * Add the renderer.
 *
 * @param {rocket.Elements} parent
 */
beestat.component.scene.prototype.add_renderer_ = function(parent) {
  this.renderer_ = new THREE.WebGLRenderer({
    'antialias': true
  });

  this.renderer_.setPixelRatio(window.devicePixelRatio);
  this.renderer_.setSize(this.width_, this.height_);

  // Enable shadow maps
  this.renderer_.shadowMap.enabled = true;
  this.renderer_.shadowMap.type = THREE.PCFSoftShadowMap;

  parent[0].appendChild(this.renderer_.domElement);
};

/**
 * Add a camera and point it at the scene.
 */
beestat.component.scene.prototype.add_camera_ = function() {
  const field_of_view = 75;
  const aspect_ratio = window.innerWidth / window.innerHeight;
  const near_plane = 1;
  const far_plane = 100000;

  this.camera_ = new THREE.PerspectiveCamera(
    field_of_view,
    aspect_ratio,
    near_plane,
    far_plane
  );

  this.camera_.layers.enable(beestat.component.scene.layer_visible);
  this.camera_.layers.enable(beestat.component.scene.layer_outline);

  // Base camera position
  const base_x = 500;
  const base_y = 500;
  const base_z = 500;

  this.camera_.position.x = base_x;
  this.camera_.position.y = base_y;
  this.camera_.position.z = base_z;
};

/**
 * Add camera controls.
 *
 * @param {rocket.Elements} parent
 */
beestat.component.scene.prototype.add_controls_ = function(parent) {
  this.controls_ = new THREE.OrbitControls(this.camera_, parent[0]);
  this.controls_.enableDamping = true;
  this.controls_.enablePan = true;
  this.controls_.maxDistance = 1500;
  this.controls_.minDistance = 120;
  this.controls_.maxPolarAngle = Math.PI / 2.1;
};

/**
 * Update the scene based on the currently set date.
 */
beestat.component.scene.prototype.update_ = function() {
  const self = this;

  // Resolve the current floor-plan snapshot and time key for this frame.
  const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
  const time = this.date_.format('HH:mm');

  // Update each room mesh: active outline, heat-map color, and label sprites.
  floor_plan.data.groups.forEach(function(group) {
    group.rooms.forEach(function(room) {
      const value_sprite = self.meshes_[room.room_id].userData.sprites.value;
      const icon_sprite = self.meshes_[room.room_id].userData.sprites.icon;

      // Room outline
      if (self.meshes_[room.room_id] === self.active_mesh_) {
        self.meshes_[room.room_id].userData.outline.visible = true;
      } else {
        self.meshes_[room.room_id].userData.outline.visible = false;
      }

      let color;
      if (
        room.sensor_id !== undefined &&
        self.data_.series[self.data_type_][room.sensor_id] !== undefined &&
        self.data_.series[self.data_type_][room.sensor_id][time] !== undefined
      ) {
        // Resolve sensor value and map it into the configured gradient range.
        const value = self.data_.series[self.data_type_][room.sensor_id][time];

        /**
         * Set the percentage between the min and max. Special case for if min
         * and max are equal to avoid math issues.
         */
        let percentage;
        if (
          self.heat_map_min_ === self.heat_map_max_ &&
          value === self.heat_map_min_
        ) {
          percentage = 0.5;
        } else {
          percentage = Math.min(
            1,
            Math.max(
              0,
              (value - self.heat_map_min_) / (self.heat_map_max_ - self.heat_map_min_)
            )
          );
        }

        color = beestat.style.rgb_to_hex(
          self.gradient_[Math.floor((self.gradient_.length - 1) * percentage)]
        );

        // Resolve the HVAC activity icon from thermostat runtime series at this minute.
        // TODO this technically doesn't handle if both heating and cooling is active in a range
        const sensor = beestat.cache.sensor[room.sensor_id];
        let icon;
        let icon_opacity;
        if (sensor !== undefined) {
          if (
            self.data_.series.compressor_cool_1[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.compressor_cool_1[sensor.thermostat_id][time] > 0
          ) {
            icon = 'snowflake';
            icon_opacity = self.data_.series.compressor_cool_1[sensor.thermostat_id][time];
          } else if (
            self.data_.series.compressor_cool_2[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.compressor_cool_2[sensor.thermostat_id][time] > 0
          ) {
            icon = 'snowflake';
            icon_opacity = self.data_.series.compressor_cool_2[sensor.thermostat_id][time];
          } else if (
            self.data_.series.compressor_heat_1[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.compressor_heat_1[sensor.thermostat_id][time] > 0
          ) {
            icon = 'fire';
            icon_opacity = self.data_.series.compressor_heat_1[sensor.thermostat_id][time];
          } else if (
            self.data_.series.compressor_heat_2[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.compressor_heat_2[sensor.thermostat_id][time] > 0
          ) {
            icon = 'fire';
            icon_opacity = self.data_.series.compressor_heat_2[sensor.thermostat_id][time];
          } else if (
            self.data_.series.auxiliary_heat_1[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.auxiliary_heat_1[sensor.thermostat_id][time] > 0
          ) {
            icon = 'fire';
            icon_opacity = self.data_.series.auxiliary_heat_1[sensor.thermostat_id][time];
          } else if (
            self.data_.series.auxiliary_heat_2[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.auxiliary_heat_2[sensor.thermostat_id][time] > 0
          ) {
            icon = 'fire';
            icon_opacity = self.data_.series.auxiliary_heat_2[sensor.thermostat_id][time];
          } else if (
            self.data_.series.fan[sensor.thermostat_id][time] !== undefined &&
            self.data_.series.fan[sensor.thermostat_id][time] > 0
          ) {
            icon = 'fan';
            icon_opacity = self.data_.series.fan[sensor.thermostat_id][time];
          }
          icon_opacity = Math.round(icon_opacity * 10) / 10;
        }

        // Update room labels based on selected data type and visibility rules.
        // Labels
        if (
          self.labels_ === true ||
          self.meshes_[room.room_id] === self.active_mesh_
        ) {
          switch (self.data_type_) {
          case 'temperature':
            value_sprite.material = self.get_label_material_({
              'type': 'value',
              'value': beestat.temperature({
                'temperature': value,
                'type': 'string',
                'units': true
              })
            });
            icon_sprite.material = self.get_label_material_({
              'type': 'icon',
              'icon': icon,
              'color': 'rgba(255, 255, 255, ' + icon_opacity + ')'
            });
            break;
          case 'occupancy':
            value_sprite.material = self.get_label_material_({
              'type': 'value',
              'value': Math.round(value) + '%'
            });
            icon_sprite.material = self.get_blank_label_material_();
            break;
          }
        } else {
          value_sprite.material = self.get_blank_label_material_();
          icon_sprite.material = self.get_blank_label_material_();
        }
      } else {
        color = beestat.style.color.gray.dark;
        value_sprite.material = self.get_blank_label_material_();
        icon_sprite.material = self.get_blank_label_material_();
      }

      // Apply the resolved room fill color.
      self.meshes_[room.room_id].material.color.setHex(color.replace('#', '0x'));
    });
  });

  // Sync celestial lights with the current date and configured location.
  if (this.date_ !== undefined && this.latitude_ !== undefined && this.longitude_ !== undefined) {
    this.update_celestial_lights_(this.date_, this.latitude_, this.longitude_);
  }

  // Sync foliage tint/leaf state with the current season.
  this.update_tree_foliage_season_();
  if (typeof this.update_christmas_lights_visibility_ === 'function') {
    this.update_christmas_lights_visibility_();
  }
};

/**
 * Set the current date.
 *
 * @param {moment} date
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_date = function(date) {
  this.date_ = date;

  if (this.rendered_ === true) {
    this.update_();
  }

  return this;
};

/**
 * Set the location for celestial light calculations.
 *
 * @param {number} latitude
 * @param {number} longitude
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_location = function(latitude, longitude) {
  this.latitude_ = latitude;
  this.longitude_ = longitude;

  if (this.rendered_ === true) {
    this.update_();
  }

  return this;
};

/**
 * Set the type of data this scene is visualizing.
 *
 * @param {string} data_type temperature|occupancy
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_data_type = function(data_type) {
  this.data_type_ = data_type;

  if (this.rendered_ === true) {
    this.update_();
  }

  return this;
};

/**
 * Set the min value of the heat map.
 *
 * @param {string} heat_map_min
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_heat_map_min = function(heat_map_min) {
  this.heat_map_min_ = heat_map_min;

  if (this.rendered_ === true) {
    this.update_();
  }

  return this;
};

/**
 * Set the max value of the heat map.
 *
 * @param {string} heat_map_max
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_heat_map_max = function(heat_map_max) {
  this.heat_map_max_ = heat_map_max;

  if (this.rendered_ === true) {
    this.update_();
  }

  return this;
};

/**
 * Set the visibility of a layer.
 *
 * @param {string} layer_name
 * @param {boolean} visible
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_layer_visible = function(layer_name, visible) {
  this.layers_[layer_name].traverse(function(child) {
    child.layers.set(
      visible === true
        ? beestat.component.scene.layer_visible
        : beestat.component.scene.layer_hidden
    );
  });

  // When toggling environment, also toggle celestial lights
  if (layer_name === 'environment' && this.layers_['celestial'] !== undefined) {
    this.layers_['celestial'].traverse(function(child) {
      child.layers.set(
        visible === true
          ? beestat.component.scene.layer_visible
          : beestat.component.scene.layer_hidden
      );
    });
  }

  return this;
};

/**
 * Set whether or not auto-rotate is enabled.
 *
 * @param {boolean} auto_rotate
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_auto_rotate = function(auto_rotate) {
  this.controls_.autoRotate = auto_rotate;

  return this;
};

/**
 * Set whether or not labels are enabled.
 *
 * @param {boolean} labels
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_labels = function(labels) {
  this.labels_ = labels;

  this.update_();

  return this;
};

/**
 * Set whether room floor meshes can be hovered/selected.
 *
 * @param {boolean} enabled
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_room_interaction_enabled = function(enabled) {
  this.room_interaction_enabled_ = enabled !== false;

  if (this.room_interaction_enabled_ !== true) {
    if (this.intersected_mesh_ !== undefined) {
      if (
        this.intersected_mesh_.material !== undefined &&
        this.intersected_mesh_.material.emissive !== undefined
      ) {
        this.intersected_mesh_.material.emissive.setHex(0x000000);
      }
      delete this.intersected_mesh_;
    }
    if (this.active_mesh_ !== undefined) {
      delete this.active_mesh_;
    }
    document.body.style.cursor = '';
    if (this.rendered_ === true) {
      this.update_();
    }
  }

  return this;
};

/**
 * Set the gradient.
 *
 * @param {boolean} gradient
 *
 * @return {beestat.component.scene}
 */
beestat.component.scene.prototype.set_gradient = function(gradient) {
  this.gradient_ = gradient;

  return this;
};

/**
 * Get the current sampled frame rate.
 *
 * @return {number}
 */
beestat.component.scene.prototype.get_fps = function() {
  return Number(this.fps_ || 0);
};

/**
 * Get the state of the camera.
 *
 * @return {object}
 */
beestat.component.scene.prototype.get_camera_state = function() {
  const state = {
    'matrix': this.camera_.matrix.toArray()
  };
  if (this.controls_ !== undefined && this.controls_.target !== undefined) {
    state.target = this.controls_.target.toArray();
  }
  return state;
};

/**
 * Restore the state of the camera.
 *
 * @param {object} camera_state
 */
beestat.component.scene.prototype.set_camera_state = function(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();
  }
};

/**
 * Get a material representing a label. Memoizes the result so the material
 * can be re-used.
 *
 * @param {object} args
 *
 * @return {THREE.Material}
 */
beestat.component.scene.prototype.get_label_material_ = function(args) {
  let memo_key;

  switch (args.type) {
  case 'value':
    memo_key = [
      args.type,
      args.value
    ].join('|');
    break;
  case 'icon':
    memo_key = [
      args.type,
      args.icon,
      args.color
    ].join('|');
    break;
  }

  if (this.label_material_memo_[memo_key] === undefined) {
    /**
     * Increasing the size of the canvas increases the resolution of the texture
     * and thus makes it less blurry.
     */
    const scale = 2;
    const canvas = document.createElement('canvas');
    canvas.width = 100 * scale;
    canvas.height = 55 * scale;

    const context = canvas.getContext('2d');

    const font_size = canvas.height / 2;
    switch (args.type) {
    case 'value': {
      context.font = '700 ' + font_size + 'px Montserrat';
      context.fillStyle = '#fff';
      context.textAlign = 'center';
      context.fillText(
        args.value,
        canvas.width / 2,
        canvas.height
      );
      break;
    }
    case 'icon': {
      const icon_scale = 2.5;
      const icon_size = 24 * icon_scale;

      context.fillStyle = args.color;
      context.translate((canvas.width / 2) - (icon_size / 2), 0);
      context.fill(this.get_icon_path_(args.icon, icon_scale));
      break;
    }
    }

    const texture = new THREE.Texture(canvas);
    texture.needsUpdate = true;
    texture.anisotropy = this.renderer_.capabilities.getMaxAnisotropy();

    const material = new THREE.SpriteMaterial({
      'map': texture,
      'sizeAttenuation': false
    });

    this.label_material_memo_[memo_key] = material;
  }

  return this.label_material_memo_[memo_key];
};

/**
 * Get a blank label.
 *
 * @return {THREE.Material}
 */
beestat.component.scene.prototype.get_blank_label_material_ = function() {
  return this.get_label_material_({
    'type': 'value',
    'value': ''
  });
};

/**
 * Get an icon path for placing on a canvas texture.
 *
 * @param {string} icon
 * @param {int} scale
 *
 * @return {Path2D}
 */
beestat.component.scene.prototype.get_icon_path_ = function(icon, scale = 4) {
  const icons = {
    'snowflake': 'M20.79,13.95L18.46,14.57L16.46,13.44V10.56L18.46,9.43L20.79,10.05L21.31,8.12L19.54,7.65L20,5.88L18.07,5.36L17.45,7.69L15.45,8.82L13,7.38V5.12L14.71,3.41L13.29,2L12,3.29L10.71,2L9.29,3.41L11,5.12V7.38L8.5,8.82L6.5,7.69L5.92,5.36L4,5.88L4.47,7.65L2.7,8.12L3.22,10.05L5.55,9.43L7.55,10.56V13.45L5.55,14.58L3.22,13.96L2.7,15.89L4.47,16.36L4,18.12L5.93,18.64L6.55,16.31L8.55,15.18L11,16.62V18.88L9.29,20.59L10.71,22L12,20.71L13.29,22L14.7,20.59L13,18.88V16.62L15.5,15.17L17.5,16.3L18.12,18.63L20,18.12L19.53,16.35L21.3,15.88L20.79,13.95M9.5,10.56L12,9.11L14.5,10.56V13.44L12,14.89L9.5,13.44V10.56Z',
    'fire': 'M17.66 11.2C17.43 10.9 17.15 10.64 16.89 10.38C16.22 9.78 15.46 9.35 14.82 8.72C13.33 7.26 13 4.85 13.95 3C13 3.23 12.17 3.75 11.46 4.32C8.87 6.4 7.85 10.07 9.07 13.22C9.11 13.32 9.15 13.42 9.15 13.55C9.15 13.77 9 13.97 8.8 14.05C8.57 14.15 8.33 14.09 8.14 13.93C8.08 13.88 8.04 13.83 8 13.76C6.87 12.33 6.69 10.28 7.45 8.64C5.78 10 4.87 12.3 5 14.47C5.06 14.97 5.12 15.47 5.29 15.97C5.43 16.57 5.7 17.17 6 17.7C7.08 19.43 8.95 20.67 10.96 20.92C13.1 21.19 15.39 20.8 17.03 19.32C18.86 17.66 19.5 15 18.56 12.72L18.43 12.46C18.22 12 17.66 11.2 17.66 11.2M14.5 17.5C14.22 17.74 13.76 18 13.4 18.1C12.28 18.5 11.16 17.94 10.5 17.28C11.69 17 12.4 16.12 12.61 15.23C12.78 14.43 12.46 13.77 12.33 13C12.21 12.26 12.23 11.63 12.5 10.94C12.69 11.32 12.89 11.7 13.13 12C13.9 13 15.11 13.44 15.37 14.8C15.41 14.94 15.43 15.08 15.43 15.23C15.46 16.05 15.1 16.95 14.5 17.5H14.5Z',
    'fan': 'M12,11A1,1 0 0,0 11,12A1,1 0 0,0 12,13A1,1 0 0,0 13,12A1,1 0 0,0 12,11M12.5,2C17,2 17.11,5.57 14.75,6.75C13.76,7.24 13.32,8.29 13.13,9.22C13.61,9.42 14.03,9.73 14.35,10.13C18.05,8.13 22.03,8.92 22.03,12.5C22.03,17 18.46,17.1 17.28,14.73C16.78,13.74 15.72,13.3 14.79,13.11C14.59,13.59 14.28,14 13.88,14.34C15.87,18.03 15.08,22 11.5,22C7,22 6.91,18.42 9.27,17.24C10.25,16.75 10.69,15.71 10.89,14.79C10.4,14.59 9.97,14.27 9.65,13.87C5.96,15.85 2,15.07 2,11.5C2,7 5.56,6.89 6.74,9.26C7.24,10.25 8.29,10.68 9.22,10.87C9.41,10.39 9.73,9.97 10.14,9.65C8.15,5.96 8.94,2 12.5,2Z'
  };

  const icon_path = new Path2D(icons[icon]);
  const svg_matrix = document.createElementNS(
    'http://www.w3.org/2000/svg',
    'svg'
  ).createSVGMatrix();
  const transform = svg_matrix.scale(scale);
  const scaled_path = new Path2D();
  scaled_path.addPath(icon_path, transform);

  return scaled_path;
};

/**
 * Dispose.
 */
beestat.component.scene.prototype.dispose = function() {
  if (this.skeleton_builder_ready_handler_ !== undefined) {
    window.removeEventListener('skeleton_builder_ready', this.skeleton_builder_ready_handler_);
    delete this.skeleton_builder_ready_handler_;
  }

  // Cancel animation loop
  window.cancelAnimationFrame(this.animation_frame_);

  // Dispose of controls
  if (this.controls_ !== undefined) {
    this.controls_.dispose();
  }

  // Dispose of renderer
  if (this.renderer_ !== undefined) {
    this.renderer_.dispose();
  }

  if (this.sun_glow_texture_ !== undefined) {
    this.sun_glow_texture_.dispose();
  }
  if (this.sun_core_texture_ !== undefined) {
    this.sun_core_texture_.dispose();
  }
  if (this.snow_particle_texture_ !== undefined) {
    this.snow_particle_texture_.dispose();
  }
  if (this.rain_particle_texture_ !== undefined) {
    this.rain_particle_texture_.dispose();
  }
  if (this.cloud_texture_ !== undefined) {
    this.cloud_texture_.dispose();
  }
  if (this.fog_volume_texture_ !== undefined) {
    this.fog_volume_texture_.dispose();
  }
  if (this.moon_phase_texture_ !== undefined) {
    this.moon_phase_texture_.dispose();
  }
  if (this.star_texture_ !== undefined) {
    this.star_texture_.dispose();
  }
  if (this.csg_cutter_material_ !== undefined) {
    this.csg_cutter_material_.dispose();
  }
  if (this.opening_frame_material_ !== undefined) {
    this.opening_frame_material_.dispose();
  }
  if (this.window_pane_material_ !== undefined) {
    this.window_pane_material_.dispose();
  }
  if (this.light_source_marker_material_ !== undefined) {
    this.light_source_marker_material_.dispose();
  }
  if (this.light_source_glow_material_ !== undefined) {
    this.light_source_glow_material_.dispose();
  }
  if (this.light_source_marker_geometry_ !== undefined) {
    this.light_source_marker_geometry_.dispose();
  }
  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) {
    this.scene_.traverse(function(object) {
      if (object.geometry) {
        object.geometry.dispose();
      }
      if (object.material) {
        if (Array.isArray(object.material)) {
          object.material.forEach(function(material) {
            material.dispose();
          });
        } else {
          object.material.dispose();
        }
      }
    });
  }

  beestat.component.prototype.dispose.apply(this, arguments);
};

/**
 * Get the currently active room.
 *
 * @return {object}
 */
beestat.component.scene.prototype.get_active_room_ = function() {
  if (this.active_mesh_ !== undefined) {
    return this.active_mesh_.userData.room;
  }

  return null;
};
