1
0
mirror of https://github.com/beestat/app.git synced 2026-02-26 13:10:23 -05:00
beestat/js/component/scene.js
2026-02-19 08:59:36 -05:00

4912 lines
155 KiB
JavaScript

// 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 = 2200;
/**
* 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;
/**
* Time in seconds for weather effects to fully transition to a new mode.
*
* @type {number}
*/
beestat.component.scene.weather_transition_seconds = 2;
/**
* 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;
/**
* Default number of decorative trees to place around the environment.
*
* @type {number}
*/
beestat.component.scene.environment_tree_count = 14;
/**
* Toggle tree foliage visibility for environment trees.
*
* @type {boolean}
*/
beestat.component.scene.environment_tree_foliage_enabled = true;
/**
* Debug opacity for round/oval canopies when foliage is visible.
*
* @type {number}
*/
beestat.component.scene.debug_tree_canopy_opacity = 1;
/**
* Keep round/oval branch meshes visible even when foliage is visible.
*
* @type {boolean}
*/
beestat.component.scene.debug_show_branches_with_foliage = true;
/**
* Round/oval primary branch density in branches per height unit.
*
* @type {number}
*/
beestat.component.scene.round_tree_branches_per_height = 0.15;
/**
* 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': 'none'
};
/**
* 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.35;
/**
* Number of star sprites generated in the sky dome.
*
* @type {number}
*/
beestat.component.scene.star_count = 900;
/**
* 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;
/**
* 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.scene_.remove(this.main_group_);
this.add_main_group_();
this.add_floor_plan_();
this.apply_appearance_rotation_to_lights_();
// Ensure everything gets updated with the latest info.
if (this.rendered_ === true) {
this.update_();
}
};
/**
* 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];
};
/**
* Set weather on the floor-plan appearance.
*
* @param {string} weather none|sunny|cloudy|rain|snow
*
* @return {beestat.component.scene}
*/
beestat.component.scene.prototype.set_weather = function(weather) {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
if (floor_plan.data.appearance === undefined) {
floor_plan.data.appearance = {};
}
floor_plan.data.appearance.weather = weather;
this.update_weather_targets_();
if (this.rendered_ === true) {
this.update_();
}
return this;
};
/**
* Get weather transition profile for visuals.
*
* @param {string} weather
*
* @return {object}
*/
beestat.component.scene.prototype.get_weather_profile_ = function(weather) {
switch (weather) {
case 'snow':
return {
'cloud_count': beestat.component.scene.weather_cloud_max_count,
'rain_count': 0,
'snow_count': beestat.component.scene.weather_snow_max_count
};
case 'rain':
return {
'cloud_count': Math.round(beestat.component.scene.weather_cloud_max_count * 0.92),
'rain_count': beestat.component.scene.weather_rain_max_count,
'snow_count': 0
};
case 'cloudy':
return {
'cloud_count': Math.round(beestat.component.scene.weather_cloud_max_count * 0.72),
'rain_count': 0,
'snow_count': 0
};
case 'sunny':
case 'none':
default:
return {
'cloud_count': 0,
'rain_count': 0,
'snow_count': 0
};
}
};
/**
* Get dimming multiplier from active cloud density for sun/moon brightness.
*
* @return {number}
*/
beestat.component.scene.prototype.get_cloud_dimming_factor_ = function() {
const current_cloud_count = this.current_cloud_count_ === undefined
? 0
: this.current_cloud_count_;
const cloud_density = Math.max(
0,
Math.min(
1,
current_cloud_count / beestat.component.scene.weather_cloud_max_count
)
);
return 1 - (cloud_density * 0.92);
};
/**
* Update weather transition targets based on appearance weather.
*/
beestat.component.scene.prototype.update_weather_targets_ = function() {
this.weather_profile_target_ = this.get_weather_profile_(this.get_appearance_value_('weather'));
this.weather_transition_start_profile_ = {
'cloud_count': this.current_cloud_count_ === undefined ? 0 : this.current_cloud_count_,
'rain_count': this.current_rain_count_ === undefined ? 0 : this.current_rain_count_,
'snow_count': this.current_snow_count_ === undefined ? 0 : this.current_snow_count_
};
this.weather_transition_start_ms_ = window.performance.now();
};
/**
* Get current snow cover blend amount (0-1) from precipitation transition.
*
* @return {number}
*/
beestat.component.scene.prototype.get_snow_cover_blend_ = function() {
if (
this.current_snow_count_ === undefined ||
beestat.component.scene.weather_snow_max_count <= 0
) {
return 0;
}
return Math.max(
0,
Math.min(
1,
this.current_snow_count_ / beestat.component.scene.weather_snow_max_count
)
);
};
/**
* Blend roof, ground, and floor-plan surface materials toward snow white.
*
* @param {number} snow_blend
*/
beestat.component.scene.prototype.update_snow_surface_colors_ = function(snow_blend) {
if (this.layers_ === undefined) {
return;
}
// Keep a small amount of base color visible at peak snow for definition.
const normalized_blend = Math.max(0, Math.min(1, snow_blend));
const blend = normalized_blend * 0.9;
const foliage_blend = normalized_blend * 0.75;
const snow_color = new THREE.Color(beestat.component.scene.snow_surface_color);
const base_roof_color = new THREE.Color(this.get_appearance_value_('roof_color'));
const base_ground_color = new THREE.Color(this.get_appearance_value_('ground_color'));
const roof_color = base_roof_color.clone().lerp(snow_color, blend);
const ground_color = base_ground_color.clone().lerp(snow_color, blend);
if (this.layers_.roof !== undefined) {
this.layers_.roof.traverse(function(object) {
if (
object.userData !== undefined &&
object.userData.is_roof === true &&
object.material !== undefined &&
object.material.color !== undefined
) {
object.material.color.copy(roof_color);
}
});
}
if (this.layers_.environment !== undefined) {
this.layers_.environment.traverse(function(object) {
if (
object.userData !== undefined &&
object.userData.is_ground === true &&
object.material !== undefined &&
object.material.color !== undefined
) {
object.material.color.copy(ground_color);
}
if (
object.userData !== undefined &&
object.userData.is_surface === true &&
object.material !== undefined &&
object.material.color !== undefined
) {
const base_surface_color = new THREE.Color(
object.userData.base_surface_color || object.material.color.getHex()
);
const surface_color = base_surface_color.clone().lerp(snow_color, blend);
object.material.color.copy(surface_color);
}
if (
object.userData !== undefined &&
object.userData.is_tree_foliage === true &&
object.material !== undefined &&
object.material.color !== undefined
) {
const base_foliage_color = new THREE.Color(
object.userData.base_tree_foliage_color || object.material.color.getHex()
);
const foliage_color = base_foliage_color.clone().lerp(snow_color, foliage_blend);
object.material.color.copy(foliage_color);
}
});
}
};
/**
* 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');
this.debug_ = {
'axes': false,
'directional_light_helpers': false,
'sun_light_helper': false,
'moon_light_helper': false,
'watcher': false,
'roof_edges': false,
'straight_skeleton': false,
'openings': true,
'opening_cutters': false
};
this.width_ = this.state_.scene_width || 800;
this.height_ = 500;
this.add_scene_(parent);
this.add_renderer_(parent);
this.add_camera_();
this.add_controls_(parent);
this.add_raycaster_(parent);
this.add_skybox_(parent);
this.add_static_lights_();
this.add_main_group_();
this.add_floor_plan_();
const animate = function() {
self.animation_frame_ = window.requestAnimationFrame(animate);
self.controls_.update();
self.update_raycaster_();
self.update_celestial_light_intensities_();
self.update_weather_();
self.renderer_.render(self.scene_, self.camera_);
};
animate();
};
/**
* Add the scene. Everything gets added to the scene.
*
* @param {rocket.Elements} parent Parent
*/
beestat.component.scene.prototype.add_scene_ = function(parent) {
this.scene_ = new THREE.Scene();
if (this.debug_.axes === true) {
this.scene_.add(
new THREE.AxesHelper(800)
.setColors(
0xff0000,
0x00ff00,
0x0000ff
)
);
}
if (this.debug_.watcher === true) {
this.debug_info_ = {};
this.debug_container_ = $.createElement('div').style({
'position': 'absolute',
'top': (beestat.style.size.gutter / 2),
'left': (beestat.style.size.gutter / 2),
'padding': (beestat.style.size.gutter / 2),
'background': 'rgba(0, 0, 0, 0.5)',
'color': '#fff',
'font-family': 'Consolas, Courier, Monospace',
'white-space': 'pre'
});
parent.appendChild(this.debug_container_);
}
};
/**
* 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;
};
/**
* Initialize a click.
*
* @param {Event} e
*/
beestat.component.scene.prototype.mousedown_handler_ = function(e) {
// Don't propagate to things under me.
e.stopPropagation();
this.mousemove_handler_ = this.mousemove_handler_.bind(this);
window.addEventListener('mousemove', this.mousemove_handler_);
window.addEventListener('touchmove', this.mousemove_handler_);
this.mouseup_handler_ = this.mouseup_handler_.bind(this);
window.addEventListener('mouseup', this.mouseup_handler_);
window.addEventListener('touchend', this.mouseup_handler_);
this.dragged_ = false;
};
/**
* Added after mousedown, so when the mouse moves just set dragged = true.
*/
beestat.component.scene.prototype.mousemove_handler_ = function() {
this.dragged_ = true;
};
/**
* Set an active mesh if it wasn't a drag.
*/
beestat.component.scene.prototype.mouseup_handler_ = function() {
window.removeEventListener('mousemove', this.mousemove_handler_);
window.removeEventListener('touchmove', this.mousemove_handler_);
window.removeEventListener('mouseup', this.mouseup_handler_);
window.removeEventListener('touchend', this.mouseup_handler_);
if (this.dragged_ === false) {
this.active_mesh_ = this.intersected_mesh_;
this.dispatchEvent('change_active_room');
this.update_();
}
};
/**
* Add the raycaster.
*
* @param {rocket.Elements} parent
*/
beestat.component.scene.prototype.add_raycaster_ = function() {
const self = this;
this.raycaster_ = new THREE.Raycaster();
this.raycaster_.layers.set(beestat.component.scene.layer_visible);
/**
* Initialize a pointer representing the raycaster. Initialize it pointing
* way off screen instead of 0,0 so nothing starts thinking the mouse is
* over it.
*/
this.raycaster_pointer_ = new THREE.Vector2(10000, 10000);
// TODO remove event listener on dispose
document.addEventListener('mousemove', 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));
};
/**
* Update the raycaster.
*
* @param {rocket.Elements} parent
*/
beestat.component.scene.prototype.update_raycaster_ = function() {
if (this.raycaster_ !== undefined) {
this.raycaster_.setFromCamera(this.raycaster_pointer_, this.camera_);
const intersects = this.raycaster_.intersectObject(this.scene_);
// Clear any existing intersects.
if (this.intersected_mesh_ !== undefined) {
document.body.style.cursor = '';
if (
this.intersected_mesh_.material !== undefined &&
this.intersected_mesh_.material.emissive !== undefined
) {
this.intersected_mesh_.material.emissive.setHex(0x000000);
}
delete this.intersected_mesh_;
}
// Set intersect.
for (let i = 0; i < intersects.length; i++) {
if (
intersects[i].object.type === 'Mesh' &&
intersects[i].object.material !== undefined &&
intersects[i].object.material.emissive !== undefined &&
intersects[i].object.userData.is_wall !== true &&
intersects[i].object.userData.is_surface !== true &&
intersects[i].object.userData.is_roof !== true &&
intersects[i].object.userData.is_environment !== true &&
intersects[i].object.userData.is_celestial_object !== true
) {
this.intersected_mesh_ = intersects[i].object;
break;
}
}
// Style intersect.
if (this.intersected_mesh_ !== undefined) {
this.intersected_mesh_.material.emissive.setHex(0xffffff);
this.intersected_mesh_.material.emissiveIntensity = 0.1;
document.body.style.cursor = 'pointer';
}
}
};
/**
* Add a skybox background. Generated using Spacescape with the Unity export
* settings. After export: bottom is rotated CW 90°; top is roted 90°CCW.
*
* nx -> bk
* ny -> dn
* nz -> lf
* px -> ft
* py -> up
* pz -> rt
*
* @link https://www.mapcore.org/topic/24535-online-tools-to-convert-cubemaps-to-panoramas-and-vice-versa/
* @link https://jaxry.github.io/panorama-to-cubemap/
* @link http://alexcpeterson.com/spacescape/
*/
beestat.component.scene.prototype.add_skybox_ = function() {
const loader = new THREE.CubeTextureLoader();
loader.setPath('img/visualize/skybox/');
const texture = loader.load([
'front.png',
'back.png',
'up.png',
'down.png',
'right.png',
'left.png'
]);
this.scene_.background = texture;
};
/**
* Create a radial glow texture used for the sun halo sprite.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_sun_glow_texture_ = function() {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0.0, 'rgba(255, 255, 235, 1.0)');
gradient.addColorStop(0.25, 'rgba(255, 230, 150, 0.75)');
gradient.addColorStop(0.6, 'rgba(255, 170, 80, 0.25)');
gradient.addColorStop(1.0, 'rgba(255, 120, 50, 0.0)');
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
};
/**
* Create a soft star sprite texture.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_star_texture_ = function() {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0.0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.2, 'rgba(245, 250, 255, 0.95)');
gradient.addColorStop(0.65, 'rgba(210, 225, 255, 0.25)');
gradient.addColorStop(1.0, 'rgba(200, 220, 255, 0)');
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
};
/**
* Create a circular particle texture for snow.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_snow_particle_texture_ = function() {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0.0, 'rgba(255, 255, 255, 1.0)');
gradient.addColorStop(0.4, 'rgba(245, 250, 255, 0.9)');
gradient.addColorStop(1.0, 'rgba(240, 245, 255, 0.0)');
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
};
/**
* Create a streak-like particle texture for rain.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_rain_particle_texture_ = function() {
const width = 24;
const height = 64;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0.0, 'rgba(170, 200, 255, 0.0)');
gradient.addColorStop(0.25, 'rgba(185, 210, 255, 0.85)');
gradient.addColorStop(1.0, 'rgba(170, 200, 255, 0.0)');
context.fillStyle = gradient;
context.fillRect(width / 2 - 2, 0, 4, height);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
};
/**
* Create a soft cloud texture used for weather cloud sprites.
*
* @return {THREE.Texture}
*/
beestat.component.scene.prototype.create_cloud_texture_ = function() {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const circles = [
{'x': 0.36, 'y': 0.56, 'r': 0.2},
{'x': 0.5, 'y': 0.5, 'r': 0.24},
{'x': 0.64, 'y': 0.56, 'r': 0.2},
{'x': 0.5, 'y': 0.64, 'r': 0.22}
];
circles.forEach(function(circle) {
const gradient = context.createRadialGradient(
size * circle.x,
size * circle.y,
0,
size * circle.x,
size * circle.y,
size * circle.r
);
gradient.addColorStop(0.0, 'rgba(255,255,255,0.9)');
gradient.addColorStop(0.55, 'rgba(240,245,255,0.55)');
gradient.addColorStop(1.0, 'rgba(240,245,255,0.0)');
context.fillStyle = gradient;
context.beginPath();
context.arc(size * circle.x, size * circle.y, size * circle.r, 0, Math.PI * 2);
context.fill();
});
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
};
/**
* Draw the moon phase into the reusable moon canvas texture.
*
* @param {number} phase Moon phase from SunCalc (0=new, 0.25=first quarter,
* 0.5=full, 0.75=last quarter).
*/
beestat.component.scene.prototype.update_moon_phase_texture_ = function(phase) {
if (this.moon_phase_canvas_ === undefined) {
this.moon_phase_canvas_ = document.createElement('canvas');
this.moon_phase_canvas_.width = 256;
this.moon_phase_canvas_.height = 256;
this.moon_phase_texture_ = new THREE.CanvasTexture(this.moon_phase_canvas_);
}
const canvas = this.moon_phase_canvas_;
const context = canvas.getContext('2d');
const size = canvas.width;
const center = size / 2;
const radius = 110;
context.clearRect(0, 0, size, size);
// Base dark moon disk.
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.fillStyle = '#2f3442';
context.fill();
// Lit region generated procedurally from phase (no image assets).
context.save();
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.clip();
context.fillStyle = '#dde3ef';
const terminator = radius * Math.cos(2 * Math.PI * phase);
const waxing = phase <= 0.5;
for (let y = -radius; y <= radius; y++) {
const x_edge = Math.sqrt(Math.max(0, radius * radius - y * y));
// Curved terminator produces natural crescent/gibbous shapes.
const x_terminator = terminator * Math.sqrt(Math.max(0, 1 - (y * y) / (radius * radius)));
let x_start;
let x_end;
if (waxing) {
x_start = Math.max(-x_edge, x_terminator);
x_end = x_edge;
} else {
x_start = -x_edge;
x_end = Math.min(x_edge, -x_terminator);
}
if (x_end > x_start) {
context.fillRect(center + x_start, center + y, x_end - x_start, 1);
}
}
context.restore();
// Subtle rim to keep the disk readable on the skybox.
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.strokeStyle = 'rgba(255, 255, 255, 0.2)';
context.lineWidth = 2;
context.stroke();
this.moon_phase_texture_.needsUpdate = true;
};
/**
* Add multiple directional lights from different angles to create definition
* and depth without harsh shadows. This three-point lighting setup gives
* surfaces varied illumination for better visual depth.
*/
beestat.component.scene.prototype.add_directional_lights_ = function() {
// Prevent re-initialization if lights already exist
if (this.directional_lights_ !== undefined) {
return;
}
this.directional_lights_ = [];
// Key light: Main light from upper front-right (strongest)
const key_light = new THREE.DirectionalLight(0xffffff, beestat.component.scene.directional_light_intensity);
key_light.position.set(2000, 3200, 2000);
this.static_light_group_.add(key_light);
this.directional_lights_.push(key_light);
// Fill light: Softer light from upper front-left (balances key light)
const fill_light = new THREE.DirectionalLight(0xffffff, beestat.component.scene.directional_light_intensity);
fill_light.position.set(-2000, 2400, 2000);
this.static_light_group_.add(fill_light);
this.directional_lights_.push(fill_light);
// Back light: Mild light from behind (creates rim lighting on edges)
const back_light = new THREE.DirectionalLight(0xffffff, beestat.component.scene.directional_light_intensity);
back_light.position.set(0, 2000, -2000);
this.static_light_group_.add(back_light);
this.directional_lights_.push(back_light);
// Top light: Gentle overhead light for roof definition
const top_light = new THREE.DirectionalLight(0xffffff, beestat.component.scene.directional_light_intensity);
top_light.position.set(0, 4000, 0);
this.static_light_group_.add(top_light);
this.directional_lights_.push(top_light);
// Add helpers for debugging
if (this.debug_.directional_light_helpers === true) {
this.directional_light_helpers_ = [];
this.directional_lights_.forEach((light) => {
const helper = new THREE.DirectionalLightHelper(light, 100);
this.static_light_group_.add(helper);
this.directional_light_helpers_.push(helper);
});
}
};
/**
* Create static lights group containing ambient and directional fill lights.
* These lights are always on and provide base illumination.
*/
beestat.component.scene.prototype.add_static_lights_ = function() {
// Prevent re-initialization
if (this.static_light_group_ !== undefined) {
return;
}
// Initialize layers object if not already done
if (this.layers_ === undefined) {
this.layers_ = {};
}
this.static_light_group_ = new THREE.Group();
this.scene_.add(this.static_light_group_);
this.layers_['static_lights'] = this.static_light_group_;
// Add ambient light
this.ambient_light_ = new THREE.AmbientLight(
0xffffff,
beestat.component.scene.ambient_light_intensity
);
this.static_light_group_.add(this.ambient_light_);
// Add directional fill lights
this.add_directional_lights_();
this.apply_appearance_rotation_to_lights_();
};
/**
* Directional sun and moon lights that provide natural lighting. Only
* visible when the environment layer is enabled. Positions are calculated based
* on time of day and location.
*/
beestat.component.scene.prototype.add_celestial_lights_ = function() {
// Prevent re-initialization if lights already exist
if (this.sun_light_ !== undefined) {
return;
}
// Create celestial group if it doesn't exist
if (this.celestial_light_group_ === undefined) {
this.celestial_light_group_ = new THREE.Group();
this.scene_.add(this.celestial_light_group_);
this.layers_['celestial'] = this.celestial_light_group_;
}
// Sun light
this.sun_light_ = new THREE.DirectionalLight(
0xffffdd, // Slightly warm color for sunlight
beestat.component.scene.sun_light_intensity
);
// Initial position (will be updated by update_celestial_lights_)
this.sun_light_.position.set(500, 500, -500);
// Enable shadow casting
this.sun_light_.castShadow = true;
this.sun_light_.shadow.mapSize.set(2048, 2048);
this.sun_light_.shadow.bias = -0.001;
// Configure shadow camera frustum
this.sun_light_.shadow.camera.left = -1000;
this.sun_light_.shadow.camera.right = 1000;
this.sun_light_.shadow.camera.top = 1000;
this.sun_light_.shadow.camera.bottom = -1000;
this.sun_light_.shadow.camera.near = 0.5;
this.sun_light_.shadow.camera.far = 5000;
this.sun_light_.shadow.camera.updateProjectionMatrix();
// Set target to world origin (0,0,0) so light always points there
this.sun_light_.target.position.set(0, 0, 0);
this.scene_.add(this.sun_light_.target);
this.celestial_light_group_.add(this.sun_light_);
// Faint arc showing the sun's path across the sky.
this.sun_path_line_ = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
'color': 0xffe7aa,
'vertexColors': true,
'transparent': true,
'opacity': 0.14,
'depthWrite': false
})
);
this.sun_path_line_.layers.set(beestat.component.scene.layer_visible);
this.celestial_light_group_.add(this.sun_path_line_);
// Visible sun body and glow
this.sun_visual_group_ = new THREE.Group();
this.sun_visual_group_.layers.set(beestat.component.scene.layer_visible);
this.celestial_light_group_.add(this.sun_visual_group_);
const sun_core_geometry = new THREE.SphereGeometry(180, 24, 24);
const sun_core_material = new THREE.MeshBasicMaterial({
'color': 0xffffff,
'transparent': true,
'opacity': 1
});
this.sun_core_mesh_ = new THREE.Mesh(sun_core_geometry, sun_core_material);
this.sun_core_mesh_.userData.is_celestial_object = true;
this.sun_visual_group_.add(this.sun_core_mesh_);
this.sun_glow_texture_ = this.create_sun_glow_texture_();
const sun_glow_material = new THREE.SpriteMaterial({
'map': this.sun_glow_texture_,
'color': 0xfff0b0,
'transparent': true,
'blending': THREE.AdditiveBlending,
'depthWrite': false,
'depthTest': true,
'opacity': 1
});
this.sun_glow_sprite_ = new THREE.Sprite(sun_glow_material);
this.sun_glow_sprite_.userData.is_celestial_object = true;
this.sun_glow_sprite_.scale.set(1280, 1280, 1);
this.sun_visual_group_.add(this.sun_glow_sprite_);
if (this.debug_.sun_light_helper === true) {
this.sun_light_helper_ = new THREE.DirectionalLightHelper(
this.sun_light_,
100
);
this.celestial_light_group_.add(this.sun_light_helper_);
}
// Moon light
this.moon_light_ = new THREE.DirectionalLight(
0xaaccff, // Cool bluish color for moonlight
beestat.component.scene.moon_light_intensity
);
// Initial position (will be updated by update_celestial_lights_)
this.moon_light_.position.set(-500, 500, 500);
// Enable shadow casting
this.moon_light_.castShadow = true;
this.moon_light_.shadow.mapSize.set(2048, 2048);
this.moon_light_.shadow.bias = -0.001;
// Configure shadow camera frustum
this.moon_light_.shadow.camera.left = -1000;
this.moon_light_.shadow.camera.right = 1000;
this.moon_light_.shadow.camera.top = 1000;
this.moon_light_.shadow.camera.bottom = -1000;
this.moon_light_.shadow.camera.near = 0.5;
this.moon_light_.shadow.camera.far = 5000;
this.moon_light_.shadow.camera.updateProjectionMatrix();
// Set target to world origin
this.moon_light_.target.position.set(0, 0, 0);
this.scene_.add(this.moon_light_.target);
this.celestial_light_group_.add(this.moon_light_);
// Visible moon disk with procedural phase texture.
this.moon_visual_group_ = new THREE.Group();
this.moon_visual_group_.layers.set(beestat.component.scene.layer_visible);
this.celestial_light_group_.add(this.moon_visual_group_);
this.update_moon_phase_texture_(0);
const moon_material = new THREE.SpriteMaterial({
'map': this.moon_phase_texture_,
'transparent': true,
'depthWrite': false,
'depthTest': true,
'opacity': 1
});
this.moon_sprite_ = new THREE.Sprite(moon_material);
this.moon_sprite_.userData.is_celestial_object = true;
this.moon_sprite_.scale.set(500, 500, 1);
this.moon_visual_group_.add(this.moon_sprite_);
if (this.debug_.moon_light_helper === true) {
this.moon_light_helper_ = new THREE.DirectionalLightHelper(
this.moon_light_,
100
);
this.celestial_light_group_.add(this.moon_light_helper_);
}
this.add_stars_();
this.apply_appearance_rotation_to_lights_();
};
/**
* Add stars as non-lighting visual sprites in the sky.
*/
beestat.component.scene.prototype.add_stars_ = function() {
if (this.star_texture_ === undefined) {
this.star_texture_ = this.create_star_texture_();
}
this.star_group_ = new THREE.Group();
this.star_group_.layers.set(beestat.component.scene.layer_visible);
this.celestial_light_group_.add(this.star_group_);
this.stars_ = [];
const radius = 4200;
for (let i = 0; i < beestat.component.scene.star_count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos((Math.random() * 2) - 1);
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
const size =
beestat.component.scene.star_min_size +
(Math.pow(Math.random(), 1.7) * (beestat.component.scene.star_max_size - beestat.component.scene.star_min_size));
const is_twinkle = size >= 20;
const base_opacity = 0.3 + (Math.random() * 0.7);
const material = new THREE.SpriteMaterial({
'map': this.star_texture_,
'transparent': true,
'opacity': 0,
'depthWrite': false,
'depthTest': true,
'blending': THREE.AdditiveBlending
});
const star = new THREE.Sprite(material);
star.position.set(x, y, z);
star.scale.set(size, size, 1);
star.userData.is_celestial_object = true;
this.star_group_.add(star);
this.stars_.push({
'sprite': star,
'base_opacity': base_opacity,
'twinkle': is_twinkle,
'twinkle_amount': is_twinkle ? (0.06 + (Math.random() * 0.1)) : 0,
'twinkle_speed': is_twinkle ? (0.8 + (Math.random() * 1.2)) : 0,
'phase': Math.random() * Math.PI * 2
});
}
this.star_visibility_ = 0;
this.target_star_visibility_ = 0;
};
/**
* Update a faint line representing the sun's path for the current date and
* location.
*
* @param {moment} date
* @param {number} latitude
* @param {number} longitude
*/
beestat.component.scene.prototype.update_sun_path_arc_ = function(date, latitude, longitude) {
if (this.sun_path_line_ === undefined) {
return;
}
const rotation_radians = (this.get_appearance_value_('rotation') * Math.PI) / 180;
const sun_distance = 4000;
const start_of_day = date.clone().startOf('day');
const end_of_day = start_of_day.clone().add(1, 'day');
const start_ms = start_of_day.valueOf();
const end_ms = end_of_day.valueOf();
const sample_count = 72;
const points = [];
for (let i = 0; i < sample_count; i++) {
const t = i / (sample_count - 1);
const sample_date = new Date(start_ms + (end_ms - start_ms) * t);
const sun_pos = SunCalc.getPosition(sample_date, latitude, longitude);
// Convert SunCalc azimuth (south-origin, west-positive) to a north-origin,
// clockwise bearing, then apply floor-plan north rotation.
const rotated_azimuth = sun_pos.azimuth + Math.PI + rotation_radians;
// Extend slightly below horizon so the path doesn't hard-stop at horizon.
if (sun_pos.altitude > -0.22) {
points.push(new THREE.Vector3(
sun_distance * Math.cos(sun_pos.altitude) * Math.sin(rotated_azimuth),
sun_distance * Math.sin(sun_pos.altitude),
-sun_distance * Math.cos(sun_pos.altitude) * Math.cos(rotated_azimuth)
));
}
}
if (points.length >= 2) {
const geometry = this.sun_path_line_.geometry;
geometry.setFromPoints(points);
// Fade the ends by dimming vertex colors toward each endpoint.
const colors = [];
const base = new THREE.Color(0xffe7aa);
for (let i = 0; i < points.length; i++) {
const t = i / (points.length - 1);
const center_weight = Math.pow(Math.sin(Math.PI * t), 1.2);
const intensity = 0.2 + (0.8 * center_weight);
const color = base.clone().multiplyScalar(intensity);
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
this.sun_path_line_.visible = true;
} else {
this.sun_path_line_.geometry.setFromPoints([]);
this.sun_path_line_.visible = false;
}
};
/**
* Static (ambient/directional fill) lights should not rotate with floor-plan
* appearance. Celestial lights are handled in update_celestial_lights_.
*/
beestat.component.scene.prototype.apply_appearance_rotation_to_lights_ = function() {
if (this.static_light_group_ !== undefined) {
this.static_light_group_.rotation.y = 0;
}
};
/**
* Update sun and moon light positions based on date and location using SunCalc.
* Adjusts light intensities based on altitude and moon phase.
*
* @param {moment} date The date/time to calculate positions for
* @param {number} latitude Location latitude
* @param {number} longitude Location longitude
*
* @link https://www.earthspacelab.com/app/solar-time/
*/
beestat.component.scene.prototype.update_celestial_lights_ = function(date, latitude, longitude) {
const sun_distance = 4000;
const moon_distance = 3920;
const js_date = date.toDate();
const rotation_radians = (this.get_appearance_value_('rotation') * Math.PI) / 180;
// Sun
const sun_pos = SunCalc.getPosition(js_date, latitude, longitude);
// Convert SunCalc azimuth (south-origin, west-positive) to a north-origin,
// clockwise bearing, then apply floor-plan north rotation.
const rotated_sun_azimuth = sun_pos.azimuth + Math.PI + rotation_radians;
this.sun_light_.position.set(
sun_distance * Math.cos(sun_pos.altitude) * Math.sin(rotated_sun_azimuth), // East-West
sun_distance * Math.sin(sun_pos.altitude), // Up-Down (altitude)
-sun_distance * Math.cos(sun_pos.altitude) * Math.cos(rotated_sun_azimuth) // North-South
);
if (this.sun_visual_group_ !== undefined) {
this.sun_visual_group_.position.copy(this.sun_light_.position);
this.sun_visual_group_.visible = true;
this.sun_visual_horizon_fade_ = Math.max(0, Math.min(1, (sun_pos.altitude + 0.15) / 0.3));
}
const cloud_dimming = this.get_cloud_dimming_factor_();
// Calculate target intensity for smooth transitions
this.target_sun_intensity_ = sun_pos.altitude < 0
? Math.max(0, beestat.component.scene.sun_light_intensity * (1 + sun_pos.altitude / (Math.PI / 6)))
: beestat.component.scene.sun_light_intensity;
this.target_sun_intensity_ *= cloud_dimming;
// Fade stars out at day and in at night.
this.target_star_visibility_ = Math.max(
0,
Math.min(1, (-sun_pos.altitude - 0.05) / 0.25)
);
// Moon
const moon_pos = SunCalc.getMoonPosition(js_date, latitude, longitude);
// Keep moon conversion consistent with the sun conversion.
const rotated_moon_azimuth = moon_pos.azimuth + Math.PI + rotation_radians;
const moon_illumination = SunCalc.getMoonIllumination(js_date);
const moon_fraction = moon_illumination.fraction;
const moon_phase = moon_illumination.phase;
this.moon_light_.position.set(
moon_distance * Math.cos(moon_pos.altitude) * Math.sin(rotated_moon_azimuth), // East-West
moon_distance * Math.sin(moon_pos.altitude), // Up-Down (altitude)
-moon_distance * Math.cos(moon_pos.altitude) * Math.cos(rotated_moon_azimuth) // North-South
);
if (this.moon_visual_group_ !== undefined) {
let moon_front_direction;
if (this.camera_ !== undefined) {
moon_front_direction = this.camera_.position.clone().sub(this.moon_light_.position).normalize();
} else {
moon_front_direction = this.moon_light_.position.clone().normalize().negate();
}
this.moon_visual_group_.position.copy(
this.moon_light_.position.clone().add(moon_front_direction.multiplyScalar(20))
);
this.moon_visual_group_.visible = true;
this.moon_visual_horizon_fade_ = Math.max(0, Math.min(1, (moon_pos.altitude + 0.12) / 0.24));
if (this.last_moon_phase_ === undefined || Math.abs(this.last_moon_phase_ - moon_phase) > 0.002) {
this.last_moon_phase_ = moon_phase;
this.update_moon_phase_texture_(moon_phase);
}
}
const moon_intensity = beestat.component.scene.moon_light_intensity * moon_fraction;
this.update_sun_path_arc_(date, latitude, longitude);
// Calculate target intensity for smooth transitions
// Moon is only visible when sun is below horizon
if (sun_pos.altitude >= 0) {
this.target_moon_intensity_ = 0;
} else {
this.target_moon_intensity_ = moon_pos.altitude < 0
? Math.max(0, moon_intensity * (1 + moon_pos.altitude / (Math.PI / 6)))
: moon_intensity;
}
this.target_moon_intensity_ *= cloud_dimming;
// Update helpers
if (this.debug_.sun_light_helper) {
this.sun_light_.updateMatrixWorld();
this.sun_light_.target.updateMatrixWorld();
this.sun_light_helper_.update();
}
if (this.debug_.moon_light_helper) {
this.moon_light_.updateMatrixWorld();
this.moon_light_.target.updateMatrixWorld();
this.moon_light_helper_.update();
}
};
/**
* Smoothly interpolate celestial light intensities towards their targets.
* Called every frame to create smooth transitions instead of instant jumps.
*/
beestat.component.scene.prototype.update_celestial_light_intensities_ = function() {
if (this.sun_light_ === undefined || this.moon_light_ === undefined) {
return;
}
// Initialize current intensities if not set
if (this.target_sun_intensity_ === undefined) {
this.target_sun_intensity_ = 0;
}
if (this.target_moon_intensity_ === undefined) {
this.target_moon_intensity_ = 0;
}
// Lerp factor - lower = smoother but slower, higher = faster but jumpier
const lerp_factor = 0.05;
// Lerp sun intensity
this.sun_light_.intensity += (this.target_sun_intensity_ - this.sun_light_.intensity) * lerp_factor;
// Lerp moon intensity
this.moon_light_.intensity += (this.target_moon_intensity_ - this.moon_light_.intensity) * lerp_factor;
// Match visible sun brightness to actual sun light intensity, with smooth
// fade at/under the horizon.
if (this.sun_core_mesh_ !== undefined && this.sun_glow_sprite_ !== undefined) {
const max_sun_intensity = beestat.component.scene.sun_light_intensity;
const intensity_ratio = max_sun_intensity > 0
? Math.max(0, Math.min(1, this.sun_light_.intensity / max_sun_intensity))
: 0;
const horizon_fade = this.sun_visual_horizon_fade_ !== undefined
? this.sun_visual_horizon_fade_
: 1;
const visual_strength = intensity_ratio * horizon_fade;
this.sun_core_mesh_.material.opacity = Math.min(1, (0.65 + visual_strength * 0.8) * visual_strength);
this.sun_glow_sprite_.material.opacity = Math.min(1, (0.45 + visual_strength * 1.4) * visual_strength);
}
if (this.moon_sprite_ !== undefined) {
const max_moon_intensity = beestat.component.scene.moon_light_intensity;
const moon_intensity_ratio = max_moon_intensity > 0
? Math.max(0, Math.min(1, this.moon_light_.intensity / max_moon_intensity))
: 0;
const moon_horizon_fade = this.moon_visual_horizon_fade_ !== undefined
? this.moon_visual_horizon_fade_
: 1;
const moon_visual_strength = moon_intensity_ratio * moon_horizon_fade;
this.moon_sprite_.material.opacity = Math.min(1, 0.2 + (moon_visual_strength * 0.95));
}
this.update_stars_();
};
/**
* Update star visibility and subtle twinkle.
*/
beestat.component.scene.prototype.update_stars_ = function() {
if (this.stars_ === undefined || this.stars_.length === 0) {
return;
}
if (this.target_star_visibility_ === undefined) {
this.target_star_visibility_ = 0;
}
if (this.star_visibility_ === undefined) {
this.star_visibility_ = 0;
}
this.star_visibility_ += (this.target_star_visibility_ - this.star_visibility_) * 0.06;
const visibility = Math.max(0, Math.min(1, this.star_visibility_));
const now_seconds = window.performance.now() / 1000;
if (this.star_group_ !== undefined) {
if (this.date_ !== undefined && typeof this.date_.valueOf === 'function') {
// Apparent star motion is westward due to Earth's eastward rotation.
const sidereal_phase = (
(this.date_.valueOf() / 1000) % beestat.component.scene.sidereal_day_seconds
) / beestat.component.scene.sidereal_day_seconds;
this.star_group_.rotation.y = -sidereal_phase * Math.PI * 2 * beestat.component.scene.star_drift_visual_factor;
}
this.star_group_.visible = visibility > 0.005;
}
for (let i = 0; i < this.stars_.length; i++) {
const star = this.stars_[i];
let twinkle = 1;
if (star.twinkle === true) {
twinkle = 1 + (Math.sin((now_seconds * star.twinkle_speed) + star.phase) * star.twinkle_amount);
}
star.sprite.material.opacity = Math.max(
0,
Math.min(
1,
star.base_opacity * visibility * twinkle
)
);
}
};
/**
* Update the scene based on the currently set date.
*/
beestat.component.scene.prototype.update_ = function() {
const self = this;
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const time = this.date_.format('HH:mm');
// Set the color of each room
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
) {
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)]
);
// 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;
}
// 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_();
}
self.meshes_[room.room_id].material.color.setHex(color.replace('#', '0x'));
});
});
// Update celestial lights (sun and moon) based on date and location
if (this.date_ !== undefined && this.latitude_ !== undefined && this.longitude_ !== undefined) {
this.update_celestial_lights_(this.date_, this.latitude_, this.longitude_);
}
this.update_tree_foliage_season_();
// Update debug watcher
if (this.debug_.watcher === true) {
this.debug_info_.sun_light_intensity = this.sun_light_ !== undefined ? this.sun_light_.intensity.toFixed(3) : 'N/A';
this.debug_info_.moon_light_intensity = this.moon_light_ !== undefined ? this.moon_light_.intensity.toFixed(3) : 'N/A';
this.update_debug_();
}
};
/**
* Add a room. Room coordinates are absolute.
*
* @param {THREE.Group} layer The layer the room belongs to.
* @param {object} group The group the room belongs to.
* @param {object} room The room to add.
*/
beestat.component.scene.prototype.add_room_ = function(layer, group, room) {
const self = this;
const color = beestat.style.color.gray.dark;
var clipper_offset = new ClipperLib.ClipperOffset();
clipper_offset.AddPath(
room.points,
ClipperLib.JoinType.jtSquare,
ClipperLib.EndType.etClosedPolygon
);
var clipper_hole = new ClipperLib.Path();
clipper_offset.Execute(clipper_hole, -beestat.component.scene.room_wall_inset);
// Just the floor plan
const extrude_height = beestat.component.scene.room_floor_thickness;
// Create a shape using the points of the room.
const shape = new THREE.Shape();
const first_point = clipper_hole[0].shift();
shape.moveTo(first_point.x, first_point.y);
clipper_hole[0].forEach(function(point) {
shape.lineTo(point.x, point.y);
});
// Extrude the shape and create the mesh.
const extrude_settings = {
'depth': extrude_height,
'bevelEnabled': false
};
const geometry = new THREE.ExtrudeGeometry(
shape,
extrude_settings
);
const material = new THREE.MeshStandardMaterial({
'color': color,
'roughness': 0.6,
'metalness': 0.0
});
if (
room.sensor_id === undefined ||
beestat.cache.sensor[room.sensor_id] === undefined
) {
const loader = new THREE.TextureLoader();
loader.load(
'img/visualize/stripe.png',
function(texture) {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(0.005, 0.005);
material.map = texture;
material.needsUpdate = true;
}
);
}
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = -extrude_height - (room.elevation || group.elevation);
// Enable shadow receiving for depth perception
mesh.receiveShadow = true;
// Translate the mesh to the room x/y position.
mesh.translateX(room.x);
mesh.translateY(room.y);
// Store a reference to the mesh representing each room.
if (this.meshes_ === undefined) {
this.meshes_ = {};
}
// Allow me to go from room -> mesh and mesh -> room
this.meshes_[room.room_id] = mesh;
mesh.userData.room = room;
layer.add(mesh);
// Label
mesh.userData.sprites = {};
// Outline
const edges_geometry = new THREE.EdgesGeometry(geometry);
const outline = new THREE.LineSegments(
edges_geometry,
new THREE.LineBasicMaterial({
'color': '#ffffff'
})
);
outline.translateX(room.x);
outline.translateY(room.y);
outline.position.z = -extrude_height - (room.elevation || group.elevation);
outline.visible = false;
outline.layers.set(beestat.component.scene.layer_outline);
mesh.userData.outline = outline;
layer.add(outline);
// Determine where the sprites will go.
const geojson_polygon = [];
room.points.forEach(function(point) {
geojson_polygon.push([
point.x,
point.y
]);
});
const label_point = polylabel([geojson_polygon]);
[
'value',
'icon'
].forEach(function(sprite_type) {
const sprite_material = self.get_blank_label_material_();
const sprite = new THREE.Sprite(sprite_material);
// Scale to an appropriate-looking size.
const scale_x = 0.14;
const scale_y = scale_x * sprite_material.map.source.data.height / sprite_material.map.source.data.width;
sprite.scale.set(scale_x, scale_y, 1);
// Set center of sprite to bottom middle.
sprite.center.set(0.5, 0);
/**
* Some arbitrary small number so the sprite is *just* above the room or
* when you view from directly above sometimes they disappear.
*/
const z_offset = 1;
sprite.position.set(
room.x + label_point[0],
room.y + label_point[1],
mesh.position.z - z_offset
);
layer.add(sprite);
mesh.userData.sprites[sprite_type] = sprite;
});
};
/**
* Add a surface. Surface coordinates are relative to surface.x/y.
*
* @param {THREE.Group} layer The layer the surface belongs to.
* @param {object} group The group the surface belongs to.
* @param {object} surface The surface to add.
*/
beestat.component.scene.prototype.add_surface_ = function(layer, group, surface) {
if (surface.points === undefined || surface.points.length < 3) {
return;
}
const shape = new THREE.Shape();
shape.moveTo(surface.points[0].x, surface.points[0].y);
for (let i = 1; i < surface.points.length; i++) {
shape.lineTo(surface.points[i].x, surface.points[i].y);
}
shape.closePath();
const color = surface.color || '#9a9a96';
const height = Math.max(0, Number(surface.height || 0));
const elevation = surface.elevation || group.elevation || 0;
const z_lift = beestat.component.scene.surface_z_lift;
let geometry;
let mesh_position_z;
if (height > 0) {
geometry = new THREE.ExtrudeGeometry(
shape,
{
'depth': height,
'bevelEnabled': false
}
);
// Keep top of the surface slightly above its base plane.
mesh_position_z = -height - elevation - z_lift;
} else {
geometry = new THREE.ShapeGeometry(shape);
// ShapeGeometry lies on z=0, so place it just above the base plane.
mesh_position_z = -elevation - z_lift;
}
const material = new THREE.MeshStandardMaterial({
'color': color,
'roughness': 0.9,
'metalness': 0.0,
'side': THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = mesh_position_z;
mesh.translateX(surface.x || 0);
mesh.translateY(surface.y || 0);
mesh.receiveShadow = true;
mesh.castShadow = true;
mesh.userData.is_environment = true;
mesh.userData.is_surface = true;
mesh.userData.base_surface_color = color;
layer.add(mesh);
};
/**
* Add all floor-plan surfaces to the environment layer.
*
* @param {THREE.Group} layer The environment surfaces layer.
*/
beestat.component.scene.prototype.add_surfaces_to_environment_ = function(layer) {
const self = this;
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
floor_plan.data.groups.forEach(function(group) {
(group.surfaces || []).forEach(function(surface) {
self.add_surface_(layer, group, surface);
});
});
};
/**
* Add exterior walls for a group. For each room, the room polygon is offset
* outward by wall_thickness, then the union of all rooms is subtracted. This
* leaves only exterior wall segments at the correct per-room height.
*
* @param {THREE.Group} layer The layer to add walls to.
* @param {object} group The floor plan group.
*/
beestat.component.scene.prototype.add_walls_ = function(layer, group) {
const wall_thickness = beestat.component.scene.wall_thickness;
if (group.rooms.length === 0) {
return;
}
// Convert all room polygons to absolute coordinates.
const absolute_paths = [];
group.rooms.forEach(function(room) {
const absolute_path = [];
room.points.forEach(function(point) {
absolute_path.push({
'x': room.x + point.x,
'y': room.y + point.y
});
});
absolute_paths.push(absolute_path);
});
// Union all room polygons (computed once per group).
const union_clipper = new ClipperLib.Clipper();
absolute_paths.forEach(function(path) {
union_clipper.AddPath(
path,
ClipperLib.PolyType.ptSubject,
true
);
});
const all_rooms_union = new ClipperLib.Paths();
union_clipper.Execute(
ClipperLib.ClipType.ctUnion,
all_rooms_union,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero
);
// For each room, compute exterior-only wall segments.
for (var i = 0; i < group.rooms.length; i++) {
const room = group.rooms[i];
const abs_path = absolute_paths[i];
// Offset this room's polygon outward by wall_thickness.
const clipper_offset = new ClipperLib.ClipperOffset();
clipper_offset.AddPath(
abs_path,
ClipperLib.JoinType.jtSquare,
ClipperLib.EndType.etClosedPolygon
);
const outer = new ClipperLib.Paths();
clipper_offset.Execute(outer, wall_thickness);
// Subtract the all-rooms union from the outer offset.
// What remains is only exterior wall segments for this room.
const diff_clipper = new ClipperLib.Clipper();
outer.forEach(function(path) {
diff_clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true);
});
all_rooms_union.forEach(function(path) {
diff_clipper.AddPath(path, ClipperLib.PolyType.ptClip, true);
});
const wall_paths = new ClipperLib.Paths();
diff_clipper.Execute(
ClipperLib.ClipType.ctDifference,
wall_paths,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero
);
if (wall_paths.length === 0) {
continue;
}
const wall_height = room.height || group.height || 96;
const elevation = room.elevation || group.elevation || 0;
// Separate paths into outer boundaries and holes based on area sign.
// Clipper returns CCW paths (positive area) as outers and CW paths
// (negative area) as holes.
const outers = [];
const hole_paths = [];
for (var j = 0; j < wall_paths.length; j++) {
const points = wall_paths[j];
if (points.length < 3) {
continue;
}
const area = ClipperLib.Clipper.Area(points);
if (Math.abs(area) < 1) {
continue;
}
if (area > 0) {
outers.push(points);
} else {
hole_paths.push(points);
}
}
// Create a mesh for each outer boundary, attaching any contained holes.
for (var j = 0; j < outers.length; j++) {
const outer_points = outers[j];
const shape = new THREE.Shape();
shape.moveTo(outer_points[0].x, outer_points[0].y);
for (var k = 1; k < outer_points.length; k++) {
shape.lineTo(outer_points[k].x, outer_points[k].y);
}
// Add holes that are inside this outer boundary.
for (var h = 0; h < hole_paths.length; h++) {
if (
ClipperLib.Clipper.PointInPolygon(
hole_paths[h][0],
outer_points
) !== 0
) {
const hole = new THREE.Path();
hole.moveTo(hole_paths[h][0].x, hole_paths[h][0].y);
for (var m = 1; m < hole_paths[h].length; m++) {
hole.lineTo(hole_paths[h][m].x, hole_paths[h][m].y);
}
shape.holes.push(hole);
}
}
const geometry = new THREE.ExtrudeGeometry(
shape,
{
'depth': wall_height,
'bevelEnabled': false
}
);
const siding_color = this.get_appearance_value_('siding_color');
const material = new THREE.MeshStandardMaterial({
'color': siding_color,
'roughness': 0.7,
'metalness': 0.0
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = -wall_height - elevation;
mesh.userData.is_wall = true;
mesh.userData.group_id = group.group_id;
mesh.userData.wall_cuttable = true;
mesh.layers.set(beestat.component.scene.layer_visible);
mesh.castShadow = true;
mesh.receiveShadow = true;
layer.add(mesh);
}
}
};
/**
* Build an opening cutter mesh for CSG subtraction.
*
* @param {object} group The floor plan group.
* @param {object} opening The opening.
* @return {?THREE.Mesh} Opening cutter mesh or null if opening is not cuttable.
*/
beestat.component.scene.prototype.build_opening_cutter_mesh_ = function(group, opening) {
if (opening.editor_hidden === true) {
return null;
}
const width = Math.max(12, Number(opening.width || 0));
const height = Math.max(1, Number(opening.height || 0));
const wall_thickness = Number(beestat.component.scene.wall_thickness || 4);
const depth = Math.max(0.5, wall_thickness);
const elevation = Number(group.elevation || 0);
if (this.csg_cutter_material_ === undefined) {
this.csg_cutter_material_ = new THREE.MeshBasicMaterial({
'visible': false
});
}
const geometry = new THREE.BoxGeometry(width, depth, height);
const cutter = new THREE.Mesh(geometry, this.csg_cutter_material_);
cutter.position.set(
Number(opening.x || 0),
Number(opening.y || 0),
-elevation - (height / 2)
);
cutter.updateMatrix();
cutter.updateMatrixWorld(true);
return cutter;
};
/**
* Add a debug wireframe for an opening cutter.
*
* @param {THREE.Group} layer The debug layer.
* @param {THREE.Mesh} cutter The cutter mesh.
*/
beestat.component.scene.prototype.add_opening_cutter_debug_ = function(layer, cutter) {
const edges_geometry = new THREE.EdgesGeometry(cutter.geometry);
const wireframe = new THREE.LineSegments(
edges_geometry,
new THREE.LineBasicMaterial({
'color': 0xff7700
})
);
wireframe.position.copy(cutter.position);
wireframe.rotation.copy(cutter.rotation);
wireframe.scale.copy(cutter.scale);
wireframe.layers.set(beestat.component.scene.layer_visible);
layer.add(wireframe);
};
/**
* Subtract opening cutters from wall meshes.
*
* @param {THREE.Group} walls_layer The wall mesh layer.
* @param {object} floor_plan The floor plan data.
* @param {THREE.Group=} opening_cutter_debug_layer Optional debug cutter layer.
*/
beestat.component.scene.prototype.apply_opening_cuts_ = function(
walls_layer,
floor_plan,
opening_cutter_debug_layer
) {
if (window.CSG === undefined || typeof window.CSG.subtract !== 'function') {
return;
}
const wall_meshes = walls_layer.children.filter(function(child) {
return (
child !== undefined &&
child.type === 'Mesh' &&
child.userData !== undefined &&
child.userData.wall_cuttable === true
);
});
floor_plan.data.groups.forEach(function(group) {
const openings = group.openings || [];
if (openings.length === 0) {
return;
}
const group_wall_meshes = wall_meshes.filter(function(mesh) {
return mesh.userData.group_id === group.group_id;
});
if (group_wall_meshes.length === 0) {
return;
}
openings.forEach((opening) => {
const cutter = this.build_opening_cutter_mesh_(group, opening);
if (cutter === null) {
return;
}
if (opening_cutter_debug_layer !== undefined) {
this.add_opening_cutter_debug_(opening_cutter_debug_layer, cutter);
}
const cutter_box = new THREE.Box3().setFromObject(cutter);
group_wall_meshes.forEach(function(wall_mesh) {
const wall_box = new THREE.Box3().setFromObject(wall_mesh);
if (wall_box.intersectsBox(cutter_box) !== true) {
return;
}
try {
wall_mesh.updateMatrix();
wall_mesh.updateMatrixWorld(true);
const result_mesh = window.CSG.subtract(wall_mesh, cutter);
if (
result_mesh === undefined ||
result_mesh.geometry === undefined ||
result_mesh.geometry.attributes === undefined ||
result_mesh.geometry.attributes.position === undefined ||
result_mesh.geometry.attributes.position.count === 0
) {
return;
}
result_mesh.geometry.computeBoundingBox();
result_mesh.geometry.computeBoundingSphere();
result_mesh.geometry.computeVertexNormals();
const old_geometry = wall_mesh.geometry;
wall_mesh.geometry = result_mesh.geometry;
wall_mesh.castShadow = true;
wall_mesh.receiveShadow = true;
wall_mesh.layers.set(beestat.component.scene.layer_visible);
wall_mesh.updateMatrix();
wall_mesh.updateMatrixWorld(true);
if (old_geometry !== undefined) {
old_geometry.dispose();
}
} catch (error) {
// Keep original wall mesh if CSG subtraction fails.
}
});
cutter.geometry.dispose();
});
}, this);
};
/**
* Add red wireframe boxes to visualize opening placement in 3D.
*
* @param {THREE.Group} layer The layer to add opening debug to.
* @param {object} group The floor plan group.
*/
beestat.component.scene.prototype.add_openings_debug_ = function(layer, group) {
if (group.openings === undefined || group.openings.length === 0) {
return;
}
const wall_thickness = beestat.component.scene.wall_thickness;
group.openings.forEach(function(opening) {
if (opening.editor_hidden === true) {
return;
}
const width = Math.max(12, Number(opening.width || 0));
const height = Math.max(1, Number(opening.height || 0));
const elevation = group.elevation || 0;
const geometry = new THREE.BoxGeometry(
width,
wall_thickness,
height
);
const edges_geometry = new THREE.EdgesGeometry(geometry);
const wireframe = new THREE.LineSegments(
edges_geometry,
new THREE.LineBasicMaterial({
'color': 0xff0000
})
);
wireframe.position.x = Number(opening.x || 0);
wireframe.position.y = Number(opening.y || 0);
wireframe.position.z = -elevation - (height / 2);
wireframe.layers.set(beestat.component.scene.layer_visible);
layer.add(wireframe);
});
};
/**
* Add a helpful debug window that can be refreshed with the contents of
* this.debug_info_.
*
* @param {rocket.Elements} parent
*/
beestat.component.scene.prototype.add_debug_ = function(parent) {
if (this.debug_.watcher === true) {
this.debug_info_ = {};
this.debug_container_ = $.createElement('div').style({
'position': 'absolute',
'top': (beestat.style.size.gutter / 2),
'left': (beestat.style.size.gutter / 2),
'padding': (beestat.style.size.gutter / 2),
'background': 'rgba(0, 0, 0, 0.5)',
'color': '#fff',
'font-family': 'Consolas, Courier, Monospace',
'white-space': 'pre'
});
parent.appendChild(this.debug_container_);
}
};
/**
* Update the debug window.
*/
beestat.component.scene.prototype.update_debug_ = function() {
if (this.debug_.watcher === true) {
this.debug_container_.innerHTML(
JSON.stringify(this.debug_info_, null, 2)
);
}
};
/**
* Get a finite bounding box for scene layout. Empty floor plans can report
* Infinity bounds; clamp those to a reasonable fallback around origin.
*
* @return {{left:number,right:number,top:number,bottom:number,width:number,height:number,x:number,y:number}}
*/
beestat.component.scene.prototype.get_scene_bounding_box_ = function() {
const bounding_box = beestat.floor_plan.get_bounding_box(this.floor_plan_id_);
const is_finite_box =
Number.isFinite(bounding_box.left) &&
Number.isFinite(bounding_box.right) &&
Number.isFinite(bounding_box.top) &&
Number.isFinite(bounding_box.bottom);
if (is_finite_box === true) {
return bounding_box;
}
const fallback_half_size = 180;
return {
'left': -fallback_half_size,
'right': fallback_half_size,
'top': -fallback_half_size,
'bottom': fallback_half_size,
'width': fallback_half_size * 2,
'height': fallback_half_size * 2,
'x': -fallback_half_size,
'y': -fallback_half_size
};
};
/**
* Add a group containing all of the extruded geometry that can be transformed
* all together.
*/
beestat.component.scene.prototype.add_main_group_ = function() {
const bounding_box = this.get_scene_bounding_box_();
// Main group handles orientation and centering
this.main_group_ = new THREE.Group();
// Center the floor plan at origin (accounting for bounding box offset)
this.main_group_.position.set(
(bounding_box.right + bounding_box.left) / -2,
0,
(bounding_box.bottom + bounding_box.top) / -2
);
// Apply X rotation to orient the floor plan
this.main_group_.rotation.x = Math.PI / 2;
this.scene_.add(this.main_group_);
};
/**
* Add the floor plan to the scene.
*/
beestat.component.scene.prototype.add_floor_plan_ = function() {
const self = this;
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
// Initialize layers if not already done
if (this.layers_ === undefined) {
this.layers_ = {};
}
// Create floor plan group for walls, rooms, and roofs
this.floor_plan_group_ = new THREE.Group();
this.main_group_.add(this.floor_plan_group_);
this.layers_['floor_plan'] = this.floor_plan_group_;
const walls_layer = new THREE.Group();
self.floor_plan_group_.add(walls_layer);
self.layers_['walls'] = walls_layer;
floor_plan.data.groups.forEach(function(group) {
const layer = new THREE.Group();
self.floor_plan_group_.add(layer);
self.layers_[group.group_id] = layer;
group.rooms.forEach(function(room) {
self.add_room_(layer, group, room);
});
self.add_walls_(walls_layer, group);
});
let opening_cutter_debug_layer;
if (this.debug_.opening_cutters === true) {
opening_cutter_debug_layer = new THREE.Group();
this.floor_plan_group_.add(opening_cutter_debug_layer);
this.layers_['opening_cutters_debug'] = opening_cutter_debug_layer;
}
this.apply_opening_cuts_(
walls_layer,
floor_plan,
opening_cutter_debug_layer
);
if (this.debug_.openings === true) {
const openings_debug_layer = new THREE.Group();
this.floor_plan_group_.add(openings_debug_layer);
this.layers_['openings_debug'] = openings_debug_layer;
floor_plan.data.groups.forEach(function(group) {
self.add_openings_debug_(openings_debug_layer, group);
});
}
// Add roofs using straight skeleton
this.add_roofs_();
if (this.debug_.roof_edges) {
this.add_roof_outline_debug_();
}
if (this.debug_.straight_skeleton) {
this.add_roof_skeleton_debug_();
}
this.add_environment_();
};
/**
* Get the ceiling Z-position for a room.
*
* @param {object} group The floor plan group
* @param {object} room The room
*
* @return {number} The ceiling Z position
*/
beestat.component.scene.prototype.get_ceiling_z_ = function(group, room) {
const elevation = room.elevation || group.elevation || 0;
const height = room.height || group.height || 96;
return -(elevation + height);
};
/**
* Convert room.points (relative coordinates) to absolute coordinates.
*
* @param {object} room The room
*
* @return {Array} Array of absolute coordinate points {x, y}
*/
beestat.component.scene.prototype.convert_room_to_absolute_polygon_ = function(room) {
const absolute = [];
room.points.forEach(function(point) {
absolute.push({
'x': room.x + point.x,
'y': room.y + point.y
});
});
return absolute;
};
/**
* Compute which ceiling areas are exposed (not covered by floors above).
*
* @param {object} floor_plan The floor plan
*
* @return {Array} Array of {ceiling_z, polygons[]} for roof outline rendering
*/
beestat.component.scene.prototype.compute_exposed_ceiling_areas_ = function(floor_plan) {
const self = this;
// Step 1: Group ceilings by Z-level
const ceiling_levels = {}; // Key: ceiling_z, Value: array of room polygons
floor_plan.data.groups.forEach(function(group) {
group.rooms.forEach(function(room) {
const elevation = room.elevation || group.elevation || 0;
// Skip basements (below ground)
if (elevation < 0) {
return;
}
const ceiling_z = self.get_ceiling_z_(group, room);
if (!ceiling_levels[ceiling_z]) {
ceiling_levels[ceiling_z] = [];
}
ceiling_levels[ceiling_z].push(
self.convert_room_to_absolute_polygon_(room)
);
});
});
// Step 2: Sort ceiling levels (ascending Z = highest to lowest)
const sorted_levels = Object.keys(ceiling_levels)
.map(z => parseFloat(z))
.sort((a, b) => a - b);
const exposed_areas = [];
// Step 3: For each level, compute exposed area
sorted_levels.forEach(function(current_ceiling_z, index) {
const current_polygons = ceiling_levels[current_ceiling_z];
// Union all rooms at this level
const union_clipper = new ClipperLib.Clipper();
current_polygons.forEach(function(polygon) {
union_clipper.AddPath(polygon, ClipperLib.PolyType.ptSubject, true);
});
const ceiling_area = new ClipperLib.Paths();
union_clipper.Execute(
ClipperLib.ClipType.ctUnion,
ceiling_area,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero
);
// Compute occlusion from all higher levels
const occlusion_clipper = new ClipperLib.Clipper();
let has_occlusion = false;
for (let i = 0; i < index; i++) {
const above_ceiling_z = sorted_levels[i];
const above_polygons = ceiling_levels[above_ceiling_z];
above_polygons.forEach(function(polygon) {
occlusion_clipper.AddPath(polygon, ClipperLib.PolyType.ptSubject, true);
has_occlusion = true;
});
}
let exposed;
if (!has_occlusion) {
// Top floor - no occlusion, entire ceiling is exposed
exposed = ceiling_area;
} else {
// Compute union of all occlusion polygons
const occlusion_area = new ClipperLib.Paths();
occlusion_clipper.Execute(
ClipperLib.ClipType.ctUnion,
occlusion_area,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero
);
// Subtract occlusion from ceiling
const diff_clipper = new ClipperLib.Clipper();
ceiling_area.forEach(function(path) {
diff_clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true);
});
occlusion_area.forEach(function(path) {
diff_clipper.AddPath(path, ClipperLib.PolyType.ptClip, true);
});
exposed = new ClipperLib.Paths();
diff_clipper.Execute(
ClipperLib.ClipType.ctDifference,
exposed,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero
);
}
// Filter out tiny polygons (floating-point artifacts)
const filtered = exposed.filter(function(path) {
return Math.abs(ClipperLib.Clipper.Area(path)) > 1;
});
if (filtered.length > 0) {
exposed_areas.push({
'ceiling_z': current_ceiling_z,
'polygons': filtered
});
}
});
return exposed_areas;
};
/**
* Generate 3D roofs using straight skeleton algorithm.
* Creates sloped roof surfaces with proper ridge lines and hip/valley geometry.
*/
/**
* Add roofs to the scene based on the configured roof style.
*/
beestat.component.scene.prototype.add_roofs_ = function() {
const skeleton_builder = this.get_skeleton_builder_();
const roof_style = this.get_appearance_value_('roof_style');
if (roof_style === 'flat') {
this.add_flat_roofs_();
} else if (roof_style === 'hip' && skeleton_builder !== undefined) {
this.add_hip_roofs_(skeleton_builder);
} else {
if (roof_style === 'hip') {
this.listen_for_skeleton_builder_ready_();
}
this.add_flat_roofs_();
}
};
/**
* Add hip roofs using the straight skeleton algorithm.
*
* @param {object} skeleton_builder
*/
beestat.component.scene.prototype.add_hip_roofs_ = function(skeleton_builder) {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan);
const roof_color = this.get_appearance_value_('roof_color');
// Create layer for roofs
const roofs_layer = new THREE.Group();
this.floor_plan_group_.add(roofs_layer);
this.layers_['roof'] = roofs_layer;
const roof_pitch = beestat.component.scene.roof_pitch;
// Process each exposed area
exposed_areas.forEach(function(area) {
area.polygons.forEach(function(polygon) {
if (polygon.length < 3) {
return;
}
try {
// Simplify polygon to handle complex shapes
const simplified = ClipperLib.Clipper.SimplifyPolygon(
polygon,
ClipperLib.PolyFillType.pftNonZero
);
simplified.forEach(function(simple_polygon) {
if (simple_polygon.length < 3) {
return;
}
// Add roof overhang by offsetting polygon outward
const roof_overhang = beestat.component.scene.roof_overhang;
const clipper_offset = new ClipperLib.ClipperOffset();
clipper_offset.AddPath(
simple_polygon,
ClipperLib.JoinType.jtMiter,
ClipperLib.EndType.etClosedPolygon
);
const offset_polygons = new ClipperLib.Paths();
clipper_offset.Execute(offset_polygons, roof_overhang);
// Use the offset polygon if successful, otherwise use original
const roof_polygon = (offset_polygons.length > 0) ? offset_polygons[0] : simple_polygon;
// Add a thin base skirt under the hip roof to give the edge subtle thickness.
const base_shape = new THREE.Shape();
base_shape.moveTo(roof_polygon[0].x, roof_polygon[0].y);
for (let i = 1; i < roof_polygon.length; i++) {
base_shape.lineTo(roof_polygon[i].x, roof_polygon[i].y);
}
base_shape.closePath();
const hip_roof_base_thickness = 4;
const base_geometry = new THREE.ExtrudeGeometry(base_shape, {
'depth': hip_roof_base_thickness,
'bevelEnabled': false
});
const base_material = new THREE.MeshStandardMaterial({
'color': roof_color,
'side': THREE.DoubleSide,
'flatShading': false,
'roughness': 0.85,
'metalness': 0.0
});
const base_mesh = new THREE.Mesh(base_geometry, base_material);
// Nudge downward so the top cap doesn't z-fight with hip roof faces.
base_mesh.position.z = area.ceiling_z + 0.5;
base_mesh.userData.is_roof = true;
base_mesh.layers.set(beestat.component.scene.layer_visible);
base_mesh.castShadow = true;
base_mesh.receiveShadow = true;
roofs_layer.add(base_mesh);
// Convert to skeleton format
const ring = roof_polygon.map(function(point) {
return [point.x, point.y];
});
ring.push([roof_polygon[0].x, roof_polygon[0].y]);
const coordinates = [ring];
const result = skeleton_builder.buildFromPolygon(coordinates);
if (!result) {
return;
}
// Identify boundary vertices (first N vertices match input polygon)
const boundary_vertex_count = roof_polygon.length;
const boundary_set = new Set();
for (let i = 0; i < boundary_vertex_count; i++) {
boundary_set.add(i);
}
// Helper function to compute distance from point to polygon boundary
const compute_distance_to_boundary = function(point_x, point_y) {
let min_distance = Infinity;
for (let i = 0; i < roof_polygon.length; i++) {
const p1 = roof_polygon[i];
const p2 = roof_polygon[(i + 1) % roof_polygon.length];
// Calculate perpendicular distance from point to line segment
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const length_sq = dx * dx + dy * dy;
if (length_sq === 0) {
// Point to point distance
const dist = Math.sqrt(
Math.pow(point_x - p1.x, 2) + Math.pow(point_y - p1.y, 2)
);
min_distance = Math.min(min_distance, dist);
continue;
}
// Project point onto line segment
let t = ((point_x - p1.x) * dx + (point_y - p1.y) * dy) / length_sq;
t = Math.max(0, Math.min(1, t));
const closest_x = p1.x + t * dx;
const closest_y = p1.y + t * dy;
const dist = Math.sqrt(
Math.pow(point_x - closest_x, 2) + Math.pow(point_y - closest_y, 2)
);
min_distance = Math.min(min_distance, dist);
}
return min_distance;
};
// Create 3D vertices with heights based on distance from boundary
const vertices_3d = result.vertices.map(function(vertex, index) {
const is_boundary = boundary_set.has(index);
let height = 0;
if (!is_boundary) {
// Interior skeleton vertex - raise it based on distance to boundary
const distance = compute_distance_to_boundary(vertex[0], vertex[1]);
height = distance * roof_pitch;
}
return new THREE.Vector3(
vertex[0],
vertex[1],
area.ceiling_z - height // Negative Z = higher in world coords
);
});
// Create geometry from skeleton polygons
result.polygons.forEach(function(face) {
if (face.length < 3) {
return;
}
// Create triangulated mesh for this face
const face_vertices = face.map(function(idx) {
return vertices_3d[idx];
});
// Triangulate the face (simple fan triangulation from first vertex)
const triangles = [];
for (let i = 1; i < face_vertices.length - 1; i++) {
triangles.push(
face_vertices[0],
face_vertices[i],
face_vertices[i + 1]
);
}
// Create geometry
const geometry = new THREE.BufferGeometry().setFromPoints(triangles);
geometry.computeVertexNormals();
// Create material - use appearance roof color
const material = new THREE.MeshStandardMaterial({
'color': roof_color,
'side': THREE.DoubleSide,
'flatShading': false,
'roughness': 0.8,
'metalness': 0.0
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.is_roof = true;
mesh.layers.set(beestat.component.scene.layer_visible);
mesh.castShadow = true;
mesh.receiveShadow = true;
roofs_layer.add(mesh);
});
});
} catch (error) {
console.error('Error generating roof:', error, polygon);
}
});
});
};
/**
* Animate weather particles (snow/rain) each frame.
*/
beestat.component.scene.prototype.update_weather_ = function() {
const now_ms = window.performance.now();
if (this.weather_last_update_ms_ === undefined) {
this.weather_last_update_ms_ = now_ms;
return;
}
const delta_seconds = Math.min(0.05, (now_ms - this.weather_last_update_ms_) / 1000);
this.weather_last_update_ms_ = now_ms;
if (delta_seconds <= 0) {
return;
}
if (this.weather_profile_target_ === undefined) {
this.update_weather_targets_();
}
if (this.weather_transition_start_profile_ === undefined) {
this.weather_transition_start_profile_ = {
'cloud_count': this.current_cloud_count_ === undefined ? 0 : this.current_cloud_count_,
'rain_count': this.current_rain_count_ === undefined ? 0 : this.current_rain_count_,
'snow_count': this.current_snow_count_ === undefined ? 0 : this.current_snow_count_
};
}
if (this.weather_transition_start_ms_ === undefined) {
this.weather_transition_start_ms_ = now_ms;
}
const transition_duration_ms = Math.max(
1,
beestat.component.scene.weather_transition_seconds * 1000
);
const transition_t = Math.max(
0,
Math.min(
1,
(now_ms - this.weather_transition_start_ms_) / transition_duration_ms
)
);
const transition = function(start, target) {
return start + ((target - start) * transition_t);
};
this.current_cloud_count_ = transition(
this.weather_transition_start_profile_.cloud_count,
this.weather_profile_target_.cloud_count
);
this.current_rain_count_ = transition(
this.weather_transition_start_profile_.rain_count,
this.weather_profile_target_.rain_count
);
this.current_snow_count_ = transition(
this.weather_transition_start_profile_.snow_count,
this.weather_profile_target_.snow_count
);
if (this.cloud_sprites_ !== undefined && this.cloud_motion_ !== undefined) {
const now_seconds = now_ms / 1000;
const cloud_density = Math.max(
0,
Math.min(
1,
this.current_cloud_count_ / beestat.component.scene.weather_cloud_max_count
)
);
for (let i = 0; i < this.cloud_sprites_.length; i++) {
const sprite = this.cloud_sprites_[i];
const motion = this.cloud_motion_[i];
const phase = now_seconds * motion.pulse_speed + motion.phase;
// Shape/size breathing plus transition growth/shrink.
const scale_x_wobble = 1 + (Math.sin(phase) * motion.scale_wobble_x);
const scale_y_wobble = 1 + (Math.cos(phase * 0.87) * motion.scale_wobble_y);
const cloud_scale_transition = 0.72 + (0.28 * cloud_density);
sprite.scale.set(
motion.base_scale_x * scale_x_wobble * cloud_scale_transition,
motion.base_scale_y * scale_y_wobble * cloud_scale_transition,
1
);
// Subtle random-looking positional wiggle.
sprite.position.x = motion.base_x + Math.sin(phase * motion.wiggle_freq_x) * motion.wiggle_x;
sprite.position.y = motion.base_y + Math.cos(phase * motion.wiggle_freq_y) * motion.wiggle_y;
sprite.position.z = motion.base_z + Math.sin(phase * motion.wiggle_freq_z) * motion.wiggle_z;
// Slight opacity shifting.
if (sprite.material !== undefined) {
sprite.material.opacity = Math.max(
0,
Math.min(
1,
(motion.base_opacity + Math.sin(phase * 0.72) * motion.opacity_wobble) * cloud_density
)
);
}
}
}
this.update_precipitation_system_(this.rain_particles_, this.current_rain_count_, delta_seconds);
this.update_precipitation_system_(this.snow_particles_, this.current_snow_count_, delta_seconds);
this.update_snow_surface_colors_(this.get_snow_cover_blend_());
if (
this.date_ !== undefined &&
this.latitude_ !== undefined &&
this.longitude_ !== undefined &&
this.sun_light_ !== undefined &&
this.moon_light_ !== undefined
) {
this.update_celestial_lights_(this.date_, this.latitude_, this.longitude_);
}
};
/**
* Add flat roofs to the scene.
*/
beestat.component.scene.prototype.add_flat_roofs_ = function() {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan);
const roof_color = this.get_appearance_value_('roof_color');
// Create layer for roofs
const roofs_layer = new THREE.Group();
this.floor_plan_group_.add(roofs_layer);
this.layers_['roof'] = roofs_layer;
// Process each exposed area
exposed_areas.forEach(function(area) {
area.polygons.forEach(function(polygon) {
if (polygon.length < 3) {
return;
}
try {
// Simplify polygon to handle complex shapes
const simplified = ClipperLib.Clipper.SimplifyPolygon(
polygon,
ClipperLib.PolyFillType.pftNonZero
);
simplified.forEach(function(simple_polygon) {
if (simple_polygon.length < 3) {
return;
}
// Add roof overhang by offsetting polygon outward
const roof_overhang = beestat.component.scene.roof_overhang;
const clipper_offset = new ClipperLib.ClipperOffset();
clipper_offset.AddPath(
simple_polygon,
ClipperLib.JoinType.jtMiter,
ClipperLib.EndType.etClosedPolygon
);
const offset_polygons = new ClipperLib.Paths();
clipper_offset.Execute(offset_polygons, roof_overhang);
// Use the offset polygon if successful, otherwise use original
const roof_polygon = (offset_polygons.length > 0) ? offset_polygons[0] : simple_polygon;
// Create flat roof shape
const shape = new THREE.Shape();
shape.moveTo(roof_polygon[0].x, roof_polygon[0].y);
for (let i = 1; i < roof_polygon.length; i++) {
shape.lineTo(roof_polygon[i].x, roof_polygon[i].y);
}
shape.closePath();
// Create extruded geometry to give flat roof some depth
const flat_roof_depth = 6; // 6 inches of depth
const geometry = new THREE.ExtrudeGeometry(shape, {
'depth': flat_roof_depth,
'bevelEnabled': false
});
// Create material - use appearance roof color
const material = new THREE.MeshStandardMaterial({
'color': roof_color,
'side': THREE.DoubleSide,
'flatShading': false,
'roughness': 0.9, // Slightly higher roughness for flat roofs
'metalness': 0.0
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = area.ceiling_z - flat_roof_depth; // Position so top is at ceiling level
mesh.userData.is_roof = true;
mesh.layers.set(beestat.component.scene.layer_visible);
mesh.castShadow = true;
mesh.receiveShadow = true;
roofs_layer.add(mesh);
});
} catch (error) {
console.error('Error generating flat roof:', error, polygon);
}
});
});
};
/**
* Add red outline visualization for exposed ceiling areas (future roof locations).
*/
beestat.component.scene.prototype.add_roof_outline_debug_ = function() {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan);
// Create layer for roof outlines
const roof_outlines_layer = new THREE.Group();
this.floor_plan_group_.add(roof_outlines_layer);
this.layers_['roof_outlines'] = roof_outlines_layer;
// Render each exposed area as red outline
exposed_areas.forEach(function(area) {
area.polygons.forEach(function(polygon) {
if (polygon.length < 3) {
return;
}
// Create line points
const points = [];
polygon.forEach(function(point) {
points.push(new THREE.Vector3(point.x, point.y, area.ceiling_z));
});
// Close the loop
points.push(new THREE.Vector3(polygon[0].x, polygon[0].y, area.ceiling_z));
// Create red line
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
'color': 0xff0000, // Red
'linewidth': 2
});
const line = new THREE.Line(geometry, material);
line.layers.set(beestat.component.scene.layer_visible);
roof_outlines_layer.add(line);
});
});
};
/**
* Visualize the straight skeleton for each roof polygon with debug lines.
*/
beestat.component.scene.prototype.add_roof_skeleton_debug_ = function() {
const skeleton_builder = this.get_skeleton_builder_();
if (skeleton_builder === undefined) {
return;
}
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan);
// Create layer for skeleton debug lines
const skeleton_debug_layer = new THREE.Group();
this.floor_plan_group_.add(skeleton_debug_layer);
this.layers_['roof_skeleton_debug'] = skeleton_debug_layer;
let total_polygons = 0;
let successful_skeletons = 0;
// Process each exposed area
exposed_areas.forEach(function(area) {
area.polygons.forEach(function(polygon) {
if (polygon.length < 3) {
return;
}
total_polygons++;
try {
// Simplify polygon to remove self-intersections and clean up topology
// This splits complex polygons (L-shapes, T-shapes) into simpler ones
const simplified = ClipperLib.Clipper.SimplifyPolygon(
polygon,
ClipperLib.PolyFillType.pftNonZero
);
// SimplifyPolygon can return multiple polygons if the original was self-intersecting
simplified.forEach(function(simple_polygon) {
if (simple_polygon.length < 3) {
return;
}
// Convert ClipperLib format {x, y} to SkeletonBuilder format [[x, y], ...]
const ring = simple_polygon.map(function(point) {
return [point.x, point.y];
});
// Close the ring by repeating the first point
ring.push([simple_polygon[0].x, simple_polygon[0].y]);
// Build the straight skeleton
const coordinates = [ring]; // Array of rings (outer ring only, no holes)
const result = skeleton_builder.buildFromPolygon(coordinates);
if (!result) {
return;
}
successful_skeletons++;
// Visualize each skeleton polygon face with blue lines
result.polygons.forEach(function(face) {
if (face.length < 2) {
return;
}
// Create line points from the face vertices
const points = [];
face.forEach(function(vertex_index) {
const vertex = result.vertices[vertex_index];
points.push(new THREE.Vector3(vertex[0], vertex[1], area.ceiling_z));
});
// Close the loop
const first_vertex = result.vertices[face[0]];
points.push(new THREE.Vector3(first_vertex[0], first_vertex[1], area.ceiling_z));
// Create blue line for skeleton edges
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
'color': 0x00ffff, // Cyan
'linewidth': 1
});
const line = new THREE.Line(geometry, material);
line.layers.set(beestat.component.scene.layer_visible);
skeleton_debug_layer.add(line);
});
}); // End simplified.forEach
} catch (error) {
console.error('Error building skeleton for polygon:', error, polygon);
}
});
});
};
/**
* Get the straight-skeleton runtime when it has finished initializing.
*
* @return {object|undefined}
*/
beestat.component.scene.prototype.get_skeleton_builder_ = function() {
if (window.SkeletonBuilderInitialized === true) {
return window.SkeletonBuilder;
}
return undefined;
};
/**
* If the skeleton runtime is still loading, listen for readiness and rerender
* once so hip roofs replace fallback flat roofs.
*/
beestat.component.scene.prototype.listen_for_skeleton_builder_ready_ = function() {
const self = this;
if (this.skeleton_builder_ready_handler_ !== undefined) {
return;
}
this.skeleton_builder_ready_handler_ = function() {
if (self.skeleton_builder_ready_handler_ !== undefined) {
window.removeEventListener('skeleton_builder_ready', self.skeleton_builder_ready_handler_);
delete self.skeleton_builder_ready_handler_;
}
if (self.rendered_ === true) {
self.rerender();
}
};
window.addEventListener('skeleton_builder_ready', this.skeleton_builder_ready_handler_);
};
/**
* Create a precipitation particle system with static particle properties.
*
* @param {object} bounds
* @param {number} max_count
* @param {object} config
*
* @return {object}
*/
beestat.component.scene.prototype.create_precipitation_system_ = function(bounds, max_count, config) {
const positions = new Float32Array(max_count * 3);
const speeds = new Float32Array(max_count);
const drift_x = new Float32Array(max_count);
const drift_y = new Float32Array(max_count);
const span_x = bounds.max_x - bounds.min_x;
const span_y = bounds.max_y - bounds.min_y;
const span_z = bounds.max_z - bounds.min_z;
for (let i = 0; i < max_count; i++) {
const offset = i * 3;
positions[offset] = bounds.min_x + Math.random() * span_x;
positions[offset + 1] = bounds.min_y + Math.random() * span_y;
positions[offset + 2] = bounds.min_z + Math.random() * span_z;
speeds[i] = config.speed_min + Math.random() * (config.speed_max - config.speed_min);
drift_x[i] = (Math.random() - 0.5) * config.drift;
drift_y[i] = (Math.random() - 0.5) * config.drift;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setDrawRange(0, 0);
const material = new THREE.PointsMaterial({
'size': config.size,
'color': config.color,
'transparent': true,
'opacity': 0,
'depthWrite': false,
'blending': THREE.NormalBlending,
'map': config.texture
});
const points = new THREE.Points(geometry, material);
points.layers.set(beestat.component.scene.layer_visible);
points.userData.is_environment = true;
return {
'points': points,
'bounds': bounds,
'speeds': speeds,
'drift_x': drift_x,
'drift_y': drift_y,
'max_count': max_count,
'target_opacity': config.opacity
};
};
/**
* Update a precipitation system by particle volume only.
*
* @param {object} precipitation
* @param {number} target_count
* @param {number} delta_seconds
*/
beestat.component.scene.prototype.update_precipitation_system_ = function(precipitation, target_count, delta_seconds) {
if (
precipitation === undefined ||
precipitation.points === undefined ||
precipitation.points.geometry === undefined ||
precipitation.points.material === undefined
) {
return;
}
const clamped_count = Math.max(
0,
Math.min(precipitation.max_count, Math.round(target_count))
);
precipitation.points.geometry.setDrawRange(0, clamped_count);
if (precipitation.max_count > 0) {
precipitation.points.material.opacity =
precipitation.target_opacity * (clamped_count / precipitation.max_count);
} else {
precipitation.points.material.opacity = 0;
}
if (clamped_count === 0) {
return;
}
const bounds = precipitation.bounds;
const span_x = bounds.max_x - bounds.min_x;
const span_y = bounds.max_y - bounds.min_y;
const span_z = bounds.max_z - bounds.min_z;
const positions = precipitation.points.geometry.attributes.position.array;
for (let i = 0; i < clamped_count; i++) {
const offset = i * 3;
positions[offset + 2] += precipitation.speeds[i] * delta_seconds;
positions[offset] += precipitation.drift_x[i] * delta_seconds;
positions[offset + 1] += precipitation.drift_y[i] * delta_seconds;
if (
positions[offset] < bounds.min_x ||
positions[offset] > bounds.max_x ||
positions[offset + 1] < bounds.min_y ||
positions[offset + 1] > bounds.max_y ||
positions[offset + 2] > bounds.max_z
) {
positions[offset] = bounds.min_x + Math.random() * span_x;
positions[offset + 1] = bounds.min_y + Math.random() * span_y;
positions[offset + 2] = bounds.min_z + Math.random() * span_z;
}
}
precipitation.points.geometry.attributes.position.needsUpdate = true;
};
/**
* Build a radial alpha texture used for soft tree-ground contact decals.
*
* @return {?THREE.CanvasTexture}
*/
beestat.component.scene.prototype.create_tree_ground_contact_texture_ = function() {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context === null) {
return null;
}
const gradient = context.createRadialGradient(
size / 2,
size / 2,
size * 0.06,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.48)');
gradient.addColorStop(0.45, 'rgba(0, 0, 0, 0.2)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
context.clearRect(0, 0, size, size);
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = true;
texture.needsUpdate = true;
return texture;
};
/**
* Get shared material for soft trunk-to-ground blending.
*
* @return {THREE.MeshBasicMaterial}
*/
beestat.component.scene.prototype.get_tree_ground_contact_material_ = function() {
if (this.tree_ground_contact_material_ !== undefined) {
return this.tree_ground_contact_material_;
}
const texture = this.create_tree_ground_contact_texture_();
this.tree_ground_contact_material_ = new THREE.MeshBasicMaterial({
'color': 0x1a1208,
'map': texture,
'transparent': true,
'opacity': 0.3,
'depthWrite': false,
'polygonOffset': true,
'polygonOffsetFactor': -1,
'polygonOffsetUnits': -2,
'side': THREE.DoubleSide
});
return this.tree_ground_contact_material_;
};
/**
* Add stylized root collar + soft contact shadow to blend tree base into terrain.
*
* @param {THREE.Group} tree
* @param {number} trunk_radius
* @param {number} trunk_color
*/
beestat.component.scene.prototype.add_tree_ground_contact_ = function(tree, trunk_radius, trunk_color) {
const base_radius = Math.max(0.8, trunk_radius);
const collar_height = Math.max(1.8, base_radius * 0.8);
const collar_geometry = new THREE.CylinderGeometry(
Math.max(1.1, base_radius * 1.5),
Math.max(1.6, base_radius * 2.2),
collar_height,
7
);
collar_geometry.rotateX(-Math.PI / 2);
const collar_color = new THREE.Color(trunk_color);
collar_color.multiplyScalar(0.84 + (Math.random() * 0.08));
const collar = new THREE.Mesh(
collar_geometry,
new THREE.MeshStandardMaterial({
'color': collar_color,
'roughness': 1.0,
'metalness': 0.0
})
);
collar.position.z = (collar_height / 2) - (Math.max(0.2, base_radius * 0.08));
collar.rotation.z = Math.random() * Math.PI * 2;
collar.castShadow = true;
collar.receiveShadow = true;
collar.userData.is_environment = true;
tree.add(collar);
const contact_radius = Math.max(2, base_radius * 2.05);
const contact_geometry = new THREE.CircleGeometry(contact_radius, 14);
const contact = new THREE.Mesh(
contact_geometry,
this.get_tree_ground_contact_material_()
);
contact.position.z = 0.06;
contact.castShadow = false;
contact.receiveShadow = false;
contact.userData.is_environment = true;
tree.add(contact);
};
/**
* Create a low-poly conical tree with slight procedural variation.
*
* @param {number} height Total tree height.
* @param {number} max_diameter Maximum foliage diameter.
* @param {boolean} has_foliage Whether foliage should be rendered.
*
* @return {THREE.Group}
*/
beestat.component.scene.prototype.create_conical_tree_ = function(height, max_diameter, has_foliage) {
const clamped_height = Math.max(40, height || 120);
const clamped_diameter = Math.max(18, max_diameter || 48);
const tree = new THREE.Group();
tree.userData.is_environment = true;
tree.userData.is_tree = true;
const trunk_height_ratio = 0.2 + (Math.random() * 0.08);
const trunk_height = clamped_height * trunk_height_ratio;
const trunk_radius_top = Math.max(1.2, clamped_diameter * (0.045 + (Math.random() * 0.015)));
const trunk_radius_bottom = trunk_radius_top * (1.25 + (Math.random() * 0.2));
const trunk_geometry = new THREE.CylinderGeometry(
trunk_radius_top,
trunk_radius_bottom,
trunk_height,
6
);
trunk_geometry.rotateX(-Math.PI / 2);
const trunk_material = new THREE.MeshStandardMaterial({
'color': 0x5d4226,
'roughness': 0.9,
'metalness': 0.0
});
const trunk = new THREE.Mesh(trunk_geometry, trunk_material);
trunk.position.z = -(trunk_height / 2) + Math.max(0.6, trunk_radius_bottom * 0.1);
trunk.castShadow = true;
trunk.receiveShadow = true;
trunk.userData.is_environment = true;
tree.add(trunk);
this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x5d4226);
if (has_foliage === false) {
return tree;
}
const clamp01 = function(value) {
return Math.max(0, Math.min(1, value));
};
const crown_height_target = Math.max(10, clamped_height - trunk_height);
const base_foliage_color = new THREE.Color(0x2f7d2d);
const base_hsl = {};
base_foliage_color.getHSL(base_hsl);
const tree_foliage_color = new THREE.Color().setHSL(
clamp01(base_hsl.h + ((Math.random() - 0.5) * 0.03)),
clamp01(base_hsl.s + ((Math.random() - 0.5) * 0.08)),
clamp01(base_hsl.l + ((Math.random() - 0.5) * 0.08))
);
const foliage_material = new THREE.MeshStandardMaterial({
'color': tree_foliage_color,
'roughness': 0.85,
'metalness': 0.0,
'flatShading': true
});
const max_tilt_radians = Math.PI * 0.02;
const max_segments = 10;
let previous_apex_height = null;
let previous_radius = null;
let previous_segment_height = null;
for (let i = 0; i < max_segments; i++) {
let segment_height;
let segment_base_height;
if (i === 0) {
segment_height = crown_height_target * (0.34 + (Math.random() * 0.14));
segment_base_height = trunk_height * (0.9 + (Math.random() * 0.08));
} else {
segment_height = previous_segment_height * (0.94 + (Math.random() * 0.02));
segment_height = Math.max(8, segment_height);
const overlap = previous_segment_height * (0.5 + ((Math.random() - 0.5) * 0.06));
segment_base_height = previous_apex_height - overlap;
}
const progress = Math.max(
0,
Math.min(1, (segment_base_height - trunk_height) / Math.max(1, crown_height_target))
);
const radius_variation = 0.9 + (Math.random() * 0.16);
let radius = Math.max(
2,
((clamped_diameter / 2) * (1 - (progress * 0.75))) * radius_variation
);
if (previous_radius !== null) {
const overlap = previous_apex_height - segment_base_height;
const previous_overlap_ratio = Math.max(
0,
Math.min(1, overlap / previous_segment_height)
);
const previous_overlap_radius = previous_radius * previous_overlap_ratio;
const min_radius_for_overlap = previous_overlap_radius * (1.06 + (Math.random() * 0.05));
const max_radius_for_taper = previous_radius * (0.94 + (Math.random() * 0.03));
radius = Math.max(radius, min_radius_for_overlap);
radius = Math.min(radius, max_radius_for_taper);
if (radius < min_radius_for_overlap) {
radius = min_radius_for_overlap;
}
}
radius = Math.max(2, radius);
const foliage_geometry = new THREE.ConeGeometry(radius, segment_height, 6);
foliage_geometry.rotateX(-Math.PI / 2);
const cone_material = foliage_material.clone();
cone_material.color.offsetHSL(
(Math.random() - 0.5) * 0.01,
(Math.random() - 0.5) * 0.03,
(Math.random() - 0.5) * 0.03
);
const foliage_mesh = new THREE.Mesh(foliage_geometry, cone_material);
foliage_mesh.position.z = -(segment_base_height + (segment_height / 2));
const tilt_direction = Math.random() * Math.PI * 2;
const tilt_amount = Math.random() * max_tilt_radians;
foliage_mesh.rotation.x = Math.cos(tilt_direction) * tilt_amount;
foliage_mesh.rotation.y = Math.sin(tilt_direction) * tilt_amount;
foliage_mesh.rotation.z = (Math.random() - 0.5) * 0.2;
foliage_mesh.castShadow = true;
foliage_mesh.receiveShadow = true;
foliage_mesh.userData.is_environment = true;
foliage_mesh.userData.is_tree_foliage = true;
foliage_mesh.userData.base_tree_foliage_color = foliage_mesh.material.color.getHex();
tree.add(foliage_mesh);
previous_apex_height = segment_base_height + segment_height;
previous_radius = radius;
previous_segment_height = segment_height;
if (previous_apex_height >= clamped_height) {
break;
}
}
return tree;
};
/**
* Sample XY offset from a stick curve at a height measured from the stick base.
*
* @param {{controls: Array<{x: number, y: number}>, height: number}} curve
* @param {number} height_from_base
*
* @return {{x: number, y: number}}
*/
beestat.component.scene.prototype.sample_stick_curve_offset_ = function(curve, height_from_base) {
if (
curve === undefined ||
curve.controls === undefined ||
curve.controls.length < 2 ||
curve.height === undefined ||
curve.height <= 0
) {
return {'x': 0, 'y': 0};
}
const t = Math.max(0, Math.min(1, height_from_base / curve.height));
const scaled = t * (curve.controls.length - 1);
const index = Math.floor(scaled);
const next_index = Math.min(curve.controls.length - 1, index + 1);
const blend = scaled - index;
return {
'x': THREE.MathUtils.lerp(curve.controls[index].x, curve.controls[next_index].x, blend),
'y': THREE.MathUtils.lerp(curve.controls[index].y, curve.controls[next_index].y, blend)
};
};
/**
* Create a low-poly tapered stick mesh with slight bend.
*
* @param {object} config
*
* @return {{mesh: THREE.Mesh, curve: {controls: Array<{x: number, y: number}>, height: number}, radius_top: number, radius_bottom: number, height: number}}
*/
beestat.component.scene.prototype.create_stick_mesh_ = function(config) {
const height = Math.max(1, config.height || 10);
const radius_bottom = Math.max(0.15, config.radius_bottom || 1);
const taper_end_ratio = config.taper_end_ratio === undefined
? null
: Math.max(0, Math.min(1, config.taper_end_ratio));
const taper_max_ratio = config.taper_max_ratio === undefined
? null
: Math.max(0, Math.min(1, config.taper_max_ratio));
const resolved_top_ratio = taper_max_ratio === null
? taper_end_ratio
: (1 - taper_max_ratio);
const radius_top = Math.max(
0,
resolved_top_ratio === null
? (config.radius_top === undefined ? (radius_bottom * 0.7) : config.radius_top)
: (radius_bottom * resolved_top_ratio)
);
const radial_segments = Math.max(3, config.radial_segments || 7);
const height_segments = Math.max(1, config.height_segments || 6);
const control_count = Math.max(2, config.control_count || 5);
const max_drift = Math.max(0, config.max_drift || 0);
const direction_jitter = config.direction_jitter || (radius_bottom * 0.15);
const straight_start_ratio = Math.max(0, Math.min(0.9, config.straight_start_ratio || 0));
const taper_start_ratio = Math.max(0, Math.min(0.95, config.taper_start_ratio || 0));
const controls = [{'x': 0, 'y': 0}];
let drift_x = 0;
let drift_y = 0;
for (let i = 1; i < control_count; i++) {
const progress = i / (control_count - 1);
drift_x += (Math.random() - 0.5) * direction_jitter;
drift_y += (Math.random() - 0.5) * direction_jitter;
const drift_length = Math.sqrt((drift_x * drift_x) + (drift_y * drift_y));
const drift_limit = max_drift * progress;
if (drift_length > drift_limit && drift_length > 0) {
const scale = drift_limit / drift_length;
drift_x *= scale;
drift_y *= scale;
}
controls.push({'x': drift_x, 'y': drift_y});
}
const curve = {
'controls': controls,
'height': height
};
const geometry = new THREE.CylinderGeometry(
radius_bottom,
radius_bottom,
height,
radial_segments,
height_segments
);
geometry.rotateX(-Math.PI / 2);
const position = geometry.attributes.position;
for (let i = 0; i < position.count; i++) {
const vertex_z = position.getZ(i);
const height_from_base = (height / 2) - vertex_z;
const height_ratio = Math.max(0, Math.min(1, height_from_base / height));
const taper_progress = height_ratio <= taper_start_ratio
? 0
: (height_ratio - taper_start_ratio) / Math.max(0.0001, 1 - taper_start_ratio);
const target_radius = THREE.MathUtils.lerp(radius_bottom, radius_top, taper_progress);
const taper_scale = target_radius / radius_bottom;
position.setX(i, position.getX(i) * taper_scale);
position.setY(i, position.getY(i) * taper_scale);
const offset = this.sample_stick_curve_offset_(curve, height_from_base);
if (straight_start_ratio > 0) {
const straight_height = height * straight_start_ratio;
const bend_blend = height_from_base <= straight_height
? (height_from_base / Math.max(0.0001, straight_height))
: 1;
position.setX(i, position.getX(i) + (offset.x * bend_blend));
position.setY(i, position.getY(i) + (offset.y * bend_blend));
} else {
position.setX(i, position.getX(i) + offset.x);
position.setY(i, position.getY(i) + offset.y);
}
}
position.needsUpdate = true;
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, config.material);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.is_environment = true;
return {
'mesh': mesh,
'curve': curve,
'radius_top': radius_top,
'radius_bottom': radius_bottom,
'height': height
};
};
/**
* Get round/oval branch count from tree height.
*
* @param {number} height Total tree height.
*
* @return {number}
*/
beestat.component.scene.prototype.get_round_tree_branch_count_ = function(height) {
return Math.max(1, Math.round(beestat.component.scene.round_tree_branches_per_height * Math.max(0, height || 0)));
};
/**
* Get normalized branch length factor f(x) for round/oval trees.
*
* `x` is normalized distance from trunk base to top in [0, 1]:
* - 0 = trunk base (ground side)
* - 1 = trunk top
*
* @param {string} tree_type round|oval
* @param {number} x Normalized distance up trunk [0, 1]
* @param {number} profile_start_ratio Lower bound for canopy profile in [0, 1).
* Lower values use more trunk height, higher values use less.
*
* @return {number} Branch length factor in [0, 1]
*/
beestat.component.scene.prototype.get_branch_length = function(tree_type, x, profile_start_ratio = 0.5) {
switch (tree_type) {
case 'oval':
// Oval equation over x in [start, 1] with softer top taper than round.
// u = (x - start) / (1 - start), t = 2u - 1, base = sqrt(max(0, 1 - t^2))
const oval_start = Math.max(0, Math.min(0.95, profile_start_ratio));
const oval_span = Math.max(0.0001, 1 - oval_start);
const oval_u = (x - oval_start) / oval_span;
const oval_t = (oval_u * 2) - 1;
return x < oval_start || x > 1
? 0
: Math.max(0, Math.min(1, Math.pow(Math.sqrt(Math.max(0, 1 - (oval_t * oval_t))), 0.82)));
case 'round':
default:
// Round equation over x in [start, 1] using a true circle cross-section:
// u = (x - start) / (1 - start), t = 2u - 1, f(x) = sqrt(max(0, 1 - t^2))
// `start` lowers from 0.5 toward 0 for wide/short trees.
const start = Math.max(0, Math.min(0.9999, profile_start_ratio));
const span = Math.max(0.0001, 1 - start);
const u = (x - start) / span;
const t = (u * 2) - 1;
return x < start || x > 1
? 0
: Math.max(0, Math.min(1, Math.sqrt(Math.max(0, 1 - (t * t)))));
}
};
/**
* Create a low-poly round canopy tree scaffold (trunk + first-level branches).
*
* @param {number} height Total tree height.
* @param {number} max_diameter Maximum canopy diameter.
* @param {boolean} has_foliage Whether foliage should be rendered.
*
* @return {THREE.Group}
*/
beestat.component.scene.prototype.create_round_tree_ = function(height, max_diameter, has_foliage, canopy_shape = 'round') {
const self = this;
const tree = new THREE.Group();
tree.userData.is_environment = true;
tree.userData.is_tree = true;
const max_canopy_radius = Math.max(0.5, max_diameter / 2);
// Use more of trunk height for round profiles when canopy is wide/short.
// If height == diameter, start reaches 0 (full [0, 1] range).
const round_canopy_span_ratio = Math.max(0, Math.min(1, max_diameter / Math.max(1, height)));
const round_canopy_start_ratio = Math.max(0, 1 - round_canopy_span_ratio);
// Oval canopies should generally occupy more trunk height than round canopies.
const oval_canopy_start_ratio = Math.max(0, round_canopy_start_ratio - 0.18);
const foliage_enabled = has_foliage === true;
const wood_material = new THREE.MeshStandardMaterial({
'color': 0x6a4d2f,
'roughness': 0.9,
'metalness': 0.0,
'flatShading': true
});
const trunk_height = height;
const trunk_radius_bottom = Math.max(1.5, trunk_height * 0.03);
const trunk_stick = this.create_stick_mesh_({
'height': trunk_height,
'radius_bottom': trunk_radius_bottom,
'radial_segments': 7,
'height_segments': 8,
'control_count': 6,
'max_drift': 8,
'direction_jitter': 3,
'taper_start_ratio': 0.35,
'taper_max_ratio': 0.72,
'material': wood_material
});
const trunk = trunk_stick.mesh;
trunk.position.z = -(trunk_height / 2) + Math.max(0.7, trunk_radius_bottom * 0.14);
tree.add(trunk);
this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x6a4d2f);
// Single branch layer: starts halfway up trunk and thins/shortens toward the top.
const branch_count = this.get_round_tree_branch_count_(height);
const branches = new THREE.Group();
branches.userData.is_environment = true;
const branch_axis = new THREE.Vector3(0, 0, -1);
const foliage = new THREE.Group();
foliage.userData.is_environment = true;
const canopy_opacity = beestat.component.scene.debug_tree_canopy_opacity;
const foliage_material = new THREE.MeshStandardMaterial({
'color': 0x4f9f2f,
'roughness': 0.82,
'metalness': 0.0,
'flatShading': true,
'transparent': canopy_opacity < 1,
'opacity': canopy_opacity,
'depthWrite': canopy_opacity >= 1,
'side': THREE.DoubleSide
});
const create_canopy_from_branch_function_ = function() {
const center_height = trunk_height * 0.7;
const center_offset_raw = self.sample_stick_curve_offset_(trunk_stick.curve, center_height);
const top_offset = self.sample_stick_curve_offset_(trunk_stick.curve, trunk_height);
// Base canopy center; upper canopy vertices are additionally aligned per-vertex to trunk tip.
const center_offset = {
'x': THREE.MathUtils.lerp(center_offset_raw.x, top_offset.x, 0.45),
'y': THREE.MathUtils.lerp(center_offset_raw.y, top_offset.y, 0.45)
};
const center = new THREE.Vector3(center_offset.x, center_offset.y, -center_height);
const base_radius = Math.max(4, max_canopy_radius * 0.96);
const geometry = new THREE.IcosahedronGeometry(1, 2);
const positions = geometry.attributes.position;
const irregularity = 0.08 + (Math.random() * 0.08);
const noise_phase_a = Math.random() * Math.PI * 2;
const noise_phase_b = Math.random() * Math.PI * 2;
const noise_phase_c = Math.random() * Math.PI * 2;
const noise_freq_a = 2.7 + (Math.random() * 1.2);
const noise_freq_b = 2.3 + (Math.random() * 1.2);
const noise_freq_c = 1.2 + (Math.random() * 0.9);
const lobe_count = 2 + Math.floor(Math.random() * 4);
const lobe_amplitude = 0.05 + (Math.random() * 0.08);
const lobe_phase = Math.random() * Math.PI * 2;
const squash_x = 0.93 + (Math.random() * 0.14);
const squash_y = 0.93 + (Math.random() * 0.14);
const z_wobble = trunk_height * (0.007 + (Math.random() * 0.007));
const tip_cap_strength = trunk_radius_bottom * (0.85 + (Math.random() * 0.45));
const tip_round_power = 1.6 + (Math.random() * 1.1);
const tip_bump_strength = 0.16 + (Math.random() * 0.24);
const canopy_drift_theta = Math.random() * Math.PI * 2;
const canopy_drift_radius = max_canopy_radius * 0.02;
const canopy_drift_x = Math.cos(canopy_drift_theta) * canopy_drift_radius;
const canopy_drift_y = Math.sin(canopy_drift_theta) * canopy_drift_radius;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const z = positions.getZ(i);
const normalized_height = Math.max(0, Math.min(1, (z + 1) / 2));
// Keep canopy vertices distributed across the active profile band instead
// of collapsing many points to zero-radius regions.
const profile_start_ratio = canopy_shape === 'oval'
? oval_canopy_start_ratio
: round_canopy_start_ratio;
const mapped_ratio = profile_start_ratio + (normalized_height * (1 - profile_start_ratio));
// Slightly cap the top sample for oval canopies to avoid a sharp apex.
const canopy_ratio = canopy_shape === 'oval' ? Math.min(0.985, mapped_ratio) : mapped_ratio;
const canopy_z = -trunk_height * canopy_ratio;
const base_factor = Math.max(0, Math.min(1, self.get_branch_length(canopy_shape, canopy_ratio, profile_start_ratio)));
const canopy_factor = base_factor;
const radial_length = Math.sqrt((x * x) + (y * y));
const hx = radial_length > 0.0001 ? x / radial_length : 1;
const hy = radial_length > 0.0001 ? y / radial_length : 0;
const theta = Math.atan2(hy, hx);
const noise =
(Math.sin((hx * noise_freq_a) + (hy * (noise_freq_b - 0.4)) + (canopy_ratio * (noise_freq_a + noise_freq_b)) + noise_phase_a) * 0.5) +
(Math.cos((hx * (noise_freq_b + 0.3)) - (hy * noise_freq_a) - (canopy_ratio * (noise_freq_b + 1.6)) + noise_phase_b) * 0.35) +
(Math.sin((canopy_ratio * (noise_freq_c + 6.6)) + (hx * noise_freq_c) + noise_phase_c) * 0.15);
const lobe = 1 + (Math.sin((theta * lobe_count) + (canopy_ratio * 4.4) + lobe_phase) * lobe_amplitude);
const organic_scale = canopy_factor <= 0 ? 1 : (1 + (noise * irregularity));
const radius = base_radius * canopy_factor * organic_scale * lobe;
// Ensure the canopy retains a small cap around the tip so trunk never pokes through.
const top_cover_t = Math.max(0, Math.min(1, (canopy_ratio - 0.84) / 0.16));
const min_radius_for_tip_cover = trunk_radius_bottom * 0.55 * top_cover_t;
const covered_radius = Math.max(radius, min_radius_for_tip_cover);
const radius_x = covered_radius * squash_x;
const radius_y = covered_radius * squash_y;
const canopy_z_offset = Math.sin((theta * (lobe_count + 1)) + lobe_phase) * z_wobble * canopy_factor;
const top_alignment_t = Math.max(0, Math.min(1, (canopy_ratio - 0.72) / 0.28));
const center_x = THREE.MathUtils.lerp(
center.x + canopy_drift_x,
top_offset.x,
top_alignment_t
);
const center_y = THREE.MathUtils.lerp(
center.y + canopy_drift_y,
top_offset.y,
top_alignment_t
);
const top_center_weight = Math.pow(Math.max(0, 1 - radial_length), tip_round_power);
const tip_bump = 0.5 + (0.5 * Math.sin((theta * (lobe_count + 2)) + lobe_phase + noise_phase_c));
const tip_cap_lift = tip_cap_strength * top_cover_t * top_center_weight * (1 + (tip_bump * tip_bump_strength));
const capped_z_offset = canopy_z_offset * (1 - (top_cover_t * 0.85));
positions.setXYZ(
i,
center_x + (hx * radius_x),
center_y + (hy * radius_y),
canopy_z + capped_z_offset - tip_cap_lift
);
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
const canopy_mesh = new THREE.Mesh(geometry, foliage_material.clone());
canopy_mesh.userData.is_tree_foliage = true;
canopy_mesh.userData.base_tree_foliage_color = canopy_mesh.material.color.getHex();
return {
'mesh': canopy_mesh
};
};
const branch_height_samples = [];
const recursive_depth_limit = 1;
const children_per_branch = 2;
if (foliage_enabled === true && this.tree_foliage_meshes_ === undefined) {
this.tree_foliage_meshes_ = [];
}
if (foliage_enabled === true && this.tree_branch_groups_ === undefined) {
this.tree_branch_groups_ = [];
}
const initial_branch_direction = new THREE.Vector3(1, 0, -0.2).normalize();
const branch_rotation_axis = new THREE.Vector3(0, 0, 1);
const get_next_branch_direction = function(previous_direction) {
const direction = previous_direction.clone().multiplyScalar(-1);
const angle_offset = (Math.PI / 18) + (Math.random() * ((Math.PI / 4) - (Math.PI / 18)));
direction.applyQuaternion(
new THREE.Quaternion().setFromAxisAngle(
branch_rotation_axis,
(Math.random() < 0.5 ? -1 : 1) * angle_offset
)
);
// Keep branches more strongly biased upward in this scene's coordinate system (-Z is up).
direction.z = -Math.max(0.34, Math.abs(direction.z));
return direction.normalize();
};
for (let i = 0; i < branch_count; i++) {
const stratified = branch_count <= 1 ? 0.5 : (i / (branch_count - 1));
const jittered = stratified + ((Math.random() - 0.5) * 0.25 / branch_count);
branch_height_samples.push(Math.max(0, Math.min(1, jittered)));
}
for (let i = branch_height_samples.length - 1; i > 0; i--) {
const swap_index = Math.floor(Math.random() * (i + 1));
const temp = branch_height_samples[i];
branch_height_samples[i] = branch_height_samples[swap_index];
branch_height_samples[swap_index] = temp;
}
const get_stick_point_world = function(branch_info, ratio) {
const clamped_ratio = Math.max(0, Math.min(1, ratio));
const along_height = branch_info.length * clamped_ratio;
const local_offset = self.sample_stick_curve_offset_(branch_info.stick.curve, along_height);
const local_point = new THREE.Vector3(
local_offset.x,
local_offset.y,
(branch_info.length / 2) - along_height
);
return branch_info.mesh.localToWorld(local_point);
};
const create_branch = function(base, direction, length, radius_bottom) {
const horizontal_direction_length = Math.sqrt(
(direction.x * direction.x) + (direction.y * direction.y)
);
if (horizontal_direction_length > 0) {
const base_horizontal_radius = Math.sqrt((base.x * base.x) + (base.y * base.y));
const max_length_from_diameter =
(max_canopy_radius - base_horizontal_radius) / horizontal_direction_length;
if (Number.isFinite(max_length_from_diameter) === true) {
length = Math.max(0, Math.min(length, max_length_from_diameter));
}
}
length = Math.max(0, length);
if (length < 1) {
return null;
}
const branch_stick = self.create_stick_mesh_({
'height': length,
'radius_bottom': radius_bottom,
'radial_segments': 7,
'height_segments': 6,
'control_count': 6,
'max_drift': length * 0.24,
'direction_jitter': length * 0.12,
'straight_start_ratio': 0.2,
'taper_start_ratio': 0.2,
'taper_max_ratio': 1,
'material': wood_material
});
const branch = branch_stick.mesh;
branch.position.copy(base).addScaledVector(direction, (length / 2) - (radius_bottom * 0.45));
branch.quaternion.setFromUnitVectors(branch_axis, direction);
branches.add(branch);
branch.updateMatrixWorld(true);
return {
'mesh': branch,
'stick': branch_stick,
'length': length,
'radius_bottom': radius_bottom,
'direction': direction.clone()
};
};
const add_sub_branches = function(parent_branch, depth) {
if (depth >= recursive_depth_limit) {
return;
}
let previous_child_direction = parent_branch.direction;
for (let j = 0; j < children_per_branch; j++) {
const attach_ratio = children_per_branch <= 1
? 0.6
: 0.35 + (j / (children_per_branch - 1)) * 0.3;
const attach_point = get_stick_point_world(parent_branch, attach_ratio);
const child_length = parent_branch.length * 0.62;
const child_radius_bottom = Math.max(0.15, parent_branch.radius_bottom * 0.62);
const child_direction = get_next_branch_direction(previous_child_direction);
const child_branch = create_branch(
attach_point,
child_direction,
child_length,
child_radius_bottom
);
if (child_branch === null) {
continue;
}
previous_child_direction = child_direction;
add_sub_branches(child_branch, depth + 1);
}
};
let previous_primary_direction = initial_branch_direction;
for (let i = 0; i < branch_count; i++) {
const base_height_ratio = branch_height_samples[i];
const base_height = trunk_height * base_height_ratio;
const base_offset = this.sample_stick_curve_offset_(trunk_stick.curve, base_height);
const branch_profile_start_ratio = canopy_shape === 'oval'
? oval_canopy_start_ratio
: round_canopy_start_ratio;
const branch_length_factor = this.get_branch_length(
canopy_shape,
base_height_ratio,
branch_profile_start_ratio,
);
const branch_length = max_canopy_radius * branch_length_factor;
if (branch_length <= 0) {
continue;
}
const branch_radius_bottom = Math.max(0.35, trunk_radius_bottom * (0.42 - (base_height_ratio * 0.26)));
const base = new THREE.Vector3(
base_offset.x,
base_offset.y,
trunk.position.z + (trunk_height / 2) - base_height
);
const primary_direction = get_next_branch_direction(previous_primary_direction);
const primary_branch = create_branch(
base,
primary_direction,
branch_length,
branch_radius_bottom
);
if (primary_branch === null) {
continue;
}
previous_primary_direction = primary_direction;
add_sub_branches(primary_branch, 0);
}
if (foliage_enabled === true) {
const canopy_result = create_canopy_from_branch_function_();
const canopy_mesh = canopy_result.mesh;
canopy_mesh.castShadow = true;
canopy_mesh.receiveShadow = true;
canopy_mesh.userData.is_environment = true;
foliage.add(canopy_mesh);
this.tree_foliage_meshes_.push(canopy_mesh);
}
if (foliage_enabled === true) {
this.tree_branch_groups_.push(branches);
}
branches.visible = foliage_enabled !== true;
tree.add(branches);
if (foliage_enabled === true) {
tree.add(foliage);
}
return tree;
};
/**
* Create a low-poly oval canopy tree.
*
* @param {number} height Total tree height.
* @param {number} max_diameter Maximum canopy diameter.
* @param {boolean} has_foliage Whether foliage should be rendered.
*
* @return {THREE.Group}
*/
beestat.component.scene.prototype.create_oval_tree_ = function(height, max_diameter, has_foliage) {
return this.create_round_tree_(height, max_diameter, has_foliage, 'oval');
};
/**
* Get seasonal foliage color and visibility from current date.
*
* @return {{color: THREE.Color, visible: boolean}}
*/
beestat.component.scene.prototype.get_tree_foliage_state_ = function() {
const colors = beestat.component.scene.tree_foliage_colors;
const summer = new THREE.Color(colors.summer);
const fall_early = new THREE.Color(colors.fall_early);
const fall_late = new THREE.Color(colors.fall_late);
const winter = new THREE.Color(colors.winter);
if (this.date_ === undefined || typeof this.date_.month !== 'function') {
return {
'color': summer,
'visible': true
};
}
const month = this.date_.month() + 1; // 1-12
const day = this.date_.date();
const day_ratio = Math.max(0, Math.min(1, (day - 1) / 30));
const color = summer.clone();
const visible = month >= 4 && month <= 10;
if (month === 9) {
color.lerp(fall_early, day_ratio);
} else if (month === 10) {
color.copy(fall_early).lerp(fall_late, day_ratio);
} else if (visible === false) {
color.copy(winter);
} else {
color.copy(summer);
}
return {
'color': color,
'visible': visible
};
};
/**
* Apply seasonal foliage appearance to round/oval canopy meshes.
*/
beestat.component.scene.prototype.update_tree_foliage_season_ = function() {
const has_foliage_meshes = this.tree_foliage_meshes_ !== undefined && this.tree_foliage_meshes_.length > 0;
const has_branch_groups = this.tree_branch_groups_ !== undefined && this.tree_branch_groups_.length > 0;
if (has_foliage_meshes === false && has_branch_groups === false) {
return;
}
const state = this.get_tree_foliage_state_();
if (has_foliage_meshes === true) {
for (let i = 0; i < this.tree_foliage_meshes_.length; i++) {
const mesh = this.tree_foliage_meshes_[i];
if (mesh === undefined || mesh.material === undefined) {
continue;
}
mesh.material.color.copy(state.color);
mesh.userData.base_tree_foliage_color = state.color.getHex();
mesh.material.opacity = beestat.component.scene.debug_tree_canopy_opacity;
mesh.material.transparent = beestat.component.scene.debug_tree_canopy_opacity < 1;
mesh.material.depthWrite = beestat.component.scene.debug_tree_canopy_opacity >= 1;
mesh.material.needsUpdate = true;
mesh.visible = state.visible;
}
}
if (has_branch_groups === true) {
for (let i = 0; i < this.tree_branch_groups_.length; i++) {
const branch_group = this.tree_branch_groups_[i];
if (branch_group !== undefined) {
// Hide branches when canopy is visible; show them when canopy is not visible.
branch_group.visible = state.visible !== true;
}
}
}
};
/**
* Add trees from floor plan data.
*
* @param {number} ground_surface_z
*/
beestat.component.scene.prototype.add_trees_ = function(ground_surface_z) {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const tree_group = new THREE.Group();
tree_group.userData.is_environment = true;
this.environment_group_.add(tree_group);
this.tree_foliage_meshes_ = [];
this.tree_branch_groups_ = [];
const foliage_enabled = beestat.component.scene.environment_tree_foliage_enabled;
const trees = [];
floor_plan.data.groups.forEach(function(group) {
if (Array.isArray(group.trees) === true) {
group.trees.forEach(function(tree) {
trees.push(tree);
});
}
});
trees.forEach(function(tree_data) {
const tree_type = ['conical', 'round', 'oval'].includes(tree_data.type)
? tree_data.type
: 'round';
const tree_height = Math.max(1, Number(tree_data.height || 0));
const tree_diameter = Math.max(1, Number(tree_data.diameter || 0));
const tree_x = Number(tree_data.x || 0);
const tree_y = Number(tree_data.y || 0);
let tree;
if (tree_type === 'conical') {
tree = this.create_conical_tree_(tree_height, tree_diameter, foliage_enabled);
} else if (tree_type === 'oval') {
tree = this.create_oval_tree_(tree_height, tree_diameter, foliage_enabled);
} else {
tree = this.create_round_tree_(tree_height, tree_diameter, foliage_enabled);
}
tree.position.set(tree_x, tree_y, ground_surface_z);
tree.rotation.z = 0;
tree_group.add(tree);
}, this);
this.update_tree_foliage_season_();
};
/**
* Add environment layers (grass and earth strata) below the house.
*/
beestat.component.scene.prototype.add_environment_ = function() {
const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_];
const bounding_box = this.get_scene_bounding_box_();
const center_x = (bounding_box.right + bounding_box.left) / 2;
const center_y = (bounding_box.bottom + bounding_box.top) / 2;
const plan_width = bounding_box.right - bounding_box.left;
const plan_height = bounding_box.bottom - bounding_box.top;
// Find the minimum elevation to position the ground flush with the lowest floor.
let min_elevation = 0;
floor_plan.data.groups.forEach(function(group) {
const elevation = group.elevation || 0;
if (elevation < min_elevation) {
min_elevation = elevation;
}
});
// Position the ground flush with the base of the house (hides any below-ground structures).
let current_z = 0;
const padding = beestat.component.scene.environment_padding;
const ground_color = this.get_appearance_value_('ground_color');
const strata = [
{'color': ground_color, 'thickness': 10, 'roughness': 0.95},
{'color': 0x4a3f35, 'thickness': 60, 'roughness': 0.85},
{'color': 0x6b5d4f, 'thickness': 60, 'roughness': 0.85},
{'color': 0x4a3f35, 'thickness': 60, 'roughness': 0.85}
];
// Create environment group for ground strata
this.environment_group_ = new THREE.Group();
this.main_group_.add(this.environment_group_);
this.layers_['environment'] = this.environment_group_;
this.environment_surface_group_ = new THREE.Group();
this.environment_surface_group_.userData.is_environment = true;
this.environment_group_.add(this.environment_surface_group_);
this.add_surfaces_to_environment_(this.environment_surface_group_);
strata.forEach(function(stratum, index) {
const geometry = new THREE.BoxGeometry(
plan_width + padding * 2,
plan_height + padding * 2,
stratum.thickness
);
const material = new THREE.MeshStandardMaterial({
'color': stratum.color,
'roughness': stratum.roughness,
'metalness': 0.0
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = center_x;
mesh.position.y = center_y;
mesh.position.z = current_z + stratum.thickness / 2;
mesh.userData.is_environment = true;
if (index === 0) {
mesh.userData.is_ground = true;
}
mesh.receiveShadow = true;
this.environment_group_.add(mesh);
current_z += stratum.thickness;
}, this);
const ground_surface_z = 0;
this.add_trees_(ground_surface_z);
// Add celestial lights (sun and moon) - toggled with environment visibility
this.add_celestial_lights_();
this.add_weather_(center_x, center_y, plan_width, plan_height);
};
/**
* Add procedural weather particles based on floor plan appearance.
*
* @param {number} center_x
* @param {number} center_y
* @param {number} plan_width
* @param {number} plan_height
*/
beestat.component.scene.prototype.add_weather_ = function(center_x, center_y, plan_width, plan_height) {
const padding = beestat.component.scene.environment_padding + 120;
const bounds = {
'min_x': center_x - ((plan_width + padding * 2) / 2),
'max_x': center_x + ((plan_width + padding * 2) / 2),
'min_y': center_y - ((plan_height + padding * 2) / 2),
'max_y': center_y + ((plan_height + padding * 2) / 2),
'min_z': -780,
'max_z': 140
};
this.weather_group_ = new THREE.Group();
this.weather_group_.userData.is_environment = true;
this.environment_group_.add(this.weather_group_);
if (this.cloud_texture_ === undefined) {
this.cloud_texture_ = this.create_cloud_texture_();
}
if (this.snow_particle_texture_ === undefined) {
this.snow_particle_texture_ = this.create_snow_particle_texture_();
}
if (this.rain_particle_texture_ === undefined) {
this.rain_particle_texture_ = this.create_rain_particle_texture_();
}
const cloud_count = beestat.component.scene.weather_cloud_max_count;
const cloud_opacity = 0.2;
const cloud_bounds = {
'min_x': bounds.min_x - 260,
'max_x': bounds.max_x + 260,
'min_y': bounds.min_y - 260,
'max_y': bounds.max_y + 260,
'z': -760
};
this.cloud_bounds_ = cloud_bounds;
this.cloud_sprites_ = [];
this.cloud_motion_ = [];
for (let i = 0; i < cloud_count; i++) {
const cloud_material = new THREE.SpriteMaterial({
'map': this.cloud_texture_,
'color': 0xdce3ee,
'transparent': true,
'opacity': 0,
'depthWrite': false,
'depthTest': true
});
const cloud = new THREE.Sprite(cloud_material);
cloud.position.set(
cloud_bounds.min_x + Math.random() * (cloud_bounds.max_x - cloud_bounds.min_x),
cloud_bounds.min_y + Math.random() * (cloud_bounds.max_y - cloud_bounds.min_y),
cloud_bounds.z + (Math.random() * 130)
);
const cloud_size = 520 + Math.random() * 560;
cloud.scale.set(cloud_size, cloud_size * 0.6, 1);
cloud.layers.set(beestat.component.scene.layer_visible);
cloud.userData.is_environment = true;
this.weather_group_.add(cloud);
this.cloud_sprites_.push(cloud);
this.cloud_motion_.push({
'base_x': cloud.position.x,
'base_y': cloud.position.y,
'base_z': cloud.position.z,
'base_scale_x': cloud.scale.x,
'base_scale_y': cloud.scale.y,
'base_opacity': cloud_opacity,
'phase': Math.random() * Math.PI * 2,
'pulse_speed': 0.36 + (Math.random() * 0.32),
'scale_wobble_x': 0.03 + (Math.random() * 0.03),
'scale_wobble_y': 0.025 + (Math.random() * 0.025),
'opacity_wobble': 0.05 + (Math.random() * 0.05),
'wiggle_x': 10 + (Math.random() * 16),
'wiggle_y': 8 + (Math.random() * 14),
'wiggle_z': 3 + (Math.random() * 5),
'wiggle_freq_x': 1.8 + (Math.random() * 1.6),
'wiggle_freq_y': 1.5 + (Math.random() * 1.3),
'wiggle_freq_z': 1.2 + (Math.random() * 1.1)
});
}
this.rain_particles_ = this.create_precipitation_system_(
bounds,
beestat.component.scene.weather_rain_max_count,
{
'size': 11,
'color': 0xa8c7ff,
'opacity': 0.7,
'speed_min': 280,
'speed_max': 430,
'drift': 28,
'texture': this.rain_particle_texture_
}
);
this.weather_group_.add(this.rain_particles_.points);
this.snow_particles_ = this.create_precipitation_system_(
bounds,
beestat.component.scene.weather_snow_max_count,
{
'size': 10,
'color': 0xffffff,
'opacity': 0.75,
'speed_min': 18,
'speed_max': 44,
'drift': 12,
'texture': this.snow_particle_texture_
}
);
this.weather_group_.add(this.snow_particles_.points);
this.weather_last_update_ms_ = window.performance.now();
const initial_weather_profile = this.get_weather_profile_(this.get_appearance_value_('weather'));
this.weather_profile_target_ = initial_weather_profile;
this.current_cloud_count_ = initial_weather_profile.cloud_count;
this.current_rain_count_ = initial_weather_profile.rain_count;
this.current_snow_count_ = initial_weather_profile.snow_count;
this.update_weather_targets_();
this.update_snow_surface_colors_(this.get_snow_cover_blend_());
};
/**
* 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 the gradient.
*
* @param {boolean} gradient
*
* @return {beestat.component.scene}
*/
beestat.component.scene.prototype.set_gradient = function(gradient) {
this.gradient_ = gradient;
return this;
};
/**
* Get the state of the camera.
*
* @return {object}
*/
beestat.component.scene.prototype.get_camera_state = function() {
return this.camera_.matrix.toArray();
};
/**
* Restore the state of the camera.
*
* @param {object} camera_state
*/
beestat.component.scene.prototype.set_camera_state = function(camera_state) {
this.camera_.matrix.fromArray(camera_state);
this.camera_.matrix.decompose(
this.camera_.position,
this.camera_.quaternion,
this.camera_.scale
);
};
/**
* 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;
};
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.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.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();
}
// 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;
};